refactor(category-config): 类目规模合并为单表展示结构(一级类目/分类并入规模行,移除分类档位模板表)

This commit is contained in:
WuYongJie 2026-09-24 17:06:41 +08:00
parent cb3efaa50c
commit 9e91f18dfd
17 changed files with 265 additions and 1198 deletions

View File

@ -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";

View File

@ -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 {

View File

@ -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'],
},
)
}

View File

@ -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'],
})
}

View File

@ -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<string, string> = {
一级类目: '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<Record<string, unknown>>(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<Uint8Array>({
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'],
})
}

View File

@ -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'],
},
)
}

View File

@ -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,

View File

@ -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,

View File

@ -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)
},
{

View File

@ -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,

View File

@ -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<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [rowDraft, setRowDraft] = useState<ScaleRowDraft | null>(null)
const [rowDraft, setRowDraft] = useState<RowDraft | null>(null)
const [savingRow, setSavingRow] = useState(false)
const [categoryDraft, setCategoryDraft] = useState<CategoryDraft | null>(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<HTMLInputElement>(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 }) {
<button type="button" className="category-table-button" disabled={exporting} onClick={() => void exportRows()}>
<Download size={15} /> {exporting ? '导出中' : '导出'}
</button>
<button type="button" className="category-table-button primary" onClick={openCreateCategory}>
<Plus size={15} /> 新增类目
<button type="button" className="category-table-button primary" onClick={openCreateRow}>
<Plus size={15} /> 新增
</button>
</>
) : null}
@ -281,7 +293,7 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) {
<form className="category-table-filters" onSubmit={submitSearch}>
<label className="category-table-search">
<Search size={15} />
<input value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="搜索一级/二级类目或分类" />
<input value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="搜索一级/二级类目、分类或规模标签" />
</label>
<select value={groupFilter} onChange={(event) => { setPage(1); setGroupFilter(event.target.value) }} aria-label="分类筛选">
<option value="ALL">全部分类</option>
@ -360,13 +372,25 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) {
<div className="category-table-modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setRowDraft(null) }}>
<form className="category-table-modal" onSubmit={saveRow}>
<div className="category-table-modal-header">
<h2>编辑规模行</h2>
<h2>{rowDraft.id ? '编辑' : '新增'}</h2>
<button type="button" title="关闭" aria-label="关闭" onClick={() => setRowDraft(null)}><X size={17} /></button>
</div>
<div className="category-table-modal-grid">
<label>一级类目<input value={rowDraft.categoryL1} readOnly disabled /></label>
<label>二级类目<input value={rowDraft.categoryL2} readOnly disabled /></label>
<label>一级类目<input required maxLength={160} value={rowDraft.categoryL1} onChange={(event) => setRowDraft({ ...rowDraft, categoryL1: event.target.value })} placeholder="例如:桌类" /></label>
<label>二级类目<input required maxLength={160} value={rowDraft.categoryL2} onChange={(event) => setRowDraft({ ...rowDraft, categoryL2: event.target.value })} placeholder="例如:住宅家具 - 书桌" /></label>
</div>
<label>分类(可留空=未分类)
<input
list="category-row-group-options"
maxLength={80}
value={rowDraft.categoryGroup}
onChange={(event) => setRowDraft({ ...rowDraft, categoryGroup: event.target.value })}
placeholder="例如:电脑桌/书桌"
/>
<datalist id="category-row-group-options">
{groups.map((group) => <option key={group} value={group} />)}
</datalist>
</label>
<label>规模标签
<select value={rowDraft.scaleLabel} onChange={(event) => setRowDraft({ ...rowDraft, scaleLabel: event.target.value })}>
{CATEGORY_GROUP_SCALE_LABELS.map((label) => <option key={label} value={label}>{label}</option>)}
@ -383,13 +407,6 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) {
</div>
)}
<CategoryEditDialog
draft={categoryDraft}
groups={groups}
onClose={() => setCategoryDraft(null)}
onSaved={async () => { await load(); await loadGroups() }}
/>
<CategoryConfigImportProgress state={scaleImport.state} onDismiss={scaleImport.dismiss} />
<style jsx>{`
@ -444,7 +461,6 @@ export function CategoryConfigPanel({ canEdit }: { canEdit: boolean }) {
.category-table-modal-header button { display: inline-flex; border: 0; background: transparent; color: #71717a; cursor: pointer; }
.category-table-modal label { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; color: #52525b; font-size: 12px; }
.category-table-modal input, .category-table-modal select { height: 36px; width: 100%; box-sizing: border-box; padding: 0 10px; border: 1px solid #d4d4d8; border-radius: 5px; outline: 0; color: #27272a; font-size: 13px; }
.category-table-modal input:disabled { background: #f4f4f5; color: #a1a1aa; }
.category-table-modal input:focus, .category-table-modal select:focus { border-color: #52525b; }
.category-table-modal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.category-table-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; }

View File

@ -1,101 +0,0 @@
'use client'
import { FormEvent, useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { readCategoryApiResponse } from './api'
export type CategoryDraft = {
categoryL1: string
categoryL2: string
categoryGroup: string
status: 'ACTIVE' | 'INACTIVE'
}
type Props = {
draft: CategoryDraft | null
groups: string[]
onClose: () => void
onSaved: () => Promise<void> | void
}
export function CategoryEditDialog({ draft, groups, onClose, onSaved }: Props) {
const [form, setForm] = useState<CategoryDraft | null>(draft)
const [saving, setSaving] = useState(false)
useEffect(() => { setForm(draft) }, [draft])
if (!form) return null
const save = async (event: FormEvent) => {
event.preventDefault()
if (saving) return
setSaving(true)
try {
const response = await fetch('/api/supply-chain/category-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
categoryL1: form.categoryL1,
categoryL2: form.categoryL2,
categoryGroup: form.categoryGroup,
}),
})
await readCategoryApiResponse(response)
onClose()
await onSaved()
} catch (cause) {
window.alert(cause instanceof Error ? cause.message : '保存失败')
} finally {
setSaving(false)
}
}
return (
<div className="category-edit-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
<form className="category-edit-modal" onSubmit={save}>
<div className="category-edit-header">
<h2>新增类目</h2>
<button type="button" title="关闭" aria-label="关闭" onClick={onClose}><X size={17} /></button>
</div>
<div className="category-edit-grid">
<label>一级类目<input required maxLength={160} value={form.categoryL1} onChange={(event) => setForm({ ...form, categoryL1: event.target.value })} placeholder="例如:桌类" /></label>
<label>二级类目<input required maxLength={160} value={form.categoryL2} onChange={(event) => setForm({ ...form, categoryL2: event.target.value })} placeholder="例如:住宅家具 - 书桌" /></label>
</div>
<label>分类(可留空=未分类)
<input
list="category-edit-group-options"
maxLength={80}
value={form.categoryGroup}
onChange={(event) => setForm({ ...form, categoryGroup: event.target.value })}
placeholder="例如:电脑桌/书桌"
/>
<datalist id="category-edit-group-options">
{groups.map((group) => <option key={group} value={group} />)}
</datalist>
</label>
<p className="category-edit-hint">保存后会按所选分类自动带出 4 档规模行(爆款/热卖款/动销款/低动销款);分类未设置时生成 4 行空阈值行。</p>
<div className="category-edit-actions">
<button type="button" onClick={onClose}>取消</button>
<button type="submit" className="primary" disabled={saving}>{saving ? '保存中...' : '保存'}</button>
</div>
</form>
<style jsx>{`
.category-edit-backdrop { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(24,24,27,.26); }
.category-edit-modal { width: min(560px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow-y: auto; padding: 20px; border: 1px solid #e4e4e7; border-radius: 8px; background: #fff; box-shadow: 0 18px 50px rgba(24,24,27,.18); box-sizing: border-box; }
.category-edit-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.category-edit-modal h2 { margin: 0; font-size: 16px; font-weight: 650; }
.category-edit-header button { display: inline-flex; border: 0; background: transparent; color: #71717a; cursor: pointer; }
.category-edit-modal label { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; color: #52525b; font-size: 12px; }
.category-edit-modal input, .category-edit-modal select { height: 36px; width: 100%; box-sizing: border-box; padding: 0 10px; border: 1px solid #d4d4d8; border-radius: 5px; outline: 0; color: #27272a; font-size: 13px; }
.category-edit-modal input:focus, .category-edit-modal select:focus { border-color: #52525b; }
.category-edit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.category-edit-hint { margin: 14px 0 0; color: #a1a1aa; font-size: 12px; line-height: 1.6; }
.category-edit-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; }
.category-edit-actions button { height: 34px; padding: 0 13px; border: 1px solid #d4d4d8; border-radius: 5px; background: #fff; color: #52525b; cursor: pointer; }
.category-edit-actions button.primary { border-color: #27272a; background: #27272a; color: #fff; }
.category-edit-actions button:disabled { opacity: .55; cursor: wait; }
@media (max-width: 760px) { .category-edit-grid { grid-template-columns: 1fr; } }
`}</style>
</div>
)
}

View File

@ -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<ProductArchiveCategoryGroupTierRecord[]>
listGroups(): Promise<string[]>
findById(id: string): Promise<ProductArchiveCategoryGroupTierRecord | null>
findByKey(categoryGroup: string, scaleLabel: string): Promise<ProductArchiveCategoryGroupTierRecord | null>
create(input: {
categoryGroup: string
scaleLabel: string
performanceStart: number
performanceEnd: number | null
dailySalesStart: number
dailySalesEnd: number | null
sortOrder: number
}): Promise<ProductArchiveCategoryGroupTierRecord>
update(input: {
id: string
categoryGroup: string
scaleLabel: string
performanceStart: number
performanceEnd: number | null
dailySalesStart: number
dailySalesEnd: number | null
sortOrder: number
}): Promise<ProductArchiveCategoryGroupTierRecord>
remove(id: string): Promise<void>
}
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 }

View File

@ -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<ProductArchiveCategoryScaleRowRecord[]>
listByCategoryL2(categoryL2: string): Promise<ProductArchiveCategoryScaleRowRecord[]>
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<ProductArchiveCategoryScaleRowRecord[]>
listGroups(): Promise<string[]>
findById(id: string): Promise<ProductArchiveCategoryScaleRowRecord | null>
findByKey(categoryL2: string, scaleLabel: string): Promise<ProductArchiveCategoryScaleRowRecord | null>
create(input: {
categoryL1: string
categoryL2: string
categoryGroup: string
scaleLabel: string
performanceStart: number | null
performanceEnd: number | null
@ -34,6 +49,9 @@ export interface SupplyChainProductArchiveCategoryScaleRowRepository {
}): Promise<ProductArchiveCategoryScaleRowRecord>
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<void>
}
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

View File

@ -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(['书架', '茶几'])
})
})

View File

@ -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<CategoryGroupTierListResult> {
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<Record<string, unknown>>,
onProgress?: (progress: CategoryGroupTierImportProgress) => void,
): Promise<CategoryGroupTierImportResult> {
const existing = await this.repository.listAll({})
const existingByKey = new Map<string, ProductArchiveCategoryGroupTierRecord>()
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<string>()
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()

View File

@ -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<CategoryScaleRowListResult> {
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<number> {
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<Record<string, unknown>>,
onProgress?: (progress: CategoryConfigImportProgress) => void,
): Promise<CategoryConfigImportResult> {
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<string, ProductArchiveCategoryScaleRowRecord>()
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({