From 8c520de0aa74f4094b83ff786d84ac815636d4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Tue, 20 Oct 2026 13:04:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(supply-chain):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=BB=84=E5=90=88=E9=99=90=E4=BB=B7=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/api/downloads/route.ts | 16 ++-- .../custom-table/CustomTableView.test.tsx | 18 +++++ .../management/custom-table/LimitPriceTab.tsx | 39 +++++++++- ...y-chain-combo-limit-export.service.test.ts | 59 +++++++++++++++ .../supply-chain-combo-limit.service.test.ts | 24 ++++++ .../download-center.executors.ts | 60 +++++++++++++++ ...supply-chain-combo-limit-export.service.ts | 73 +++++++++++++++++++ .../supply-chain-combo-limit.service.ts | 10 +++ 8 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit-export.service.test.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-combo-limit-export.service.ts diff --git a/furniture-3d-system/src/app/api/downloads/route.ts b/furniture-3d-system/src/app/api/downloads/route.ts index 6558428f..6c70cfa6 100644 --- a/furniture-3d-system/src/app/api/downloads/route.ts +++ b/furniture-3d-system/src/app/api/downloads/route.ts @@ -23,22 +23,24 @@ export async function POST(request: NextRequest) { const params = (body?.params && typeof body.params === 'object' ? body.params : {}) as Record // 任务级权限:产品档案导出走「产品全量档案 · 下载」,限价基础信息导出走「限价管理 · 下载」 - if (type === 'archive-export' || type === 'limit-export') { + if (type === 'archive-export' || type === 'limit-export' || type === 'combo-limit-export') { const { supplyChainPermissionSettingsService } = await import( '@/server/services/supply-chain-permission-settings.service' ) - const target = type === 'limit-export' ? 'limitManagement' : 'productArchive' + const target = type === 'archive-export' ? 'productArchive' : 'limitManagement' const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, target) if (!access.download) { return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId) } // 字段权限在创建任务时快照进参数:后台执行器拿不到用户上下文, // 且排队期间权限变化不应影响已建任务产物的口径。 - const { productArchiveFieldPermissionService } = await import( - '@/server/services/product-archive-field-permission.service' - ) - const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user) - params.fieldBlocks = [...blockedFields] + if (type !== 'combo-limit-export') { + const { productArchiveFieldPermissionService } = await import( + '@/server/services/product-archive-field-permission.service' + ) + const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user) + params.fieldBlocks = [...blockedFields] + } } try { diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx index 4acfa94d..ad573ae7 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx @@ -202,6 +202,12 @@ function mockLimitManagementFetch(rows: SupplyChainProductArchiveRow[]) { }, }) } + if (url.includes('/api/downloads')) { + return jsonResponse({ + success: true, + data: { id: 'download-task-1', status: 'pending' }, + }) + } throw new Error(`unexpected fetch: ${url}`) }) as unknown as typeof fetch } @@ -257,6 +263,8 @@ describe('限价管理', () => { await screen.findByText('测试岩板餐桌') expect(screen.getByRole('button', { name: '导出明细' })).toBeInTheDocument() expect(screen.getByRole('button', { name: '导出明细(含图片)' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '组合限价' })) + expect(await screen.findByRole('button', { name: '导出组合限价' })).toBeInTheDocument() }) it('品牌商品旁展示组合限价页,并只展示组合编码与限价价格字段', async () => { @@ -279,6 +287,16 @@ describe('限价管理', () => { const columnLabels = screen.getAllByRole('columnheader').map((cell) => (cell.textContent ?? '').replace(/[↕↑↓▾]/g, '').trim()) expect(columnLabels.indexOf('运营负责人')).toBe(columnLabels.indexOf('组合编码') + 1) expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '导出组合限价' })) + await waitFor(() => { + const exportCall = (global.fetch as jest.Mock).mock.calls + .find((call) => String(call[0]).includes('/api/downloads')) + expect(exportCall).toBeTruthy() + const body = JSON.parse(String((exportCall?.[1] as RequestInit | undefined)?.body ?? '{}')) + expect(body.type).toBe('combo-limit-export') + expect(body.params.sort).toEqual({ field: 'ownerLimitPrice', direction: 'desc' }) + }) }) it('条码候选值跟随已选商品状态,避免选出与明细条件冲突的条码', async () => { diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx index a4fd750d..d07499d5 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx @@ -241,7 +241,7 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c // 使用 filters(用户主动选择的筛选条件,如商品状态/品牌/类目等); // 「大货 / 品牌」页签的分区不参与导出——导出始终包含大货 + 品牌全部数据; // columnSet=limit 让导出抬头与限价界面完全一致(在下载中心执行器里固定)。 - const [creatingExport, setCreatingExport] = useState<'plain' | 'image' | null>(null) + const [creatingExport, setCreatingExport] = useState<'plain' | 'image' | 'combo' | null>(null) const [exportMessage, setExportMessage] = useState(null) useEffect(() => { if (!exportMessage) return @@ -270,6 +270,31 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c } }, [filters]) + const createComboLimitExportTask = useCallback(async () => { + setCreatingExport('combo') + setExportMessage(null) + try { + const response = await fetch('/api/downloads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'combo-limit-export', + params: { filters: comboFilters, sort: comboSort }, + }), + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) { + throw new Error(payload?.error?.message || '创建组合限价导出任务失败') + } + requestDownloadCenterRefresh() + setExportMessage('已加入下载中心,点击右上角下载图标查看进度') + } catch (error) { + setExportMessage(error instanceof Error ? error.message : '创建组合限价导出任务失败') + } finally { + setCreatingExport(null) + } + }, [comboFilters, comboSort]) + // 单页排序:只对当前页已加载的数据排序(与截图里的「升序(单页)」口径一致)。 const displayRows = useMemo(() => { if (!sort) return rows @@ -489,7 +514,17 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c )} - {productKind !== 'combo' && exportMessage ? {exportMessage} : null} + {productKind === 'combo' && canExport && ( + + )} + {exportMessage ? {exportMessage} : null} {productKind !== 'combo' && ( ({ + jstComboLimitSourceRepository: { + listCompleteComboLimits: jest.fn().mockResolvedValue([]), + }, +})) + +import ExcelJS from 'exceljs' +import { COMBO_LIMIT_FIELDS, buildComboLimitRow } from '@/lib/supply-chain-combo-limit-price' +import { SupplyChainComboLimitExportService } from '../supply-chain-combo-limit-export.service' + +describe('SupplyChainComboLimitExportService', () => { + it('exports all filtered combo limit columns to an Excel worksheet', async () => { + const rows = [ + buildComboLimitRow({ + combineCode: 'COMBO-A', + opsHead: '运营甲', + itemCount: 2, + costCount: 2, + ownerTotal: 69, + nonOwnerTotal: 79, + costTotal: 37, + }), + buildComboLimitRow({ + combineCode: 'COMBO-B', + opsHead: '运营乙', + itemCount: 1, + costCount: 1, + ownerTotal: 50, + nonOwnerTotal: 60, + costTotal: 30, + }), + ] + const reader = { + listAll: jest.fn().mockResolvedValue(rows), + } + const service = new SupplyChainComboLimitExportService( + reader, + () => new Date('2026-10-20T02:30:45.000Z'), + ) + + const result = await service.createWorkbook({ + filters: { comboCode: ['COMBO-A'] }, + sort: { field: 'ownerLimitPrice', direction: 'desc' }, + }) + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(result.content) + const sheet = workbook.getWorksheet('组合限价') + + expect(result.fileName).toBe('组合限价-20261020-103045.xlsx') + expect(result.rowCount).toBe(2) + expect(reader.listAll).toHaveBeenCalledWith({ + filters: { comboCode: ['COMBO-A'] }, + sort: { field: 'ownerLimitPrice', direction: 'desc' }, + }) + expect(sheet?.getRow(1).values.slice(1)).toEqual(COMBO_LIMIT_FIELDS.map((field) => field.label)) + expect(sheet?.getRow(2).getCell(1).value).toBe('COMBO-A') + expect(sheet?.getRow(2).getCell(3).value).toBe(69) + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts index 290c6287..e4ee489f 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts @@ -71,6 +71,30 @@ describe('SupplyChainComboLimitService', () => { expect(result.values).toEqual(['COMBO-B']) }) + it('returns all filtered rows for exports without pagination', async () => { + const repository = { + listCompleteComboLimits: jest.fn().mockResolvedValue([ + aggregate('COMBO-B', 80), + aggregate('COMBO-A', 69), + aggregate('COMBO-C', 90), + ]), + } + const service = new SupplyChainComboLimitService( + repository, + jest.fn().mockResolvedValue([ + { barcode: 'A', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }, + ]), + () => 1_000, + ) + + const rows = await service.listAll({ + filters: { opsHead: ['运营甲'] }, + sort: { field: 'ownerLimitPrice', direction: 'desc' }, + }) + + expect(rows.map((row) => row.comboCode)).toEqual(['COMBO-C', 'COMBO-B', 'COMBO-A']) + }) + it('removes stopped and discontinued child products before building combinations', async () => { const repository = { listCompleteComboLimits: jest.fn().mockResolvedValue([]), diff --git a/furniture-3d-system/src/server/services/download-center/download-center.executors.ts b/furniture-3d-system/src/server/services/download-center/download-center.executors.ts index 7e50cab5..871050a9 100644 --- a/furniture-3d-system/src/server/services/download-center/download-center.executors.ts +++ b/furniture-3d-system/src/server/services/download-center/download-center.executors.ts @@ -12,6 +12,14 @@ import { stripProductArchiveBlockedFilters, isProductArchiveFieldKey, } from '@/lib/product-archive-field-permissions' +import { + isComboLimitFieldKey, + type ComboLimitFilters, + type ComboLimitSort, +} from '@/lib/supply-chain-combo-limit-price' +import { + supplyChainComboLimitExportService, +} from '@/server/services/supply-chain-combo-limit-export.service' export type DownloadExecutorContext = { taskId: string @@ -116,9 +124,61 @@ const limitExportExecutor: DownloadExecutor = { }, } +function parseComboLimitExportParams(params: Record) { + const filters: ComboLimitFilters = {} + const rawFilters = params.filters + if (rawFilters !== undefined) { + if (!rawFilters || typeof rawFilters !== 'object' || Array.isArray(rawFilters)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '组合限价筛选条件不正确') + } + for (const [key, value] of Object.entries(rawFilters)) { + if (!isComboLimitFieldKey(key) || !Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '组合限价筛选条件不正确') + } + filters[key] = value + } + } + + let sort: ComboLimitSort = null + if (params.sort !== undefined && params.sort !== null) { + const rawSort = params.sort + if (!rawSort || typeof rawSort !== 'object' || Array.isArray(rawSort)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '组合限价排序条件不正确') + } + const record = rawSort as Record + if ( + typeof record.field !== 'string' + || !isComboLimitFieldKey(record.field) + || (record.direction !== 'asc' && record.direction !== 'desc') + ) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '组合限价排序条件不正确') + } + sort = { field: record.field, direction: record.direction } + } + return { filters, sort } +} + +const comboLimitExportExecutor: DownloadExecutor = { + label: '组合限价', + validateParams(params) { + parseComboLimitExportParams(params) + }, + async run({ taskDir, params, onProgress }) { + const parsed = parseComboLimitExportParams(params) + const result = await supplyChainComboLimitExportService.createWorkbook({ + ...parsed, + onProgress: (processedRows, totalRows) => onProgress?.(rowProgress(processedRows, totalRows)), + }) + const filePath = path.join(taskDir, path.basename(result.fileName)) + await fs.promises.writeFile(filePath, result.content) + return { fileName: result.fileName, rowCount: result.rowCount } + }, +} + export const downloadExecutors: Record = { 'archive-export': archiveExportExecutor, 'limit-export': limitExportExecutor, + 'combo-limit-export': comboLimitExportExecutor, } export function getDownloadExecutor(type: string): DownloadExecutor | null { diff --git a/furniture-3d-system/src/server/services/supply-chain-combo-limit-export.service.ts b/furniture-3d-system/src/server/services/supply-chain-combo-limit-export.service.ts new file mode 100644 index 00000000..0ba0663c --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-combo-limit-export.service.ts @@ -0,0 +1,73 @@ +import ExcelJS from 'exceljs' +import { + COMBO_LIMIT_FIELDS, + type ComboLimitFilters, + type ComboLimitRow, + type ComboLimitSort, +} from '@/lib/supply-chain-combo-limit-price' +import { + SupplyChainComboLimitService, + supplyChainComboLimitService, +} from './supply-chain-combo-limit.service' + +type ComboLimitReader = Pick + +function exportStamp(date: Date) { + const digits = date + .toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai', hour12: false }) + .replace(/\D/g, '') + .slice(0, 14) + return `${digits.slice(0, 8)}-${digits.slice(8, 14)}` +} + +export class SupplyChainComboLimitExportService { + constructor( + private readonly reader: ComboLimitReader = supplyChainComboLimitService, + private readonly now: () => Date = () => new Date(), + ) {} + + async createWorkbook(input: { + filters?: ComboLimitFilters + sort?: ComboLimitSort + onProgress?: (processedRows: number, totalRows: number) => void + } = {}) { + input.onProgress?.(0, 0) + const rows = await this.reader.listAll({ + filters: input.filters, + sort: input.sort, + }) + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet('组合限价') + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + worksheet.columns = COMBO_LIMIT_FIELDS.map((field) => ({ + header: field.label, + key: field.key, + width: field.key === 'comboCode' ? 48 : field.key === 'opsHead' ? 22 : 16, + style: field.numeric ? { numFmt: '#,##0.00' } : undefined, + })) + worksheet.getRow(1).font = { bold: true } + worksheet.getRow(1).alignment = { vertical: 'middle' } + for (const [index, row] of rows.entries()) { + worksheet.addRow(Object.fromEntries(COMBO_LIMIT_FIELDS.map((field) => { + const value = row[field.key] + return [field.key, field.numeric && value ? Number(value) : value] + })) as Partial) + if (index === rows.length - 1 || index % 500 === 0) { + input.onProgress?.(index + 1, rows.length) + } + } + worksheet.autoFilter = { + from: { row: 1, column: 1 }, + to: { row: Math.max(1, rows.length + 1), column: COMBO_LIMIT_FIELDS.length }, + } + const content = Buffer.from(await workbook.xlsx.writeBuffer()) + input.onProgress?.(rows.length, rows.length) + return { + fileName: `组合限价-${exportStamp(this.now())}.xlsx`, + content, + rowCount: rows.length, + } + } +} + +export const supplyChainComboLimitExportService = new SupplyChainComboLimitExportService() diff --git a/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts index 8767cee2..eb2b1289 100644 --- a/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts @@ -117,6 +117,16 @@ export class SupplyChainComboLimitService { ) } + async listAll(input: { + filters?: ComboLimitFilters + sort?: ComboLimitSort + forceRefresh?: boolean + } = {}) { + const rows = await this.loadRows(input.forceRefresh) + const filtered = filterComboLimitRows(rows, input.filters ?? {}) + return sortComboLimitRows(filtered, input.sort ?? null) + } + async listOptions(input: { field: ComboLimitFieldKey q?: string