fix(supply-chain): 同款编码改为直接正反向匹配,不再顺延替换链到末端

This commit is contained in:
居居 2026-09-29 17:21:24 +08:00
parent 34abc63edc
commit 39f603c091
2 changed files with 215 additions and 57 deletions

View File

@ -127,62 +127,48 @@ type ReplacementContext = {
}
type ReplacementIndex = {
newToOld: Map<string, string[]>
oldToNew: Map<string, string>
// 同款商品编码(单个):正向「商品编码→可替换商品编码」优先,未匹配到的用反向「可替换商品编码→商品编码」补齐。
partner: Map<string, string>
}
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<string, { newCode: string; at: string; sourceId: number }>()
const forward = new Map<string, { code: string; at: string; sourceId: number }>()
const reverse = new Map<string, { code: string; at: string; sourceId: number }>()
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<string, string>(
[...latestByOld.entries()].map(([oldCode, value]) => [oldCode, value.newCode]),
)
const oldToNew = new Map<string, string>()
for (const [oldCode, newCode] of direct) {
let current = newCode
const seen = new Set<string>([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<string, string>()
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<string, string[]>()
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()]
}

View File

@ -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([{