feat(clearance): 增加库存与任务指标每日刷新

This commit is contained in:
居居 2026-11-09 11:29:32 +08:00
parent 531c813d9a
commit ac72b4e5c1
6 changed files with 315 additions and 3 deletions

View File

@ -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%;上下限均可配置为包含或不包含,上限留空表示无上限,区间不可重叠,旧“最低完成率”配置会自动转换为连续区间。

View File

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

View File

@ -3,9 +3,16 @@ import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
let prisma: typeof import('../src/lib/prisma').prisma | undefined
let closeSourceRepository: (() => Promise<void>) | 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()
})

View File

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

View File

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

View File

@ -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<string, unknown>
: {}
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