diff --git a/furniture-3d-system/src/server/services/supply-chain-product-sabc.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-sabc.service.ts index 1c648de9..5de479e0 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-sabc.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-sabc.service.ts @@ -127,62 +127,48 @@ type ReplacementContext = { } type ReplacementIndex = { - newToOld: Map - oldToNew: Map + // 同款商品编码(单个):正向「商品编码→可替换商品编码」优先,未匹配到的用反向「可替换商品编码→商品编码」补齐。 + partner: Map } function normalizeCode(value: string) { return String(value ?? '').trim().toUpperCase() } -// 同款商品编码(业绩&费比合并计算)规则:新编码承接老编码业绩; -// 同一老编码有多条规则时取创建时间(sourceCreatedAt)最新的一条; -// 链式替换(A→B→C)顺延到最终编码,保证新老两侧都能匹配到同一个同款编码。 +// 同款商品编码(业绩&费比合并计算)规则: +// 1) 正向:商品编码(sku) 匹配 可替换商品编码(replaceSku); +// 2) 反向:没有正向匹配到的编码,用 可替换商品编码 反查 商品编码; +// 同一条编码有多条规则时取创建时间(sourceCreatedAt)最新的一条。 function buildReplacementIndex(rules: Array<{ sku: string; replaceSku: string; sourceCreatedAt?: string | null; sourceId?: number }>): ReplacementIndex { - const latestByOld = new Map() + const forward = new Map() + const reverse = new Map() for (const rule of rules) { - const oldCode = normalizeCode(rule.sku) - const newCode = normalizeCode(rule.replaceSku) - if (!oldCode || !newCode || oldCode === newCode) continue + const sku = normalizeCode(rule.sku) + const replaceSku = normalizeCode(rule.replaceSku) + if (!sku || !replaceSku || sku === replaceSku) continue const at = String(rule.sourceCreatedAt ?? '') const sourceId = Number(rule.sourceId ?? 0) - const current = latestByOld.get(oldCode) - if (!current || at > current.at || (at === current.at && sourceId >= current.sourceId)) { - latestByOld.set(oldCode, { newCode, at, sourceId }) + const currentForward = forward.get(sku) + if (!currentForward || at > currentForward.at || (at === currentForward.at && sourceId >= currentForward.sourceId)) { + forward.set(sku, { code: replaceSku, at, sourceId }) + } + const currentReverse = reverse.get(replaceSku) + if (!currentReverse || at > currentReverse.at || (at === currentReverse.at && sourceId >= currentReverse.sourceId)) { + reverse.set(replaceSku, { code: sku, at, sourceId }) } } - const direct = new Map( - [...latestByOld.entries()].map(([oldCode, value]) => [oldCode, value.newCode]), - ) - const oldToNew = new Map() - for (const [oldCode, newCode] of direct) { - let current = newCode - const seen = new Set([oldCode]) - let cyclic = false - while (direct.has(current)) { - if (seen.has(current)) { cyclic = true; break } - seen.add(current) - current = direct.get(current) as string - } - if (!cyclic) oldToNew.set(oldCode, current) + const partner = new Map() + for (const [sku, value] of forward) partner.set(sku, value.code) + for (const [replaceSku, value] of reverse) { + if (!partner.has(replaceSku)) partner.set(replaceSku, value.code) } - const newToOld = new Map() - for (const [oldCode, newCode] of oldToNew) { - const list = newToOld.get(newCode) ?? [] - list.push(oldCode) - newToOld.set(newCode, list) - } - return { newToOld, oldToNew } + return { partner } } function replacementContextFor(barcode: string, index: ReplacementIndex): ReplacementContext { const code = normalizeCode(barcode) - const mergedInto = index.oldToNew.get(code) - if (mergedInto) return { sameProductCode: mergedInto, mergedAway: true } - const oldCodes = index.newToOld.get(code) - // 字段只展示同款编码;一个编码承接多个老编码时展示第一个。 - if (oldCodes?.length) return { sameProductCode: oldCodes[0], mergedAway: false } - return { sameProductCode: '无', mergedAway: false } + const partner = index.partner.get(code) + return { sameProductCode: partner ?? '无', mergedAway: false } } function sumDecimalText(left: string, right: string) { @@ -190,7 +176,7 @@ function sumDecimalText(left: string, right: string) { } function mergeReplacementAggregates(rows: MonthlyAggregateRow[], index: ReplacementIndex): MonthlyAggregateRow[] { - if (index.oldToNew.size === 0) return rows + if (index.partner.size === 0) return rows const original = new Map(rows.map((row) => [normalizeCode(row.barcode), row])) const merged = new Map(original) const zeroRow = (barcode: string): MonthlyAggregateRow => ({ @@ -201,23 +187,21 @@ function mergeReplacementAggregates(rows: MonthlyAggregateRow[], index: Replacem grossProfit: '0', afterSaleAmount: '0', }) - for (const [oldCode, newCode] of index.oldToNew) { - const oldRow = original.get(oldCode) - const newRow = original.get(newCode) - if (!oldRow && !newRow) continue - const oldBase = oldRow ?? zeroRow(oldCode) - const newBase = newRow ?? zeroRow(newCode) - // 新老两个编码都按「自身 + 同款商品编码」合并计算。 - const combined: MonthlyAggregateRow = { - barcode: newCode, - shipAmount: sumDecimalText(newBase.shipAmount, oldBase.shipAmount), - realPerformance: sumDecimalText(newBase.realPerformance, oldBase.realPerformance), - shipQty: (BigInt(newBase.shipQty || '0') + BigInt(oldBase.shipQty || '0')).toString(), - grossProfit: sumDecimalText(newBase.grossProfit, oldBase.grossProfit), - afterSaleAmount: sumDecimalText(newBase.afterSaleAmount, oldBase.afterSaleAmount), - } - merged.set(newCode, combined) - merged.set(oldCode, { ...combined, barcode: oldCode }) + for (const [code, partnerCode] of index.partner) { + const ownRow = original.get(code) + const partnerRow = original.get(partnerCode) + if (!ownRow && !partnerRow) continue + const ownBase = ownRow ?? zeroRow(code) + const partnerBase = partnerRow ?? zeroRow(partnerCode) + // 每个编码都按「自身 + 同款商品编码」合并计算。 + merged.set(code, { + barcode: code, + shipAmount: sumDecimalText(ownBase.shipAmount, partnerBase.shipAmount), + realPerformance: sumDecimalText(ownBase.realPerformance, partnerBase.realPerformance), + shipQty: (BigInt(ownBase.shipQty || '0') + BigInt(partnerBase.shipQty || '0')).toString(), + grossProfit: sumDecimalText(ownBase.grossProfit, partnerBase.grossProfit), + afterSaleAmount: sumDecimalText(ownBase.afterSaleAmount, partnerBase.afterSaleAmount), + }) } return [...merged.values()] } diff --git a/furniture-3d-system/src/server/services/supply-chain-product-sabc.test.ts b/furniture-3d-system/src/server/services/supply-chain-product-sabc.test.ts index 222447f0..5f1eaa95 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-sabc.test.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-sabc.test.ts @@ -548,6 +548,180 @@ describe('supply-chain-product-sabc rules', () => { })) }) + it('matches the direct partner on both sides of a replacement chain', async () => { + ;(prisma.$queryRaw as jest.Mock) + .mockResolvedValueOnce([{ + categoryL1: '住宅家具', + categoryL2: '住宅家具 - 角柜', + categoryGroup: '角柜', + scaleLabel: '爆款', + performanceStart: '100', + performanceEnd: null, + dailySalesStart: '1', + dailySalesEnd: null, + sortOrder: 1, + }]) + replacementRules.mockResolvedValueOnce([ + { sku: 'CHAIN-A', replaceSku: 'CHAIN-B', sourceCreatedAt: '2026-02-08 15:57:27.93', sourceId: 1 }, + { sku: 'CHAIN-B', replaceSku: 'CHAIN-C', sourceCreatedAt: '2026-08-15 14:12:11.413', sourceId: 2 }, + ]) + const archiveRow = (barcode: string) => ({ + id: `archive-${barcode}`, + barcode, + spu: 'SPU-1', + sku: 'SKU-1', + businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'), + categoryL1: '住宅家具', + categoryL2: '住宅家具 - 角柜', + productName: '角柜', + productStatus: '销售', + productAttribute: '成品', + brand: '品牌', + productTag: '', + sabcRating: '', + sabcUpdatedAt: null, + }) + archiveFindMany.mockResolvedValueOnce([archiveRow('CHAIN-A'), archiveRow('CHAIN-B'), archiveRow('CHAIN-C')]) + archiveCount.mockResolvedValueOnce(3) + const aggregateRow = (barcode: string, shipAmount: string, realPerformance: string, shipQty: string, clearSalesCost: string, afterSaleAmount: string) => ({ + barcode, + shipAmount, + salesAmount: shipAmount, + returnAmount: '0', + realPerformance, + shipQty, + returnQty: '0', + clearSalesCost, + sendFreight: '0', + platformCommission: '0', + xyCost: '0', + afterSaleAmount, + pushFeeTotal: '0', + marketingFeeTotal: '0', + advertisementFeeTotal: '0', + sharedWarehousingSum: '0', + otherFee: '0', + operatingFee: '0', + }) + salesAggregates.mockResolvedValueOnce([ + aggregateRow('CHAIN-A', '200', '100', '10', '20', '5'), + aggregateRow('CHAIN-B', '300', '200', '20', '40', '7'), + aggregateRow('CHAIN-C', '400', '300', '30', '60', '9'), + ]) + + const result = await new SupplyChainProductSabcService().listPage({ + year: 2026, + month: 8, + page: 1, + pageSize: 50, + }) + + const rowOf = (code: string) => result.rows.find((row) => row.productCode === code) + // 正向匹配优先:A→B、B→C;C 没有正向规则,用反向规则匹配回 B。字段只展示直接同款编码。 + expect(rowOf('CHAIN-A')).toEqual(expect.objectContaining({ + sameProductCode: 'CHAIN-B', + shipAmount: '500.0000', + realPerformance: '300.0000', + shipQty: '30', + })) + expect(rowOf('CHAIN-B')).toEqual(expect.objectContaining({ + sameProductCode: 'CHAIN-C', + shipAmount: '700.0000', + realPerformance: '500.0000', + shipQty: '50', + })) + expect(rowOf('CHAIN-C')).toEqual(expect.objectContaining({ + sameProductCode: 'CHAIN-B', + shipAmount: '700.0000', + realPerformance: '500.0000', + shipQty: '50', + })) + }) + + it('uses the newest rule when several old codes point to the same replacement code', async () => { + ;(prisma.$queryRaw as jest.Mock) + .mockResolvedValueOnce([{ + categoryL1: '住宅家具', + categoryL2: '住宅家具 - 角柜', + categoryGroup: '角柜', + scaleLabel: '爆款', + performanceStart: '100', + performanceEnd: null, + dailySalesStart: '1', + dailySalesEnd: null, + sortOrder: 1, + }]) + replacementRules.mockResolvedValueOnce([ + { sku: 'OLD-1', replaceSku: 'NEW', sourceCreatedAt: '2026-01-01 09:00:00.000', sourceId: 1 }, + { sku: 'OLD-2', replaceSku: 'NEW', sourceCreatedAt: '2026-08-15 14:12:11.413', sourceId: 2 }, + ]) + const archiveRow = (barcode: string) => ({ + id: `archive-${barcode}`, + barcode, + spu: 'SPU-1', + sku: 'SKU-1', + businessCreatedAt: new Date('2025-01-01T00:00:00.000Z'), + categoryL1: '住宅家具', + categoryL2: '住宅家具 - 角柜', + productName: '角柜', + productStatus: '销售', + productAttribute: '成品', + brand: '品牌', + productTag: '', + sabcRating: '', + sabcUpdatedAt: null, + }) + archiveFindMany.mockResolvedValueOnce([archiveRow('OLD-1'), archiveRow('OLD-2'), archiveRow('NEW')]) + archiveCount.mockResolvedValueOnce(3) + const aggregateRow = (barcode: string, shipAmount: string, realPerformance: string, shipQty: string, clearSalesCost: string, afterSaleAmount: string) => ({ + barcode, + shipAmount, + salesAmount: shipAmount, + returnAmount: '0', + realPerformance, + shipQty, + returnQty: '0', + clearSalesCost, + sendFreight: '0', + platformCommission: '0', + xyCost: '0', + afterSaleAmount, + pushFeeTotal: '0', + marketingFeeTotal: '0', + advertisementFeeTotal: '0', + sharedWarehousingSum: '0', + otherFee: '0', + operatingFee: '0', + }) + salesAggregates.mockResolvedValueOnce([ + aggregateRow('OLD-1', '100', '50', '5', '10', '1'), + aggregateRow('OLD-2', '200', '100', '10', '20', '2'), + aggregateRow('NEW', '300', '200', '20', '40', '3'), + ]) + + const result = await new SupplyChainProductSabcService().listPage({ + year: 2026, + month: 8, + page: 1, + pageSize: 50, + }) + + const rowOf = (code: string) => result.rows.find((row) => row.productCode === code) + expect(rowOf('OLD-1')).toEqual(expect.objectContaining({ + sameProductCode: 'NEW', + realPerformance: '250.0000', + })) + expect(rowOf('OLD-2')).toEqual(expect.objectContaining({ + sameProductCode: 'NEW', + realPerformance: '300.0000', + })) + // 反向匹配取创建时间最新的一条规则 + expect(rowOf('NEW')).toEqual(expect.objectContaining({ + sameProductCode: 'OLD-2', + realPerformance: '300.0000', + })) + }) + it('does not double count when the replacement rule points to the same barcode', async () => { ;(prisma.$queryRaw as jest.Mock) .mockResolvedValueOnce([{