From ac72b4e5c195799f8afa7b4badeedd0d400857de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 9 Nov 2026 11:29:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(clearance):=20=E5=A2=9E=E5=8A=A0=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=E4=BB=BB=E5=8A=A1=E6=8C=87=E6=A0=87=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/架构设计/清货管理-20261020.md | 6 +- .../run-limit-price-daily-snapshot.mjs | 4 + .../scripts/sync-clearance-progress.ts | 14 +- .../supply-chain-clearance.repository.ts | 40 ++++++ .../supply-chain-clearance.service.test.ts | 124 +++++++++++++++++ .../supply-chain-clearance.service.ts | 130 ++++++++++++++++++ 6 files changed, 315 insertions(+), 3 deletions(-) diff --git a/furniture-3d-system/docs/架构设计/清货管理-20261020.md b/furniture-3d-system/docs/架构设计/清货管理-20261020.md index 0c5a0b52..4d16594f 100644 --- a/furniture-3d-system/docs/架构设计/清货管理-20261020.md +++ b/furniture-3d-system/docs/架构设计/清货管理-20261020.md @@ -147,8 +147,10 @@ 执行进度: +- 每日档案与销售同步完成后,自动刷新当前清货池的库存拆项、简尚库存、工厂库存、总库存、日销、可用天数及建议折扣相关分数;优先级、批次、状态、人工覆盖等其他字段保持不变。 +- 清货任务页读取每日刷新后的池库存、日销和可用天数,并据此重新计算真实任务量、清货进度、分数概览等派生结果。 - 清货任务页实时读取本地销售出库日数据,口径为清货月份内 `shipQty` 合计,仅对 `APPROVED` 任务生效。 -- 每日销售出库同步完成后会自动回写上月和当月任务进度;也可单独执行 `npm run sync:clearance-progress -- --period=YYYY-MM`,不传月份时同步当月。 +- 每日销售出库同步完成后会自动刷新清货池指标并回写当月任务进度;也可单独执行 `npm run sync:clearance-progress -- --period=YYYY-MM`,不传月份时同步当月。 - 回写内容为 `clearedQty`、`lastProgressAt`;销售源未覆盖目标月份时不覆盖上次进度。 - 本地销售出库数据未覆盖目标月份时保留上次持久化进度,页面会显示覆盖到哪一天。 @@ -194,7 +196,7 @@ ## 6. 当前版本边界 -- 清货任务的 `clearedQty` 已接入本地销售出库汇总;页面实时展示,持久化由 `sync:clearance-progress` 完成,生产定时任务仍需按部署环境配置。 +- 清货任务的 `clearedQty` 已接入本地销售出库汇总;页面实时展示,清货池指标和任务发货进度均由每日 09:30 数据同步链中的 `sync:clearance-progress` 持久化。 - 所有旧导入、重置和库存同步脚本仍会拒绝直接修改已生成月份;更新必须通过经管「清货配置 > 时间窗口」中的生成选项执行。 - 清货分数先使用示例权重:完成度 60 + 毛利率 25 + 货值回收 15;历史周期使用复盘快照计算,当月任务只有完成度可计算。 - 清货分数档位按完成率上下限区间匹配,完成率允许高于 100%;上下限均可配置为包含或不包含,上限留空表示无上限,区间不可重叠,旧“最低完成率”配置会自动转换为连续区间。 diff --git a/furniture-3d-system/scripts/run-limit-price-daily-snapshot.mjs b/furniture-3d-system/scripts/run-limit-price-daily-snapshot.mjs index c8a7096e..98d382ce 100644 --- a/furniture-3d-system/scripts/run-limit-price-daily-snapshot.mjs +++ b/furniture-3d-system/scripts/run-limit-price-daily-snapshot.mjs @@ -66,6 +66,10 @@ async function main() { path.join(root, 'scripts/local-sync-archive.mjs'), '--skip-sabc', ]) + await run('刷新清货池与清货任务每日指标', [ + tsxCli, + path.join(root, 'scripts/sync-clearance-progress.ts'), + ]) await run('同步负责人限价流程', [ path.join(root, 'scripts/sync-limit-workflows.mjs'), ]) diff --git a/furniture-3d-system/scripts/sync-clearance-progress.ts b/furniture-3d-system/scripts/sync-clearance-progress.ts index 638b628c..178b45b1 100644 --- a/furniture-3d-system/scripts/sync-clearance-progress.ts +++ b/furniture-3d-system/scripts/sync-clearance-progress.ts @@ -3,9 +3,16 @@ import { loadEnvConfig } from '@next/env' loadEnvConfig(process.cwd()) let prisma: typeof import('../src/lib/prisma').prisma | undefined +let closeSourceRepository: (() => Promise) | undefined async function main() { prisma = (await import('../src/lib/prisma')).prisma + closeSourceRepository = async () => { + const { supplyChainDiscontinuationSourceRepository } = await import( + '../src/server/repositories/supply-chain-discontinuation-source.repository' + ) + await supplyChainDiscontinuationSourceRepository.close() + } const { normalizePeriodKey, shanghaiPeriodKey, @@ -20,7 +27,11 @@ async function main() { )) const results = [] for (const periodKey of periods) { - results.push(await supplyChainClearanceService.syncTaskProgress(periodKey)) + const poolMetrics = await supplyChainClearanceService.refreshClearanceDailyMetrics( + periodKey, + ) + const taskProgress = await supplyChainClearanceService.syncTaskProgress(periodKey) + results.push({ ...taskProgress, poolMetrics }) } console.log(JSON.stringify({ results }, null, 2)) } @@ -31,5 +42,6 @@ void main() process.exitCode = 1 }) .finally(async () => { + await closeSourceRepository?.().catch(() => undefined) await prisma?.$disconnect() }) diff --git a/furniture-3d-system/src/server/repositories/supply-chain-clearance.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-clearance.repository.ts index 181f0758..fbb272e8 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-clearance.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-clearance.repository.ts @@ -535,6 +535,46 @@ class SupplyChainClearanceRepository { }, { maxWait: 10_000, timeout: 240_000 }) } + async updatePoolDailyMetrics(input: { + updates: Array<{ + id: string + stockJianshang: number + stockFactory: number + totalStock: number + dailySales: string | null + availableDays: string + extra: Prisma.InputJsonValue + }> + }) { + if (!input.updates.length) return 0 + await prisma.$transaction(async (tx) => { + for (let start = 0; start < input.updates.length; start += 1000) { + const part = input.updates.slice(start, start + 1000) + await tx.$executeRaw(Prisma.sql` + UPDATE "SupplyChainClearancePoolSnapshot" AS pool + SET + "stockJianshang" = snapshot."stockJianshang", + "stockFactory" = snapshot."stockFactory", + "totalStock" = snapshot."totalStock", + "dailySales" = snapshot."dailySales"::numeric, + "availableDays" = snapshot."availableDays", + "extra" = snapshot."extra" + FROM jsonb_to_recordset(${JSON.stringify(part)}::jsonb) AS snapshot( + "id" text, + "stockJianshang" integer, + "stockFactory" integer, + "totalStock" integer, + "dailySales" text, + "availableDays" text, + "extra" jsonb + ) + WHERE pool."id" = snapshot."id" + `) + } + }, { maxWait: 10_000, timeout: 240_000 }) + return input.updates.length + } + async latestPoolStatDate() { const latest = await prisma.supplyChainClearancePoolSnapshot.findFirst({ orderBy: [{ statDate: 'desc' }, { statTime: 'desc' }], diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-clearance.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-clearance.service.test.ts index 21b78f1d..1937910a 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-clearance.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-clearance.service.test.ts @@ -47,6 +47,7 @@ jest.mock('@/server/repositories/supply-chain-clearance.repository', () => ({ replacePoolGeneration: jest.fn(), listPoolRowsForRefresh: jest.fn(), refreshPoolGeneration: jest.fn(), + updatePoolDailyMetrics: jest.fn(), latestPoolStatDate: jest.fn(), listPoolFilterOptions: jest.fn(), listPool: jest.fn(), @@ -383,6 +384,7 @@ describe('supply-chain-clearance workflow', () => { missingBarcodes: [], }) repository.listPoolRowsForRefresh.mockResolvedValue([]) + repository.updatePoolDailyMetrics.mockResolvedValue(0) repository.listPoolFilterOptions.mockResolvedValue([]) repository.poolOverview.mockResolvedValue({ stockJianshang: 0, @@ -1563,6 +1565,128 @@ describe('supply-chain-clearance workflow', () => { expect(sourceRepository.listLatestDiscontinuationDatesByBarcodes).not.toHaveBeenCalled() }) + it('refreshes only daily pool metrics while preserving batch priority and inbound fields', async () => { + repository.findPoolGeneration.mockResolvedValue({ + id: 'generation-1', + periodKey: '2026-11', + generationMode: 'FULL', + statDate: new Date('2026-10-27T00:00:00.000Z'), + generatedAt: new Date('2026-10-27T08:21:18.073Z'), + generatedById: user.id, + generatedByName: user.name, + rowCount: 2, + createdAt: new Date(), + }) + repository.listPoolRowsForRefresh.mockResolvedValue([ + { + id: 'pool-1', + barcode: 'SKU001', + extra: { + priority: '人工重点清货', + clearanceBatch: '第55批', + lastInboundAt: '2026-03-12', + inboundDays: 45, + taskChannelClosed: false, + }, + }, + { + id: 'pool-2', + barcode: 'MISSING001', + extra: { priority: '保持不变' }, + }, + ]) + repository.listProductArchiveTaskValuesByBarcodes.mockResolvedValue([{ + barcode: 'SKU001', + spu: 'SPU001', + productTag: '', + inOutWarehouse: '', + weight: null, + length: null, + width: null, + height: null, + volume: null, + costPrice: '12.50', + sales3d: '10', + sales7d: '5', + sales15d: '2', + stockJianshang: '0', + stockFactory: '0', + }] as never) + sourceRepository.listClearanceWarehouseInventoryByBarcodes.mockResolvedValue([{ + barcode: 'SKU001', + bigWarehouseStock: 100, + frontWarehouseStock: 30, + dedicatedWarehouseStock: 20, + dropshipStock: 10, + }]) + sourceRepository.listSupplierClearanceByBarcodes.mockResolvedValue([{ + barcode: 'SKU001', + supplierRemainingStock: 20, + hasPlmRecord: true, + hasActivePlmRecord: true, + sourceRecordId: 'plm-1', + sourceRecordedAt: new Date(), + }]) + repository.updatePoolDailyMetrics.mockResolvedValue(1) + + await expect(supplyChainClearanceService.refreshClearanceDailyMetrics( + '2026-11', + new Date('2026-11-09T01:00:00.000Z'), + )).resolves.toMatchObject({ + periodKey: '2026-11', + hasPool: true, + poolRows: 2, + updated: 1, + unmatched: 1, + metricsDate: '2026-11-09', + }) + + expect(repository.updatePoolDailyMetrics).toHaveBeenCalledWith({ + updates: [{ + id: 'pool-1', + stockJianshang: 150, + stockFactory: 30, + totalStock: 180, + dailySales: '6.90', + availableDays: '26', + extra: expect.objectContaining({ + priority: '人工重点清货', + clearanceBatch: '第55批', + lastInboundAt: '2026-03-12', + inboundDays: 45, + taskChannelClosed: false, + bigWarehouseStock: 100, + frontWarehouseStock: 30, + dedicatedWarehouseStock: 20, + dropshipStock: 10, + supplierRemainingStock: 20, + factoryTotalStock: 30, + stockUsableDaysScore: 100, + dailySalesScore: 100, + inboundDaysScore: 100, + totalScore: 100, + suggestedDiscount: 1, + reviewScoreVersion: '2026-11-v2', + }), + }], + }) + expect(repository.refreshPoolGeneration).not.toHaveBeenCalled() + expect(sourceRepository.listLatestPurchaseInboundDatesByBarcodes).not.toHaveBeenCalled() + }) + + it('skips daily pool metrics when the month has no generated pool', async () => { + repository.findPoolGeneration.mockResolvedValue(null) + + await expect(supplyChainClearanceService.refreshClearanceDailyMetrics('2026-11')) + .resolves.toMatchObject({ + hasPool: false, + poolRows: 0, + updated: 0, + }) + expect(repository.listPoolRowsForRefresh).not.toHaveBeenCalled() + expect(repository.updatePoolDailyMetrics).not.toHaveBeenCalled() + }) + it('submits a complete task with quantity, discount and reason', async () => { repository.createTask.mockImplementation(async (data) => ({ ...pendingTask, diff --git a/furniture-3d-system/src/server/services/supply-chain-clearance.service.ts b/furniture-3d-system/src/server/services/supply-chain-clearance.service.ts index 9f5db31f..65d64d6a 100644 --- a/furniture-3d-system/src/server/services/supply-chain-clearance.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-clearance.service.ts @@ -1316,6 +1316,136 @@ class SupplyChainClearanceService { } } + async refreshClearanceDailyMetrics( + periodKeyInput?: string | null, + metricsAt = new Date(), + ) { + const periodKey = normalizePeriodKey(periodKeyInput) + const generation = await supplyChainClearanceRepository.findPoolGeneration(periodKey) + if (!generation) { + return { + periodKey, + hasPool: false, + poolRows: 0, + updated: 0, + unmatched: 0, + metricsDate: shanghaiDateKey(metricsAt), + } + } + const poolRows = await supplyChainClearanceRepository.listPoolRowsForRefresh(periodKey) + if (!poolRows.length) { + return { + periodKey, + hasPool: true, + poolRows: 0, + updated: 0, + unmatched: 0, + metricsDate: shanghaiDateKey(metricsAt), + } + } + const barcodes = Array.from(new Set( + poolRows.map((row) => row.barcode.trim().toUpperCase()).filter(Boolean), + )) + const [ + archiveValues, + warehouseProfiles, + supplierProfiles, + virtualCloudStocks, + clearanceConfig, + ] = await Promise.all([ + supplyChainClearanceRepository.listProductArchiveTaskValuesByBarcodes(barcodes), + supplyChainDiscontinuationSourceRepository.listClearanceWarehouseInventoryByBarcodes( + barcodes, + ), + supplyChainDiscontinuationSourceRepository.listSupplierClearanceByBarcodes(barcodes), + supplyChainClearanceRepository.listLatestVirtualCloudStocks(), + supplyChainClearanceRepository.findClearanceConfig(), + ]) + const archiveByBarcode = new Map( + archiveValues.map((row) => [row.barcode.trim().toUpperCase(), row]), + ) + const warehouseByBarcode = new Map( + warehouseProfiles.map((row) => [row.barcode.trim().toUpperCase(), row]), + ) + const supplierByBarcode = new Map( + supplierProfiles.map((row) => [row.barcode.trim().toUpperCase(), row]), + ) + const cloudStockByBarcode = new Map( + virtualCloudStocks.map((row) => [ + row.barcode.trim().toUpperCase(), + Math.max(0, integer(row.cloudStock)), + ]), + ) + const reviewScoreConfig = normalizeClearanceReviewScoreConfig( + clearanceConfig?.reviewScoreConfig, + ) + const updates = poolRows.flatMap((poolRow) => { + const barcode = poolRow.barcode.trim().toUpperCase() + const archive = archiveByBarcode.get(barcode) + if (!archive) return [] + const warehouse = warehouseByBarcode.get(barcode) + const supplier = supplierByBarcode.get(barcode) + const bigWarehouseStock = integer(warehouse?.bigWarehouseStock) + const frontWarehouseStock = integer(warehouse?.frontWarehouseStock) + const dedicatedWarehouseStock = integer(warehouse?.dedicatedWarehouseStock) + const dropshipStock = integer(warehouse?.dropshipStock) + const supplierRemainingStock = integer(supplier?.supplierRemainingStock) + const physicalJianshangStock = Math.max( + 0, + bigWarehouseStock + frontWarehouseStock + dedicatedWarehouseStock, + ) + const stockJianshang = netClearanceJianshangStock( + physicalJianshangStock, + cloudStockByBarcode.get(barcode) ?? 0, + ) + const stockFactory = Math.max( + 0, + supplierRemainingStock + dropshipStock, + ) + const totalStock = stockJianshang + stockFactory + const dailySales = calculateClearanceDailySales(archive) + const availableDays = availableDaysText(totalStock, dailySales) + const existingExtra = + poolRow.extra && typeof poolRow.extra === 'object' && !Array.isArray(poolRow.extra) + ? poolRow.extra as Record + : {} + const reviewScore = calculateClearanceReviewScore({ + availableDays, + dailySales, + inboundDays: existingExtra.inboundDays, + }, reviewScoreConfig) + return [{ + id: poolRow.id, + stockJianshang, + stockFactory, + totalStock, + dailySales: dailySales?.toFixed(2) ?? null, + availableDays, + extra: { + ...existingExtra, + bigWarehouseStock, + frontWarehouseStock, + dedicatedWarehouseStock, + dropshipStock, + supplierRemainingStock, + factoryTotalStock: stockFactory, + ...poolReviewScoreExtra(reviewScore), + }, + }] + }) + const updated = await supplyChainClearanceRepository.updatePoolDailyMetrics({ + updates, + }) + return { + periodKey, + hasPool: true, + poolRows: poolRows.length, + updated, + unmatched: poolRows.length - updated, + metricsDate: shanghaiDateKey(metricsAt), + } + } + async getPool(input: ClearancePoolQueryInput) { const generations = await supplyChainClearanceRepository.listPoolGenerations() const requestedPeriod = isPeriodKey(input.periodKey) ? normalizePeriodKey(input.periodKey) : null