refactor(category-config): 类目规模合并为单表展示结构(一级类目/分类并入规模行,移除分类档位模板表)
This commit is contained in:
parent
cb3efaa50c
commit
9e91f18dfd
@ -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";
|
||||
@ -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 {
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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'],
|
||||
})
|
||||
}
|
||||
@ -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'],
|
||||
})
|
||||
}
|
||||
@ -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'],
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
},
|
||||
{
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
@ -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
|
||||
|
||||
@ -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(['书架', '茶几'])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user