From 9e91f18dfd9ab0d9c1ecf0e7857d9e87097b26df Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 24 Sep 2026 17:06:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(category-config):=20=E7=B1=BB=E7=9B=AE?= =?UTF-8?q?=E8=A7=84=E6=A8=A1=E5=90=88=E5=B9=B6=E4=B8=BA=E5=8D=95=E8=A1=A8?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E7=BB=93=E6=9E=84=EF=BC=88=E4=B8=80=E7=BA=A7?= =?UTF-8?q?=E7=B1=BB=E7=9B=AE/=E5=88=86=E7=B1=BB=E5=B9=B6=E5=85=A5?= =?UTF-8?q?=E8=A7=84=E6=A8=A1=E8=A1=8C=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E6=A1=A3=E4=BD=8D=E6=A8=A1=E6=9D=BF=E8=A1=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 15 + furniture-3d-system/prisma/schema.prisma | 19 +- .../category-config/group-tiers/[id]/route.ts | 66 ----- .../group-tiers/export/route.ts | 53 ---- .../group-tiers/import/route.ts | 100 ------- .../category-config/group-tiers/route.ts | 68 ----- .../category-config/scale-rows/[id]/route.ts | 3 + .../scale-rows/export/route.ts | 16 +- .../groups/route.ts | 4 +- .../category-config/scale-rows/route.ts | 2 + .../category-config/CategoryConfigPanel.tsx | 92 +++--- .../category-config/CategoryEditDialog.tsx | 101 ------- ...-archive-category-group-tier.repository.ts | 159 ---------- ...t-archive-category-scale-row.repository.ts | 84 +++++- ...rchive-category-group-tier.service.test.ts | 157 ---------- ...uct-archive-category-group-tier.service.ts | 279 ------------------ ...duct-archive-category-scale-row.service.ts | 245 +++++++-------- 17 files changed, 265 insertions(+), 1198 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260924160000_merge_category_scale_one_table/migration.sql delete mode 100644 furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/[id]/route.ts delete mode 100644 furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/export/route.ts delete mode 100644 furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/import/route.ts delete mode 100644 furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/route.ts rename furniture-3d-system/src/app/api/supply-chain/category-config/{group-tiers => scale-rows}/groups/route.ts (64%) delete mode 100644 furniture-3d-system/src/app/supply-chain/database/category-config/CategoryEditDialog.tsx delete mode 100644 furniture-3d-system/src/server/repositories/supply-chain-product-archive-category-group-tier.repository.ts delete mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-category-group-tier.service.test.ts delete mode 100644 furniture-3d-system/src/server/services/supply-chain-product-archive-category-group-tier.service.ts diff --git a/furniture-3d-system/prisma/migrations/20260924160000_merge_category_scale_one_table/migration.sql b/furniture-3d-system/prisma/migrations/20260924160000_merge_category_scale_one_table/migration.sql new file mode 100644 index 00000000..7b849d94 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260924160000_merge_category_scale_one_table/migration.sql @@ -0,0 +1,15 @@ +-- 合并为单表:类目规模行表自带一级类目与分类列,删除分类档位模板表 +ALTER TABLE "SupplyChainProductArchiveCategoryScaleRow" ADD COLUMN "categoryL1" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "SupplyChainProductArchiveCategoryScaleRow" ADD COLUMN "categoryGroup" TEXT NOT NULL DEFAULT ''; + +-- 从类目主表回填一级类目与分类 +UPDATE "SupplyChainProductArchiveCategoryScaleRow" s +SET "categoryL1" = c."categoryL1", + "categoryGroup" = c."categoryGroup" +FROM "SupplyChainProductArchiveCategoryConfig" c +WHERE s."categoryL2" = c."categoryL2"; + +CREATE INDEX "SupplyChainProductArchiveCategoryScaleRow_categoryGroup_idx" ON "SupplyChainProductArchiveCategoryScaleRow"("categoryGroup"); + +-- 删除分类档位模板表(其数据已按类目展开至规模行表) +DROP TABLE "SupplyChainProductArchiveCategoryGroupTier"; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 58c49824..b5a67836 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -940,25 +940,11 @@ model SupplyChainProductArchiveCategoryConfig { @@index([status]) } -model SupplyChainProductArchiveCategoryGroupTier { - id String @id @default(cuid()) - categoryGroup String - scaleLabel String - performanceStart Decimal @db.Decimal(14, 2) - performanceEnd Decimal? @db.Decimal(14, 2) - dailySalesStart Decimal @db.Decimal(12, 2) - dailySalesEnd Decimal? @db.Decimal(12, 2) - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([categoryGroup, scaleLabel]) - @@index([categoryGroup]) -} - model SupplyChainProductArchiveCategoryScaleRow { id String @id @default(cuid()) + categoryL1 String @default("") categoryL2 String + categoryGroup String @default("") scaleLabel String performanceStart Decimal? @db.Decimal(14, 2) performanceEnd Decimal? @db.Decimal(14, 2) @@ -970,6 +956,7 @@ model SupplyChainProductArchiveCategoryScaleRow { @@unique([categoryL2, scaleLabel]) @@index([categoryL2]) + @@index([categoryGroup]) } model SupplyChainProductArchiveWarehouseConfig { diff --git a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/[id]/route.ts deleted file mode 100644 index ad566930..00000000 --- a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/[id]/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NextRequest } from 'next/server' -import { z } from 'zod' -import { ErrorCode } from '@/server/core/errors' -import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { CATEGORY_GROUP_SCALE_LABELS } from '@/lib/supply-chain-product-archive-category-config' -import { supplyChainProductArchiveCategoryGroupTierService } from '@/server/services/supply-chain-product-archive-category-group-tier.service' - -const amountSchema = z.union([z.number(), z.string()]) -const optionalAmountSchema = z.union([z.number(), z.string(), z.null()]).optional() - -const updateSchema = z.object({ - categoryGroup: z.string().trim().min(1).max(80), - scaleLabel: z.enum(CATEGORY_GROUP_SCALE_LABELS), - performanceStart: amountSchema, - performanceEnd: optionalAmountSchema, - dailySalesStart: amountSchema, - dailySalesEnd: optionalAmountSchema, -}).strict() - -export async function PATCH( - request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { - return handleRequest( - request, - async (ctx) => { - const { id } = await context.params - let body: unknown - try { - body = await request.json() - } catch { - return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '请求体格式不正确', 400, ctx.requestId) - } - const parsed = updateSchema.safeParse(body) - if (!parsed.success) { - return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分类档位内容不正确', 400, ctx.requestId) - } - const result = await supplyChainProductArchiveCategoryGroupTierService.update({ id, ...parsed.data }) - return jsonSuccess(result, 200, ctx.requestId) - }, - { - requireAuth: true, - allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], - allowedBusinessRoles: ['MANAGEMENT'], - }, - ) -} - -export async function DELETE( - request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { - return handleRequest( - request, - async (ctx) => { - const { id } = await context.params - await supplyChainProductArchiveCategoryGroupTierService.remove(id) - return jsonSuccess({ id }, 200, ctx.requestId) - }, - { - requireAuth: true, - allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], - allowedBusinessRoles: ['MANAGEMENT'], - }, - ) -} diff --git a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/export/route.ts b/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/export/route.ts deleted file mode 100644 index a27a0320..00000000 --- a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/export/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import * as XLSX from 'xlsx' -import { z } from 'zod' -import { handleRequest, jsonError } from '@/server/core/response' -import { ErrorCode } from '@/server/core/errors' -import { supplyChainProductArchiveCategoryGroupTierService } from '@/server/services/supply-chain-product-archive-category-group-tier.service' - -const exportQuerySchema = z.object({ - categoryGroup: z.string().trim().max(80).optional(), -}) - -export async function GET(request: NextRequest) { - return handleRequest(request, async (ctx) => { - const parsed = exportQuerySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) - if (!parsed.success) { - return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分类档位导出条件不正确', 400, ctx.requestId) - } - const items = await supplyChainProductArchiveCategoryGroupTierService.listAll(parsed.data) - - const headers = ['分类', '规模标签', '业绩起点', '业绩终点', '日销起点', '日销终点'] - const sheet = XLSX.utils.aoa_to_sheet([ - headers, - ...items.map((item) => [ - item.categoryGroup, - item.scaleLabel, - item.performanceStart, - item.performanceEnd ?? '', - item.dailySalesStart, - item.dailySalesEnd ?? '', - ]), - ]) - sheet['!cols'] = headers.map((header) => ({ wch: Math.max(14, header.length * 2 + 6) })) - const workbook = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(workbook, sheet, '分类档位') - const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer - - const timestamp = new Date().toISOString().slice(0, 10).replaceAll('-', '') - const fileName = `分类档位-${timestamp}.xlsx` - return new NextResponse(new Uint8Array(content), { - status: 200, - headers: { - 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`, - 'Cache-Control': 'private, no-store, max-age=0', - 'X-Content-Type-Options': 'nosniff', - }, - }) - }, { - requireAuth: true, - allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], - allowedBusinessRoles: ['MANAGEMENT'], - }) -} diff --git a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/import/route.ts b/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/import/route.ts deleted file mode 100644 index fe4d8de2..00000000 --- a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/import/route.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import * as XLSX from 'xlsx' -import type { CategoryGroupTierImportProgress, CategoryGroupTierImportStreamEvent } from '@/lib/supply-chain-product-archive-category-config' -import { ErrorCode, isAppError } from '@/server/core/errors' -import { handleRequest, jsonError } from '@/server/core/response' -import { supplyChainProductArchiveCategoryGroupTierService } from '@/server/services/supply-chain-product-archive-category-group-tier.service' - -const HEADER_MAP: Record = { - 一级类目: 'categoryL1', - 二级类目: 'categoryL2', - 分类: 'categoryGroup', - 规模标签: 'scaleLabel', - '业绩起点(元)': 'performanceStart', - 业绩起点: 'performanceStart', - 业绩终点: 'performanceEnd', - '日销起点(件/天)': 'dailySalesStart', - 日销起点: 'dailySalesStart', - 日销终点: 'dailySalesEnd', - categoryGroup: 'categoryGroup', - scaleLabel: 'scaleLabel', - performanceStart: 'performanceStart', - performanceEnd: 'performanceEnd', - dailySalesStart: 'dailySalesStart', - dailySalesEnd: 'dailySalesEnd', -} - -const IMPORT_MAX_ROWS = 5000 - -export async function POST(request: NextRequest) { - return handleRequest(request, async (ctx) => { - const form = await request.formData() - const file = form.get('file') - if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId) - if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId) - const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: false }) - const sheet = workbook.Sheets[workbook.SheetNames[0]] - if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId) - const rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: '' }) - const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => { - const key = HEADER_MAP[header.trim()] - return key ? [[key, value]] : [] - }))) - if (rows.length === 0) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId) - if (rows.length > IMPORT_MAX_ROWS) { - return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, `单次最多导入${IMPORT_MAX_ROWS}行`, 400, ctx.requestId) - } - - const encoder = new TextEncoder() - const stream = new ReadableStream({ - start(controller) { - let streamClosed = false - let progress: CategoryGroupTierImportProgress = { processed: 0, total: rows.length, created: 0, updated: 0, failed: 0 } - const send = (event: CategoryGroupTierImportStreamEvent) => { - if (streamClosed) return - try { - controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)) - } catch { - streamClosed = true - } - } - const close = () => { - if (streamClosed) return - streamClosed = true - controller.close() - } - - send({ type: 'progress', progress }) - void supplyChainProductArchiveCategoryGroupTierService.importRows(rows, (nextProgress) => { - progress = nextProgress - send({ type: 'progress', progress }) - }).then((result) => { - progress = { processed: rows.length, total: rows.length, created: result.created, updated: result.updated, failed: result.failed } - send({ type: 'complete', progress, failures: result.failures }) - close() - }).catch((error: unknown) => { - console.error(`[${ctx.requestId}] Category group tier import stream failed:`, error) - send({ - type: 'error', - progress, - message: isAppError(error) ? error.message : '导入过程中发生错误', - }) - close() - }) - }, - }) - - return new NextResponse(stream, { - status: 200, - headers: { - 'Content-Type': 'application/x-ndjson; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - 'X-Accel-Buffering': 'no', - }, - }) - }, { - requireAuth: true, - allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], - allowedBusinessRoles: ['MANAGEMENT'], - }) -} diff --git a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/route.ts b/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/route.ts deleted file mode 100644 index e987b264..00000000 --- a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { NextRequest } from 'next/server' -import { z } from 'zod' -import { ErrorCode } from '@/server/core/errors' -import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { CATEGORY_GROUP_SCALE_LABELS } from '@/lib/supply-chain-product-archive-category-config' -import { supplyChainProductArchiveCategoryGroupTierService } from '@/server/services/supply-chain-product-archive-category-group-tier.service' - -const listQuerySchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - pageSize: z.coerce.number().int().min(1).max(200).default(100), - search: z.string().trim().max(80).optional(), -}) - -const amountSchema = z.union([z.number(), z.string()]) -const optionalAmountSchema = z.union([z.number(), z.string(), z.null()]).optional() - -const createSchema = z.object({ - categoryGroup: z.string().trim().min(1).max(80), - scaleLabel: z.enum(CATEGORY_GROUP_SCALE_LABELS), - performanceStart: amountSchema, - performanceEnd: optionalAmountSchema, - dailySalesStart: amountSchema, - dailySalesEnd: optionalAmountSchema, -}).strict() - -export async function GET(request: NextRequest) { - return handleRequest( - request, - async (ctx) => { - const parsed = listQuerySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries())) - if (!parsed.success) { - return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分类档位筛选条件不正确', 400, ctx.requestId) - } - const result = await supplyChainProductArchiveCategoryGroupTierService.list(parsed.data) - return jsonSuccess(result, 200, ctx.requestId) - }, - { - requireAuth: true, - allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], - allowedBusinessRoles: ['MANAGEMENT'], - }, - ) -} - -export async function POST(request: NextRequest) { - return handleRequest( - request, - async (ctx) => { - let body: unknown - try { - body = await request.json() - } catch { - return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '请求体格式不正确', 400, ctx.requestId) - } - const parsed = createSchema.safeParse(body) - if (!parsed.success) { - return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分类、规模标签和起点不能为空', 400, ctx.requestId) - } - const result = await supplyChainProductArchiveCategoryGroupTierService.create(parsed.data) - return jsonSuccess(result, 201, ctx.requestId) - }, - { - requireAuth: true, - allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], - allowedBusinessRoles: ['MANAGEMENT'], - }, - ) -} diff --git a/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/[id]/route.ts index 686848f8..06c9323e 100644 --- a/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/[id]/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/[id]/route.ts @@ -8,6 +8,9 @@ const amountSchema = z.union([z.number(), z.string()]) const optionalAmountSchema = z.union([z.number(), z.string(), z.null()]).optional() const updateSchema = z.object({ + categoryL1: z.string().trim().min(1).max(160), + categoryL2: z.string().trim().min(1).max(160), + categoryGroup: z.string().trim().max(80).optional(), scaleLabel: z.string().trim().min(1).max(20), performanceStart: amountSchema, performanceEnd: optionalAmountSchema, diff --git a/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/export/route.ts b/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/export/route.ts index f145ff4e..ad2aed4c 100644 --- a/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/export/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/export/route.ts @@ -17,22 +17,16 @@ export async function GET(request: NextRequest) { if (!parsed.success) { return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '规模行导出条件不正确', 400, ctx.requestId) } - const items = await supplyChainProductArchiveCategoryScaleRowService.listAll() - - const filtered = items.filter((item) => { - if (parsed.data.categoryGroup && item.categoryGroup !== parsed.data.categoryGroup) return false - if (parsed.data.scaleLabel && item.scaleLabel !== parsed.data.scaleLabel) return false - if (parsed.data.search) { - const keyword = parsed.data.search.toLowerCase() - if (!`${item.categoryL1}${item.categoryL2}${item.categoryGroup}${item.scaleLabel}`.toLowerCase().includes(keyword)) return false - } - return true + const items = await supplyChainProductArchiveCategoryScaleRowService.listAll({ + search: parsed.data.search, + categoryGroup: parsed.data.categoryGroup, + scaleLabel: parsed.data.scaleLabel, }) const headers = ['一级类目', '二级类目', '分类', '规模标签', '业绩起点(元)', '业绩终点', '日销起点(件/天)', '日销终点'] const sheet = XLSX.utils.aoa_to_sheet([ headers, - ...filtered.map((item) => [ + ...items.map((item) => [ item.categoryL1, item.categoryL2, item.categoryGroup, diff --git a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/groups/route.ts b/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/groups/route.ts similarity index 64% rename from furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/groups/route.ts rename to furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/groups/route.ts index efc3da2e..e0a4c0a7 100644 --- a/furniture-3d-system/src/app/api/supply-chain/category-config/group-tiers/groups/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/groups/route.ts @@ -1,12 +1,12 @@ import { NextRequest } from 'next/server' import { handleRequest, jsonSuccess } from '@/server/core/response' -import { supplyChainProductArchiveCategoryGroupTierService } from '@/server/services/supply-chain-product-archive-category-group-tier.service' +import { supplyChainProductArchiveCategoryScaleRowService } from '@/server/services/supply-chain-product-archive-category-scale-row.service' export async function GET(request: NextRequest) { return handleRequest( request, async (ctx) => { - const groups = await supplyChainProductArchiveCategoryGroupTierService.listGroups() + const groups = await supplyChainProductArchiveCategoryScaleRowService.listGroups() return jsonSuccess({ groups }, 200, ctx.requestId) }, { diff --git a/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/route.ts b/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/route.ts index 9f4d6491..6001a68b 100644 --- a/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/category-config/scale-rows/route.ts @@ -16,7 +16,9 @@ const amountSchema = z.union([z.number(), z.string()]) const optionalAmountSchema = z.union([z.number(), z.string(), z.null()]).optional() const createSchema = z.object({ + categoryL1: z.string().trim().min(1).max(160), categoryL2: z.string().trim().min(1).max(160), + categoryGroup: z.string().trim().max(80).optional(), scaleLabel: z.string().trim().min(1).max(20), performanceStart: amountSchema, performanceEnd: optionalAmountSchema, diff --git a/furniture-3d-system/src/app/supply-chain/database/category-config/CategoryConfigPanel.tsx b/furniture-3d-system/src/app/supply-chain/database/category-config/CategoryConfigPanel.tsx index ae7faa5b..63c9ec53 100644 --- a/furniture-3d-system/src/app/supply-chain/database/category-config/CategoryConfigPanel.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/category-config/CategoryConfigPanel.tsx @@ -5,19 +5,17 @@ import { ArrowLeft, ArrowRight, Download, Edit3, FileDown, Plus, RefreshCw, Sear import { CATEGORY_GROUP_SCALE_LABELS, type ProductArchiveCategoryScaleRow } from '@/lib/supply-chain-product-archive-category-config' import { CategoryConfigImportProgress } from './CategoryConfigImportProgress' import { useCategoryConfigImport } from './useCategoryConfigImport' -import { CategoryEditDialog, type CategoryDraft } from './CategoryEditDialog' import { readCategoryApiResponse } from './api' type PageInfo = { page: number; pageSize: number; total: number; totalPages: number } type GroupFilter = 'ALL' | 'NONE' | string type LabelFilter = 'ALL' | string -type ScaleRowDraft = { - id: string +type RowDraft = { + id?: string categoryL1: string categoryL2: string categoryGroup: string - status: 'ACTIVE' | 'INACTIVE' scaleLabel: string performanceStart: string performanceEnd: string @@ -56,9 +54,8 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { const [groups, setGroups] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [rowDraft, setRowDraft] = useState(null) + const [rowDraft, setRowDraft] = useState(null) const [savingRow, setSavingRow] = useState(false) - const [categoryDraft, setCategoryDraft] = useState(null) const [exporting, setExporting] = useState(false) const load = useCallback(async () => { @@ -82,7 +79,7 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { const loadGroups = useCallback(async () => { try { - const data = await readCategoryApiResponse(await fetch('/api/supply-chain/category-config/group-tiers/groups', { cache: 'no-store' })) + const data = await readCategoryApiResponse(await fetch('/api/supply-chain/category-config/scale-rows/groups', { cache: 'no-store' })) setGroups(data.groups ?? []) } catch { // 分类选项加载失败不阻塞主列表 @@ -110,13 +107,17 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { if (nextPage !== page) setPage(nextPage) } - const openCreateCategory = () => { + const openCreateRow = () => { if (!canEdit) return - setCategoryDraft({ + setRowDraft({ categoryL1: '', categoryL2: '', categoryGroup: '', - status: 'ACTIVE', + scaleLabel: '爆款', + performanceStart: '', + performanceEnd: '', + dailySalesStart: '', + dailySalesEnd: '', }) } @@ -127,7 +128,6 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { categoryL1: item.categoryL1, categoryL2: item.categoryL2, categoryGroup: item.categoryGroup, - status: 'ACTIVE', scaleLabel: item.scaleLabel, performanceStart: item.performanceStart ?? '', performanceEnd: item.performanceEnd ?? '', @@ -142,6 +142,7 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { try { await readCategoryApiResponse(await fetch(`/api/supply-chain/category-config/scale-rows/${encodeURIComponent(item.id)}`, { method: 'DELETE' })) await load() + await loadGroups() } catch (cause) { window.alert(cause instanceof Error ? cause.message : '删除失败') } @@ -154,6 +155,10 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { const performanceEnd = parseAmountInput(rowDraft.performanceEnd) const dailySalesStart = parseAmountInput(rowDraft.dailySalesStart) const dailySalesEnd = parseAmountInput(rowDraft.dailySalesEnd) + if (!rowDraft.categoryL1.trim() || !rowDraft.categoryL2.trim()) { + window.alert('一级类目、二级类目不能为空') + return + } if (performanceStart === null || Number.isNaN(performanceStart) || Number.isNaN(performanceEnd) || dailySalesStart === null || Number.isNaN(dailySalesStart) || Number.isNaN(dailySalesEnd)) { window.alert('起点必填,终点留空表示不设上限;请填写合法数字') @@ -161,21 +166,28 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { } setSavingRow(true) try { - // 行数据:规模标签 + 阈值 - const rowResponse = await fetch(`/api/supply-chain/category-config/scale-rows/${encodeURIComponent(rowDraft.id)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - scaleLabel: rowDraft.scaleLabel, - performanceStart, - performanceEnd, - dailySalesStart, - dailySalesEnd, - }), + const body = JSON.stringify({ + categoryL1: rowDraft.categoryL1, + categoryL2: rowDraft.categoryL2, + categoryGroup: rowDraft.categoryGroup, + scaleLabel: rowDraft.scaleLabel, + performanceStart, + performanceEnd, + dailySalesStart, + dailySalesEnd, }) - await readCategoryApiResponse(rowResponse) + const response = await fetch( + rowDraft.id ? `/api/supply-chain/category-config/scale-rows/${encodeURIComponent(rowDraft.id)}` : '/api/supply-chain/category-config/scale-rows', + { + method: rowDraft.id ? 'PATCH' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }, + ) + await readCategoryApiResponse(response) setRowDraft(null) await load() + await loadGroups() } catch (cause) { window.alert(cause instanceof Error ? cause.message : '保存失败') } finally { @@ -237,7 +249,7 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { const fileRef = useRef(null) const scaleImport = useCategoryConfigImport({ endpoint: '/api/supply-chain/category-config/scale-rows/import', - onCompleted: load, + onCompleted: async () => { await load(); await loadGroups() }, }) return ( @@ -271,8 +283,8 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) { - ) : null} @@ -281,7 +293,7 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) {
- + + + - - - -

保存后会按所选分类自动带出 4 档规模行(爆款/热卖款/动销款/低动销款);分类未设置时生成 4 行空阈值行。

-
- - -
-
- - - ) -} diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-category-group-tier.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-category-group-tier.repository.ts deleted file mode 100644 index 5e73315c..00000000 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-category-group-tier.repository.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Prisma } from '@prisma/client' -import { prisma } from '@/lib/prisma' - -const groupTierSelect = { - id: true, - categoryGroup: true, - scaleLabel: true, - performanceStart: true, - performanceEnd: true, - dailySalesStart: true, - dailySalesEnd: true, - sortOrder: true, - createdAt: true, - updatedAt: true, -} satisfies Prisma.SupplyChainProductArchiveCategoryGroupTierSelect - -export type ProductArchiveCategoryGroupTierRecord = Prisma.SupplyChainProductArchiveCategoryGroupTierGetPayload<{ - select: typeof groupTierSelect -}> - -export interface SupplyChainProductArchiveCategoryGroupTierRepository { - listPage(input: { - page: number - pageSize: number - search?: string - }): Promise<{ records: ProductArchiveCategoryGroupTierRecord[]; total: number }> - listAll(input: { categoryGroup?: string }): Promise - listGroups(): Promise - findById(id: string): Promise - findByKey(categoryGroup: string, scaleLabel: string): Promise - create(input: { - categoryGroup: string - scaleLabel: string - performanceStart: number - performanceEnd: number | null - dailySalesStart: number - dailySalesEnd: number | null - sortOrder: number - }): Promise - update(input: { - id: string - categoryGroup: string - scaleLabel: string - performanceStart: number - performanceEnd: number | null - dailySalesStart: number - dailySalesEnd: number | null - sortOrder: number - }): Promise - remove(id: string): Promise -} - -class SupplyChainProductArchiveCategoryGroupTierRepositoryClass - implements SupplyChainProductArchiveCategoryGroupTierRepository -{ - async listPage(input: { - page: number - pageSize: number - search?: string - }) { - const where: Prisma.SupplyChainProductArchiveCategoryGroupTierWhereInput = { - ...(input.search - ? { - OR: [ - { categoryGroup: { contains: input.search, mode: 'insensitive' } }, - { scaleLabel: { contains: input.search, mode: 'insensitive' } }, - ], - } - : {}), - } - const skip = (input.page - 1) * input.pageSize - const [records, total] = await Promise.all([ - prisma.supplyChainProductArchiveCategoryGroupTier.findMany({ - where, - select: groupTierSelect, - orderBy: [{ categoryGroup: 'asc' }, { sortOrder: 'asc' }], - skip, - take: input.pageSize, - }), - prisma.supplyChainProductArchiveCategoryGroupTier.count({ where }), - ]) - return { records, total } - } - - async listAll(input: { categoryGroup?: string }) { - return prisma.supplyChainProductArchiveCategoryGroupTier.findMany({ - where: { - ...(input.categoryGroup ? { categoryGroup: input.categoryGroup } : {}), - }, - select: groupTierSelect, - orderBy: [{ categoryGroup: 'asc' }, { sortOrder: 'asc' }], - }) - } - - async listGroups() { - const records = await prisma.supplyChainProductArchiveCategoryGroupTier.findMany({ - select: { categoryGroup: true }, - distinct: ['categoryGroup'], - orderBy: { categoryGroup: 'asc' }, - }) - return records.map((record) => record.categoryGroup) - } - - async findById(id: string) { - return prisma.supplyChainProductArchiveCategoryGroupTier.findUnique({ - where: { id }, - select: groupTierSelect, - }) - } - - async findByKey(categoryGroup: string, scaleLabel: string) { - return prisma.supplyChainProductArchiveCategoryGroupTier.findUnique({ - where: { categoryGroup_scaleLabel: { categoryGroup, scaleLabel } }, - select: groupTierSelect, - }) - } - - async create(input: { - categoryGroup: string - scaleLabel: string - performanceStart: number - performanceEnd: number | null - dailySalesStart: number - dailySalesEnd: number | null - sortOrder: number - }) { - return prisma.supplyChainProductArchiveCategoryGroupTier.create({ - data: input, - select: groupTierSelect, - }) - } - - async update(input: { - id: string - categoryGroup: string - scaleLabel: string - performanceStart: number - performanceEnd: number | null - dailySalesStart: number - dailySalesEnd: number | null - sortOrder: number - }) { - const { id, ...data } = input - return prisma.supplyChainProductArchiveCategoryGroupTier.update({ - where: { id }, - data, - select: groupTierSelect, - }) - } - - async remove(id: string) { - await prisma.supplyChainProductArchiveCategoryGroupTier.delete({ where: { id } }) - } -} - -export const supplyChainProductArchiveCategoryGroupTierRepository = - new SupplyChainProductArchiveCategoryGroupTierRepositoryClass() - -export { SupplyChainProductArchiveCategoryGroupTierRepositoryClass } diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-category-scale-row.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-category-scale-row.repository.ts index 346fef13..13a82fed 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-category-scale-row.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-category-scale-row.repository.ts @@ -3,7 +3,9 @@ import { prisma } from '@/lib/prisma' const scaleRowSelect = { id: true, + categoryL1: true, categoryL2: true, + categoryGroup: true, scaleLabel: true, performanceStart: true, performanceEnd: true, @@ -19,12 +21,25 @@ export type ProductArchiveCategoryScaleRowRecord = Prisma.SupplyChainProductArch }> export interface SupplyChainProductArchiveCategoryScaleRowRepository { - listAll(): Promise - listByCategoryL2(categoryL2: string): Promise + listPage(input: { + page: number + pageSize: number + search?: string + categoryGroup?: string + scaleLabel?: string + }): Promise<{ records: ProductArchiveCategoryScaleRowRecord[]; total: number }> + listAll(input: { + search?: string + categoryGroup?: string + scaleLabel?: string + }): Promise + listGroups(): Promise findById(id: string): Promise findByKey(categoryL2: string, scaleLabel: string): Promise create(input: { + categoryL1: string categoryL2: string + categoryGroup: string scaleLabel: string performanceStart: number | null performanceEnd: number | null @@ -34,6 +49,9 @@ export interface SupplyChainProductArchiveCategoryScaleRowRepository { }): Promise update(input: { id: string + categoryL1: string + categoryL2: string + categoryGroup: string scaleLabel: string performanceStart: number | null performanceEnd: number | null @@ -44,22 +62,67 @@ export interface SupplyChainProductArchiveCategoryScaleRowRepository { remove(id: string): Promise } +function buildListWhere(input: { + search?: string + categoryGroup?: string + scaleLabel?: string +}): Prisma.SupplyChainProductArchiveCategoryScaleRowWhereInput { + return { + ...(input.search + ? { + OR: [ + { categoryL1: { contains: input.search, mode: 'insensitive' } }, + { categoryL2: { contains: input.search, mode: 'insensitive' } }, + { categoryGroup: { contains: input.search, mode: 'insensitive' } }, + { scaleLabel: { contains: input.search, mode: 'insensitive' } }, + ], + } + : {}), + ...(input.categoryGroup !== undefined ? { categoryGroup: input.categoryGroup } : {}), + ...(input.scaleLabel ? { scaleLabel: input.scaleLabel } : {}), + } +} + class SupplyChainProductArchiveCategoryScaleRowRepositoryClass implements SupplyChainProductArchiveCategoryScaleRowRepository { - async listAll() { + async listPage(input: { + page: number + pageSize: number + search?: string + categoryGroup?: string + scaleLabel?: string + }) { + const where = buildListWhere(input) + const skip = (input.page - 1) * input.pageSize + const [records, total] = await Promise.all([ + prisma.supplyChainProductArchiveCategoryScaleRow.findMany({ + where, + select: scaleRowSelect, + orderBy: [{ categoryL2: 'asc' }, { sortOrder: 'asc' }], + skip, + take: input.pageSize, + }), + prisma.supplyChainProductArchiveCategoryScaleRow.count({ where }), + ]) + return { records, total } + } + + async listAll(input: { search?: string; categoryGroup?: string; scaleLabel?: string }) { return prisma.supplyChainProductArchiveCategoryScaleRow.findMany({ + where: buildListWhere(input), select: scaleRowSelect, orderBy: [{ categoryL2: 'asc' }, { sortOrder: 'asc' }], }) } - async listByCategoryL2(categoryL2: string) { - return prisma.supplyChainProductArchiveCategoryScaleRow.findMany({ - where: { categoryL2 }, - select: scaleRowSelect, - orderBy: [{ sortOrder: 'asc' }], + async listGroups() { + const records = await prisma.supplyChainProductArchiveCategoryScaleRow.findMany({ + select: { categoryGroup: true }, + distinct: ['categoryGroup'], + orderBy: { categoryGroup: 'asc' }, }) + return records.map((record) => record.categoryGroup).filter(Boolean) } async findById(id: string) { @@ -77,7 +140,9 @@ class SupplyChainProductArchiveCategoryScaleRowRepositoryClass } async create(input: { + categoryL1: string categoryL2: string + categoryGroup: string scaleLabel: string performanceStart: number | null performanceEnd: number | null @@ -93,6 +158,9 @@ class SupplyChainProductArchiveCategoryScaleRowRepositoryClass async update(input: { id: string + categoryL1: string + categoryL2: string + categoryGroup: string scaleLabel: string performanceStart: number | null performanceEnd: number | null diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-category-group-tier.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-category-group-tier.service.test.ts deleted file mode 100644 index 95f056f9..00000000 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-category-group-tier.service.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { - ProductArchiveCategoryGroupTierRecord, - SupplyChainProductArchiveCategoryGroupTierRepository, -} from '../../repositories/supply-chain-product-archive-category-group-tier.repository' -import { SupplyChainProductArchiveCategoryGroupTierService } from '../supply-chain-product-archive-category-group-tier.service' - -function createMemoryRepository(seed: Array<{ - categoryGroup: string - scaleLabel: string - performanceStart: string - performanceEnd: string | null - dailySalesStart: string - dailySalesEnd: string | null - sortOrder: number -}> = []) { - let sequence = 0 - const records: ProductArchiveCategoryGroupTierRecord[] = seed.map((item) => ({ - id: `seed-${(sequence += 1)}`, - categoryGroup: item.categoryGroup, - scaleLabel: item.scaleLabel, - performanceStart: item.performanceStart, - performanceEnd: item.performanceEnd, - dailySalesStart: item.dailySalesStart, - dailySalesEnd: item.dailySalesEnd, - sortOrder: item.sortOrder, - createdAt: new Date('2026-09-01T00:00:00Z'), - updatedAt: new Date('2026-09-01T00:00:00Z'), - })) - - const repository: SupplyChainProductArchiveCategoryGroupTierRepository = { - async listPage(input) { - const filtered = input.search - ? records.filter((record) => record.categoryGroup.includes(input.search!) || record.scaleLabel.includes(input.search!)) - : records - const start = (input.page - 1) * input.pageSize - return { records: filtered.slice(start, start + input.pageSize), total: filtered.length } - }, - async listAll(input) { - if (!input.categoryGroup) return [...records] - return records.filter((record) => record.categoryGroup === input.categoryGroup) - }, - async listGroups() { - return [...new Set(records.map((record) => record.categoryGroup))].sort() - }, - async findById(id) { - return records.find((record) => record.id === id) ?? null - }, - async findByKey(categoryGroup, scaleLabel) { - return records.find((record) => record.categoryGroup === categoryGroup && record.scaleLabel === scaleLabel) ?? null - }, - async create(input) { - const record: ProductArchiveCategoryGroupTierRecord = { - id: `created-${(sequence += 1)}`, - categoryGroup: input.categoryGroup, - scaleLabel: input.scaleLabel, - performanceStart: String(input.performanceStart), - performanceEnd: input.performanceEnd === null ? null : String(input.performanceEnd), - dailySalesStart: String(input.dailySalesStart), - dailySalesEnd: input.dailySalesEnd === null ? null : String(input.dailySalesEnd), - sortOrder: input.sortOrder, - createdAt: new Date('2026-09-24T00:00:00Z'), - updatedAt: new Date('2026-09-24T00:00:00Z'), - } - records.push(record) - return record - }, - async update(input) { - const record = records.find((item) => item.id === input.id) - if (!record) throw new Error('record not found') - record.categoryGroup = input.categoryGroup - record.scaleLabel = input.scaleLabel - record.performanceStart = String(input.performanceStart) - record.performanceEnd = input.performanceEnd === null ? null : String(input.performanceEnd) - record.dailySalesStart = String(input.dailySalesStart) - record.dailySalesEnd = input.dailySalesEnd === null ? null : String(input.dailySalesEnd) - record.sortOrder = input.sortOrder - record.updatedAt = new Date('2026-09-24T00:00:00Z') - return { ...record } - }, - async remove(id) { - const index = records.findIndex((record) => record.id === id) - if (index < 0) throw new Error('record not found') - records.splice(index, 1) - }, - } - - return { repository, records } -} - -describe('SupplyChainProductArchiveCategoryGroupTierService.importRows', () => { - it('creates tiers per (categoryGroup, scaleLabel) and treats blank ends as uncapped', async () => { - const memory = createMemoryRepository() - const service = new SupplyChainProductArchiveCategoryGroupTierService(memory.repository) - - const result = await service.importRows([ - { categoryGroup: '电脑桌/书桌', scaleLabel: '爆款', performanceStart: 60000, performanceEnd: '', dailySalesStart: 20, dailySalesEnd: '' }, - { categoryGroup: '电脑桌/书桌', scaleLabel: '热卖款', performanceStart: 48000, performanceEnd: 72000, dailySalesStart: 10, dailySalesEnd: 20 }, - ]) - - expect(result).toMatchObject({ total: 2, processed: 2, created: 2, updated: 0, failed: 0, failures: [] }) - expect(memory.records.find((record) => record.scaleLabel === '爆款')).toMatchObject({ - categoryGroup: '电脑桌/书桌', - performanceEnd: null, - dailySalesEnd: null, - sortOrder: 1, - }) - expect(memory.records.find((record) => record.scaleLabel === '热卖款')).toMatchObject({ - performanceEnd: '72000', - sortOrder: 2, - }) - }) - - it('updates existing tiers by key and keeps processing failed rows with Excel row numbers', async () => { - const memory = createMemoryRepository([ - { - categoryGroup: '书架', - scaleLabel: '爆款', - performanceStart: '48000', - performanceEnd: null, - dailySalesStart: '20', - dailySalesEnd: null, - sortOrder: 1, - }, - ]) - const service = new SupplyChainProductArchiveCategoryGroupTierService(memory.repository) - - const result = await service.importRows([ - { categoryGroup: '书架', scaleLabel: '爆款', performanceStart: 60000, performanceEnd: '', dailySalesStart: 20, dailySalesEnd: '' }, - { categoryGroup: '', scaleLabel: '动销款', performanceStart: 12000, performanceEnd: '', dailySalesStart: 5, dailySalesEnd: '' }, - { categoryGroup: '书架', scaleLabel: '超级款', performanceStart: 1, performanceEnd: '', dailySalesStart: 1, dailySalesEnd: '' }, - { categoryGroup: '书架', scaleLabel: '动销款', performanceStart: 9000, performanceEnd: 6000, dailySalesStart: 1, dailySalesEnd: '' }, - ]) - - expect(result.updated).toBe(1) - expect(result.created).toBe(0) - expect(result.failed).toBe(3) - expect(result.failures.map((failure) => failure.reason)).toEqual([ - '分类不能为空', - '规模标签只能是爆款、热卖款、动销款、低动销款', - '业绩终点必须大于业绩起点(留空表示不设上限)', - ]) - expect(result.failures.map((failure) => failure.row)).toEqual([3, 4, 5]) - expect(memory.records.find((record) => record.scaleLabel === '爆款')).toMatchObject({ performanceStart: '60000' }) - }) -}) - -describe('SupplyChainProductArchiveCategoryGroupTierService.listGroups', () => { - it('returns distinct category groups sorted', async () => { - const memory = createMemoryRepository([ - { categoryGroup: '茶几', scaleLabel: '爆款', performanceStart: '36000', performanceEnd: null, dailySalesStart: '10', dailySalesEnd: null, sortOrder: 1 }, - { categoryGroup: '书架', scaleLabel: '爆款', performanceStart: '48000', performanceEnd: null, dailySalesStart: '20', dailySalesEnd: null, sortOrder: 1 }, - { categoryGroup: '书架', scaleLabel: '热卖款', performanceStart: '36000', performanceEnd: '48000', dailySalesStart: '10', dailySalesEnd: '20', sortOrder: 2 }, - ]) - const service = new SupplyChainProductArchiveCategoryGroupTierService(memory.repository) - await expect(service.listGroups()).resolves.toEqual(['书架', '茶几']) - }) -}) diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-category-group-tier.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-category-group-tier.service.ts deleted file mode 100644 index 4107678b..00000000 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-category-group-tier.service.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { Prisma } from '@prisma/client' -import { AppError, ErrorCode } from '@/server/core/errors' -import { - CATEGORY_GROUP_SCALE_LABELS, - type CategoryGroupTierImportFailure, - type CategoryGroupTierImportProgress, - type CategoryGroupTierImportResult, -} from '@/lib/supply-chain-product-archive-category-config' -import { - supplyChainProductArchiveCategoryGroupTierRepository, - type ProductArchiveCategoryGroupTierRecord, - type SupplyChainProductArchiveCategoryGroupTierRepository, -} from '@/server/repositories/supply-chain-product-archive-category-group-tier.repository' - -const GROUP_MAX_LENGTH = 80 - -export type CategoryGroupTierDTO = { - id: string - categoryGroup: string - scaleLabel: string - performanceStart: string - performanceEnd: string | null - dailySalesStart: string - dailySalesEnd: string | null - sortOrder: number - createdAt: string - updatedAt: string -} - -export type CategoryGroupTierListResult = { - items: CategoryGroupTierDTO[] - pageInfo: { page: number; pageSize: number; total: number; totalPages: number } -} - -export type CategoryGroupTierInput = { - categoryGroup: string - scaleLabel: string - performanceStart: number - performanceEnd: number | null - dailySalesStart: number - dailySalesEnd: number | null - sortOrder: number -} - -function mapRecord(record: ProductArchiveCategoryGroupTierRecord): CategoryGroupTierDTO { - return { - id: record.id, - categoryGroup: record.categoryGroup, - scaleLabel: record.scaleLabel, - performanceStart: String(record.performanceStart), - performanceEnd: record.performanceEnd === null ? null : String(record.performanceEnd), - dailySalesStart: String(record.dailySalesStart), - dailySalesEnd: record.dailySalesEnd === null ? null : String(record.dailySalesEnd), - sortOrder: record.sortOrder, - createdAt: record.createdAt.toISOString(), - updatedAt: record.updatedAt.toISOString(), - } -} - -function cleanGroup(value: string) { - const cleaned = value.trim() - if (!cleaned) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '分类不能为空') - if (cleaned.length > GROUP_MAX_LENGTH) { - throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `分类不能超过${GROUP_MAX_LENGTH}个字符`) - } - return cleaned -} - -function cleanScaleLabel(value: string) { - const cleaned = value.trim() - if (!(CATEGORY_GROUP_SCALE_LABELS as readonly string[]).includes(cleaned)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '规模标签只能是爆款、热卖款、动销款、低动销款') - } - return cleaned -} - -function parseAmount(value: unknown, label: string, { required = false } = {}): number | null { - const text = value === null || value === undefined ? '' : String(value).trim() - if (!text) { - if (required) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${label}不能为空`) - return null - } - const parsed = Number(text) - if (!Number.isFinite(parsed)) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${label}必须是数字`) - if (parsed < 0) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `${label}不能为负数`) - return parsed -} - -function buildTierInput(input: { - categoryGroup: string - scaleLabel: string - performanceStart: unknown - performanceEnd?: unknown - dailySalesStart: unknown - dailySalesEnd?: unknown -}): CategoryGroupTierInput { - const categoryGroup = cleanGroup(input.categoryGroup) - const scaleLabel = cleanScaleLabel(input.scaleLabel) - const performanceStart = parseAmount(input.performanceStart, '业绩起点', { required: true }) - const performanceEnd = parseAmount(input.performanceEnd, '业绩终点') - const dailySalesStart = parseAmount(input.dailySalesStart, '日销起点', { required: true }) - const dailySalesEnd = parseAmount(input.dailySalesEnd, '日销终点') - if (performanceStart !== null && performanceEnd !== null && performanceEnd <= performanceStart) { - throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '业绩终点必须大于业绩起点(留空表示不设上限)') - } - if (dailySalesStart !== null && dailySalesEnd !== null && dailySalesEnd <= dailySalesStart) { - throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '日销终点必须大于日销起点(留空表示不设上限)') - } - const sortOrder = CATEGORY_GROUP_SCALE_LABELS.indexOf(scaleLabel as (typeof CATEGORY_GROUP_SCALE_LABELS)[number]) + 1 - return { - categoryGroup, - scaleLabel, - performanceStart: performanceStart as number, - performanceEnd, - dailySalesStart: dailySalesStart as number, - dailySalesEnd, - sortOrder, - } -} - -function isUniqueViolation(error: unknown) { - return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' -} - -export class SupplyChainProductArchiveCategoryGroupTierService { - constructor( - private readonly repository: SupplyChainProductArchiveCategoryGroupTierRepository = supplyChainProductArchiveCategoryGroupTierRepository, - ) {} - - async list(input: { page?: number; pageSize?: number; search?: string }): Promise { - const page = Math.max(1, Math.floor(input.page ?? 1)) - const pageSize = Math.min(200, Math.max(1, Math.floor(input.pageSize ?? 100))) - const search = input.search?.trim().slice(0, GROUP_MAX_LENGTH) || undefined - const result = await this.repository.listPage({ page, pageSize, search }) - return { - items: result.records.map(mapRecord), - pageInfo: { - page, - pageSize, - total: result.total, - totalPages: Math.max(1, Math.ceil(result.total / pageSize)), - }, - } - } - - async listAll(input: { categoryGroup?: string }) { - const categoryGroup = input.categoryGroup?.trim() || undefined - const records = await this.repository.listAll({ categoryGroup }) - return records.map(mapRecord) - } - - async listGroups() { - return this.repository.listGroups() - } - - async create(input: { - categoryGroup: string - scaleLabel: string - performanceStart: unknown - performanceEnd?: unknown - dailySalesStart: unknown - dailySalesEnd?: unknown - }) { - const data = buildTierInput(input) - try { - return mapRecord(await this.repository.create(data)) - } catch (error) { - if (isUniqueViolation(error)) { - throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS, '该分类下已存在同名规模标签') - } - throw error - } - } - - async update(input: { - id: string - categoryGroup: string - scaleLabel: string - performanceStart: unknown - performanceEnd?: unknown - dailySalesStart: unknown - dailySalesEnd?: unknown - }) { - const id = input.id.trim() - if (!id) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '档位 ID 不能为空') - const data = buildTierInput(input) - if (!(await this.repository.findById(id))) { - throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_NOT_FOUND, '分类档位不存在') - } - try { - return mapRecord(await this.repository.update({ id, ...data })) - } catch (error) { - if (isUniqueViolation(error)) { - throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS, '该分类下已存在同名规模标签') - } - throw error - } - } - - async remove(id: string) { - const key = id.trim() - if (!key) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '档位 ID 不能为空') - await this.repository.remove(key).catch((error) => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { - throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_NOT_FOUND, '分类档位不存在') - } - throw error - }) - } - - /** - * 按(分类, 规模标签)逐行 upsert;终点留空 = 不设上限。 - * 单行失败记录行号与原因不中断整批,每行回调进度。 - */ - async importRows( - rows: Array>, - onProgress?: (progress: CategoryGroupTierImportProgress) => void, - ): Promise { - const existing = await this.repository.listAll({}) - const existingByKey = new Map() - for (const record of existing) { - const key = `${record.categoryGroup}||${record.scaleLabel}` - if (!existingByKey.has(key)) existingByKey.set(key, record) - } - - const progress: CategoryGroupTierImportProgress = { - processed: 0, - total: rows.length, - created: 0, - updated: 0, - failed: 0, - } - const failures: CategoryGroupTierImportFailure[] = [] - const seenInFile = new Set() - - for (let index = 0; index < rows.length; index += 1) { - const row = rows[index] - const rowNumber = index + 2 - try { - const data = buildTierInput({ - categoryGroup: String(row.categoryGroup ?? '').trim(), - scaleLabel: String(row.scaleLabel ?? '').trim(), - performanceStart: row.performanceStart, - performanceEnd: row.performanceEnd, - dailySalesStart: row.dailySalesStart, - dailySalesEnd: row.dailySalesEnd, - }) - const key = `${data.categoryGroup}||${data.scaleLabel}` - if (seenInFile.has(key)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '文件内分类+规模标签重复') - } - seenInFile.add(key) - const current = existingByKey.get(key) - if (current) { - const record = await this.repository.update({ id: current.id, ...data }) - existingByKey.set(key, record) - progress.updated += 1 - } else { - const record = await this.repository.create(data) - existingByKey.set(key, record) - progress.created += 1 - } - } catch (error) { - progress.failed += 1 - failures.push({ - row: rowNumber, - reason: error instanceof AppError ? error.message : '写入失败,请检查该行内容', - }) - } - progress.processed += 1 - onProgress?.({ ...progress }) - } - - return { ...progress, failures } - } -} - -export const supplyChainProductArchiveCategoryGroupTierService = - new SupplyChainProductArchiveCategoryGroupTierService() diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-category-scale-row.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-category-scale-row.service.ts index 95ab634c..3b4c995f 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-category-scale-row.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-category-scale-row.service.ts @@ -17,10 +17,6 @@ import { supplyChainProductArchiveCategoryConfigRepository, type SupplyChainProductArchiveCategoryConfigRepository, } from '@/server/repositories/supply-chain-product-archive-category-config.repository' -import { - supplyChainProductArchiveCategoryGroupTierRepository, - type SupplyChainProductArchiveCategoryGroupTierRepository, -} from '@/server/repositories/supply-chain-product-archive-category-group-tier.repository' export type CategoryScaleRowDTO = ProductArchiveCategoryScaleRow @@ -30,6 +26,9 @@ export type CategoryScaleRowListResult = { } export type CategoryScaleRowInput = { + categoryL1: string + categoryL2: string + categoryGroup: string scaleLabel: string performanceStart: number | null performanceEnd: number | null @@ -38,15 +37,12 @@ export type CategoryScaleRowInput = { sortOrder: number } -function mapRecord(record: ProductArchiveCategoryScaleRowRecord, config?: { - categoryL1: string - categoryGroup: string -} | null): CategoryScaleRowDTO { +function mapRecord(record: ProductArchiveCategoryScaleRowRecord): CategoryScaleRowDTO { return { id: record.id, - categoryL1: config?.categoryL1 ?? '', + categoryL1: record.categoryL1, categoryL2: record.categoryL2, - categoryGroup: config?.categoryGroup ?? '', + categoryGroup: record.categoryGroup, scaleLabel: record.scaleLabel, performanceStart: record.performanceStart === null ? null : String(record.performanceStart), performanceEnd: record.performanceEnd === null ? null : String(record.performanceEnd), @@ -58,8 +54,17 @@ function mapRecord(record: ProductArchiveCategoryScaleRowRecord, config?: { } } -function cleanScaleLabel(value: string) { - const cleaned = value.trim() +function cleanText(value: unknown, label: string, maxLength: number) { + const cleaned = String(value ?? '').trim() + if (!cleaned) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${label}不能为空`) + if (cleaned.length > maxLength) { + throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `${label}不能超过${maxLength}个字符`) + } + return cleaned +} + +function cleanScaleLabel(value: unknown) { + const cleaned = String(value ?? '').trim() if (!(CATEGORY_GROUP_SCALE_LABELS as readonly string[]).includes(cleaned)) { throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '规模标签只能是爆款、热卖款、动销款、低动销款') } @@ -79,12 +84,18 @@ function parseAmount(value: unknown, label: string, { required = false } = {}): } function buildRowInput(input: { - scaleLabel: string + categoryL1: unknown + categoryL2: unknown + categoryGroup: unknown + scaleLabel: unknown performanceStart: unknown performanceEnd?: unknown dailySalesStart: unknown dailySalesEnd?: unknown }): CategoryScaleRowInput { + const categoryL1 = cleanText(input.categoryL1, '一级类目', 160) + const categoryL2 = cleanText(input.categoryL2, '二级类目', 160) + const categoryGroup = String(input.categoryGroup ?? '').trim().slice(0, 80) const scaleLabel = cleanScaleLabel(input.scaleLabel) const performanceStart = parseAmount(input.performanceStart, '业绩起点', { required: true }) const performanceEnd = parseAmount(input.performanceEnd, '业绩终点') @@ -97,7 +108,17 @@ function buildRowInput(input: { throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '日销终点必须大于日销起点(留空表示不设上限)') } const sortOrder = CATEGORY_GROUP_SCALE_LABELS.indexOf(scaleLabel as (typeof CATEGORY_GROUP_SCALE_LABELS)[number]) + 1 - return { scaleLabel, performanceStart, performanceEnd, dailySalesStart, dailySalesEnd, sortOrder } + return { + categoryL1, + categoryL2, + categoryGroup, + scaleLabel, + performanceStart: performanceStart as number, + performanceEnd, + dailySalesStart: dailySalesStart as number, + dailySalesEnd, + sortOrder, + } } function isUniqueViolation(error: unknown) { @@ -108,7 +129,6 @@ export class SupplyChainProductArchiveCategoryScaleRowService { constructor( private readonly repository: SupplyChainProductArchiveCategoryScaleRowRepository = supplyChainProductArchiveCategoryScaleRowRepository, private readonly categoryRepository: SupplyChainProductArchiveCategoryConfigRepository = supplyChainProductArchiveCategoryConfigRepository, - private readonly tierRepository: SupplyChainProductArchiveCategoryGroupTierRepository = supplyChainProductArchiveCategoryGroupTierRepository, ) {} async list(input: { @@ -120,78 +140,82 @@ export class SupplyChainProductArchiveCategoryScaleRowService { }): Promise { const page = Math.max(1, Math.floor(input.page ?? 1)) const pageSize = Math.min(200, Math.max(1, Math.floor(input.pageSize ?? 100))) - const [records, configs] = await Promise.all([ - this.repository.listAll(), - this.categoryRepository.listAll({}), - ]) - const configByKey = new Map(configs.map((config) => [config.categoryL2, config])) - - const search = input.search?.trim().toLowerCase() || '' - const groupFilter = input.categoryGroup?.trim() ?? '' - const labelFilter = input.scaleLabel?.trim() || '' - - const joined = records - .map((record) => ({ record, config: configByKey.get(record.categoryL2) ?? null })) - .filter(({ record, config }) => { - if (search) { - const haystack = `${config?.categoryL1 ?? ''}${config?.categoryL2 ?? ''}${config?.categoryGroup ?? ''}`.toLowerCase() - if (!haystack.includes(search) - && !record.scaleLabel.toLowerCase().includes(search)) return false - } - if (groupFilter !== '') { - if ((config?.categoryGroup ?? '') !== groupFilter) return false - } - if (labelFilter && record.scaleLabel !== labelFilter) return false - return true - }) - - const total = joined.length - const start = (page - 1) * pageSize + const search = input.search?.trim().slice(0, 160) || undefined + const categoryGroup = input.categoryGroup?.trim() ?? undefined + const scaleLabel = input.scaleLabel?.trim() || undefined + const result = await this.repository.listPage({ page, pageSize, search, categoryGroup, scaleLabel }) return { - items: joined.slice(start, start + pageSize).map(({ record, config }) => mapRecord(record, config)), + items: result.records.map(mapRecord), pageInfo: { page, pageSize, - total, - totalPages: Math.max(1, Math.ceil(total / pageSize)), + total: result.total, + totalPages: Math.max(1, Math.ceil(result.total / pageSize)), }, } } - async listAll() { - const [records, configs] = await Promise.all([ - this.repository.listAll(), - this.categoryRepository.listAll({}), - ]) - const configByKey = new Map(configs.map((config) => [config.categoryL2, config])) - return records.map((record) => mapRecord(record, configByKey.get(record.categoryL2) ?? null)) + async listAll(input: { search?: string; categoryGroup?: string; scaleLabel?: string }) { + const search = input.search?.trim().slice(0, 160) || undefined + const categoryGroup = input.categoryGroup?.trim() ?? undefined + const scaleLabel = input.scaleLabel?.trim() || undefined + const records = await this.repository.listAll({ search, categoryGroup, scaleLabel }) + return records.map(mapRecord) } async listGroups() { - return (await this.categoryRepository.listAll({})) - .map((config) => config.categoryGroup) - .filter((group, index, all) => group && all.indexOf(group) === index) - .sort() + return this.repository.listGroups() + } + + /** + * 新增/修改规模行的同时,把一级类目、二级类目、分类同步到类目主表, + * 保证产品全量档案、类目下拉等消费方的类目清单完整。 + */ + private async syncCategoryConfig(input: CategoryScaleRowInput, status?: ProductArchiveCategoryConfigStatus) { + const existing = await this.categoryRepository.findByCategoryL2(input.categoryL2) + if (existing) { + if (existing.categoryL1 !== input.categoryL1 || existing.categoryGroup !== input.categoryGroup) { + await this.categoryRepository.update({ + id: existing.id, + categoryL2: existing.categoryL2, + categoryL1: input.categoryL1, + categoryGroup: input.categoryGroup, + scaleLabel: existing.scaleLabel, + performanceStart: existing.performanceStart === null ? null : Number(existing.performanceStart), + performanceEnd: existing.performanceEnd === null ? null : Number(existing.performanceEnd), + dailySalesStart: existing.dailySalesStart === null ? null : Number(existing.dailySalesStart), + dailySalesEnd: existing.dailySalesEnd === null ? null : Number(existing.dailySalesEnd), + status: existing.status as ProductArchiveCategoryConfigStatus, + }) + } + return + } + await this.categoryRepository.create({ + categoryL2: input.categoryL2, + categoryL1: input.categoryL1, + categoryGroup: input.categoryGroup, + ...(status ? { status } : {}), + }) } async create(input: { - categoryL2: string - scaleLabel: string + categoryL1: unknown + categoryL2: unknown + categoryGroup: unknown + scaleLabel: unknown performanceStart: unknown performanceEnd?: unknown dailySalesStart: unknown dailySalesEnd?: unknown }) { - const categoryL2 = input.categoryL2.trim() - if (!categoryL2) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '二级类目不能为空') - const config = await this.categoryRepository.findByCategoryL2(categoryL2) - if (!config) throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_NOT_FOUND, '二级类目不存在,请先新增类目') const data = buildRowInput(input) try { - return mapRecord(await this.repository.create({ categoryL2, ...data }), config) + const record = await this.repository.create(data) + await this.syncCategoryConfig(data, 'ACTIVE') + return mapRecord(record) } catch (error) { if (isUniqueViolation(error)) { - throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS, '该类目下已存在同名规模标签') + throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS, '该二级类目下已存在同名规模标签') } throw error } @@ -199,7 +223,10 @@ export class SupplyChainProductArchiveCategoryScaleRowService { async update(input: { id: string - scaleLabel: string + categoryL1: unknown + categoryL2: unknown + categoryGroup: unknown + scaleLabel: unknown performanceStart: unknown performanceEnd?: unknown dailySalesStart: unknown @@ -211,10 +238,12 @@ export class SupplyChainProductArchiveCategoryScaleRowService { const current = await this.repository.findById(id) if (!current) throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_NOT_FOUND, '规模行不存在') try { - return mapRecord(await this.repository.update({ id, ...data })) + const record = await this.repository.update({ id, ...data }) + await this.syncCategoryConfig(data) + return mapRecord(record) } catch (error) { if (isUniqueViolation(error)) { - throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS, '该类目下已存在同名规模标签') + throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS, '该二级类目下已存在同名规模标签') } throw error } @@ -231,57 +260,15 @@ export class SupplyChainProductArchiveCategoryScaleRowService { }) } - /** - * 按类目自动生成 4 档规模行: - * 优先复制「分类×档位」模板的阈值;该分类没有模板时生成 4 行空阈值行。 - * 已存在的行跳过。 - */ - async generateForCategory(categoryL2: string): Promise { - const config = await this.categoryRepository.findByCategoryL2(categoryL2) - if (!config) return 0 - const templates = config.categoryGroup - ? await this.tierRepository.listAll({ categoryGroup: config.categoryGroup }) - : [] - const plans = templates.length > 0 - ? templates.map((template) => ({ - scaleLabel: template.scaleLabel, - performanceStart: template.performanceStart === null ? null : Number(template.performanceStart), - performanceEnd: template.performanceEnd === null ? null : Number(template.performanceEnd), - dailySalesStart: template.dailySalesStart === null ? null : Number(template.dailySalesStart), - dailySalesEnd: template.dailySalesEnd === null ? null : Number(template.dailySalesEnd), - sortOrder: template.sortOrder, - })) - : CATEGORY_GROUP_SCALE_LABELS.map((label, index) => ({ - scaleLabel: label, - performanceStart: null, - performanceEnd: null, - dailySalesStart: null, - dailySalesEnd: null, - sortOrder: index + 1, - })) - - let created = 0 - for (const plan of plans) { - if (await this.repository.findByKey(categoryL2, plan.scaleLabel)) continue - await this.repository.create({ categoryL2, ...plan }) - created += 1 - } - return created - } - /** * 按(二级类目, 规模标签)逐行 upsert;终点留空 = 不设上限。 - * 单行失败记录行号与原因不中断整批,每行回调进度。 + * 一级类目/分类同步到类目主表;单行失败记录行号与原因不中断整批,每行回调进度。 */ async importRows( rows: Array>, onProgress?: (progress: CategoryConfigImportProgress) => void, ): Promise { - const [existing, configs] = await Promise.all([ - this.repository.listAll(), - this.categoryRepository.listAll({}), - ]) - const configByL2 = new Map(configs.map((config) => [config.categoryL2, config])) + const existing = await this.repository.listAll({}) const existingByKey = new Map() for (const record of existing) { const key = `${record.categoryL2}||${record.scaleLabel}` @@ -302,40 +289,17 @@ export class SupplyChainProductArchiveCategoryScaleRowService { const row = rows[index] const rowNumber = index + 2 try { - const categoryL2 = String(row.categoryL2 ?? '').trim() - if (!categoryL2) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '二级类目不能为空') - const config = configByL2.get(categoryL2) - if (!config) { - throw new AppError(ErrorCode.SUPPLY_CHAIN_CATEGORY_CONFIG_NOT_FOUND, '二级类目不存在,请先在类目配置中新增') - } - - // 同步类目信息:文件里带一级类目/分类时更新类目配置(留空保留原值) - const fileL1 = String(row.categoryL1 ?? '').trim() - const fileGroup = String(row.categoryGroup ?? '').trim() - if (fileL1 || fileGroup) { - const updatedConfig = await this.categoryRepository.update({ - id: config.id, - categoryL2: config.categoryL2, - categoryL1: fileL1 || config.categoryL1, - categoryGroup: fileGroup || config.categoryGroup, - scaleLabel: config.scaleLabel, - performanceStart: config.performanceStart === null ? null : Number(config.performanceStart), - performanceEnd: config.performanceEnd === null ? null : Number(config.performanceEnd), - dailySalesStart: config.dailySalesStart === null ? null : Number(config.dailySalesStart), - dailySalesEnd: config.dailySalesEnd === null ? null : Number(config.dailySalesEnd), - status: config.status as ProductArchiveCategoryConfigStatus, - }) - configByL2.set(categoryL2, updatedConfig) - } - const data = buildRowInput({ - scaleLabel: String(row.scaleLabel ?? '').trim(), + categoryL1: row.categoryL1, + categoryL2: row.categoryL2, + categoryGroup: row.categoryGroup, + scaleLabel: row.scaleLabel, performanceStart: row.performanceStart, performanceEnd: row.performanceEnd, dailySalesStart: row.dailySalesStart, dailySalesEnd: row.dailySalesEnd, }) - const key = `${categoryL2}||${data.scaleLabel}` + const key = `${data.categoryL2}||${data.scaleLabel}` if (seenInFile.has(key)) { throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '文件内二级类目+规模标签重复') } @@ -347,10 +311,11 @@ export class SupplyChainProductArchiveCategoryScaleRowService { existingByKey.set(key, record) progress.updated += 1 } else { - const record = await this.repository.create({ categoryL2, ...data }) + const record = await this.repository.create(data) existingByKey.set(key, record) progress.created += 1 } + await this.syncCategoryConfig(data) } catch (error) { progress.failed += 1 failures.push({