fix(supply-chain): 同款编码改为直接正反向匹配,不再顺延替换链到末端
This commit is contained in:
parent
34abc63edc
commit
39f603c091
@ -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()]
|
||||
}
|
||||
|
||||
@ -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([{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user