From 24a5043d1514dd06359ac2f3a9b45388247602e5 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 11 Aug 2026 05:10:02 +0800 Subject: [PATCH] fix(bom): scale fallback costs by board area --- .../bom-costing-scenario.service.test.ts | 62 +++++++++ .../bom-costing-scenario.service.ts | 124 ++++++++++++++++-- 2 files changed, 172 insertions(+), 14 deletions(-) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts index 56e5663d..860b2840 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts @@ -1506,6 +1506,68 @@ describe('BomCostingScenarioService', () => { ])) }) + it('scales a historical whole-board category total by current physical board area', async () => { + const bomLines = [ + line({ + id: 'wide-panel', + category: 'BOARD', + partName: '大板件', + unit: '块', + quantity: 2, + lengthMm: 2_000, + widthMm: 1_000, + }), + line({ + id: 'narrow-panel', + category: 'BOARD', + partName: '小板件', + unit: '块', + quantity: 1, + lengthMm: 2_000, + widthMm: 500, + }), + ] + mockBase({ bomLines }) + jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([]) + jest.spyOn(bomCostingScenarioRepository, 'findCategoryCohortCandidates').mockImplementation( + async (input) => input.category === 'BOARD' + ? [ + candidate({ id: 'area-a', category: 'BOARD', partName: '历史板件', sourceSku: 'A', unitPrice: null, amount: 100, areaSqm: 2.5 }), + candidate({ id: 'area-b', category: 'BOARD', partName: '历史板件', sourceSku: 'B', unitPrice: null, amount: 100, areaSqm: 2.5 }), + candidate({ id: 'area-c', category: 'BOARD', partName: '历史板件', sourceSku: 'C', unitPrice: null, amount: 100, areaSqm: 2.5 }), + ] + : [], + ) + const service = new BomCostingScenarioService() + + await service.calculateSameMaterial({ caseId: 'case-1', skuId: 'sku-1' }) + + const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0] + // Current area is 5m² while the historical median is 2.5m². + expect(saved.sameMaterial.total).toBe(200) + expect(saved.sameMaterial.lines).toEqual(expect.arrayContaining([ + expect.objectContaining({ + sourceLineId: 'wide-panel', + subtotal: 160, + calculationRule: 'reference-profile-category-gap-allocation/v2-area-scaled', + }), + expect.objectContaining({ + sourceLineId: 'narrow-panel', + subtotal: 40, + calculationRule: 'reference-profile-category-gap-allocation/v2-area-scaled', + }), + ])) + expect(saved.sameMaterial.lines[0]?.rawSnapshot).toMatchObject({ + calculation: { + priceSourceAudit: { + historicalMedianPhysicalQuantity: 2.5, + currentPhysicalQuantity: 5, + appliedPhysicalScale: 2, + }, + }, + }) + }) + it('uses the whole historical category estimate when a direct board quote cannot be calculated', async () => { const bomLines = [line({ id: 'oak-board-fact', diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts index dac5a753..5c5734e9 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts @@ -1238,6 +1238,9 @@ type CategoryProfileEstimate = { estimateRange: { min: number; max: number } medianLineCount: number medianLineAmount: number + medianPhysicalQuantity: number | null + physicalSampleCount: number + physicalUnit: 'AREA_SQM' | null lossFactor: number sampleCount: number referenceRowIds: string[] @@ -1379,10 +1382,12 @@ async function applyHistoricalLossFactorFallbacks(input: { function buildCategoryProfileEstimate( candidates: BomCostingScenarioReferenceCandidate[], basis: CategoryProfileEstimate['basis'], + category: string, ): CategoryProfileEstimate | null { const profiles = new Map() for (const candidate of candidates) { @@ -1391,9 +1396,17 @@ function buildCategoryProfileEstimate( const key = candidate.sourceSku && candidate.bomVersionKey ? `${candidate.sourceSku}\u0000${candidate.bomVersionKey}` : candidate.sourceSku || candidate.sourceRowId || candidate.id - const profile = profiles.get(key) ?? { total: 0, lineCount: 0, rows: [] } + const profile = profiles.get(key) ?? { + total: 0, + lineCount: 0, + physicalQuantity: 0, + rows: [], + } profile.total += amount profile.lineCount += 1 + if (category === 'BOARD') { + profile.physicalQuantity += candidateNumber(candidate.areaSqm) ?? 0 + } profile.rows.push(candidate) profiles.set(key, profile) } @@ -1402,6 +1415,9 @@ function buildCategoryProfileEstimate( const totals = samples.map((profile) => profile.total) const lineCounts = samples.map((profile) => profile.lineCount) const lineAmounts = samples.map((profile) => profile.total / Math.max(1, profile.lineCount)) + const physicalQuantities = category === 'BOARD' + ? samples.map((profile) => profile.physicalQuantity).filter((value) => value > 0) + : [] const lossFactors = candidates.flatMap((candidate) => { const value = candidateLossFactor(candidate) return value === null ? [] : [{ value, weight: 1 }] @@ -1409,6 +1425,10 @@ function buildCategoryProfileEstimate( const total = weightedMedian(totals.map((value) => ({ value, weight: 1 }))) const medianLineCount = weightedMedian(lineCounts.map((value) => ({ value, weight: 1 }))) const medianLineAmount = weightedMedian(lineAmounts.map((value) => ({ value, weight: 1 }))) + const enoughPhysicalSamples = physicalQuantities.length >= Math.min(3, samples.length) + const medianPhysicalQuantity = enoughPhysicalSamples + ? weightedMedian(physicalQuantities.map((value) => ({ value, weight: 1 }))) + : null if (total === null || medianLineCount === null || medianLineAmount === null) return null const rows = samples.flatMap((profile) => profile.rows) const first = rows[0]! @@ -1418,6 +1438,11 @@ function buildCategoryProfileEstimate( estimateRange: priceEstimateRange(totals, total, basis), medianLineCount, medianLineAmount, + medianPhysicalQuantity, + physicalSampleCount: physicalQuantities.length, + physicalUnit: category === 'BOARD' && medianPhysicalQuantity !== null + ? 'AREA_SQM' + : null, lossFactor: weightedMedian(lossFactors) ?? 1, sampleCount: samples.length, referenceRowIds: Array.from(new Set(rows.map((row) => row.sourceRowId).filter(Boolean))), @@ -1442,7 +1467,11 @@ async function resolveCategoryProfileEstimate(input: { referenceSkus: input.familyReferenceSkus, limit: 500, }) - const familyEstimate = buildCategoryProfileEstimate(familyCandidates, 'SKU_FAMILY') + const familyEstimate = buildCategoryProfileEstimate( + familyCandidates, + 'SKU_FAMILY', + input.category, + ) if (familyEstimate) return familyEstimate } if (input.similarReferenceSkus.length > 0) { @@ -1452,7 +1481,11 @@ async function resolveCategoryProfileEstimate(input: { referenceSkus: input.similarReferenceSkus, limit: 500, }) - const similarEstimate = buildCategoryProfileEstimate(similarCandidates, 'SIMILAR_HISTORY') + const similarEstimate = buildCategoryProfileEstimate( + similarCandidates, + 'SIMILAR_HISTORY', + input.category, + ) if (similarEstimate) return similarEstimate } const cohortCandidates = await bomCostingScenarioRepository.findCategoryCohortCandidates({ @@ -1460,7 +1493,43 @@ async function resolveCategoryProfileEstimate(input: { category: input.category, limit: 500, }) - return buildCategoryProfileEstimate(cohortCandidates, 'CATEGORY_COHORT') + return buildCategoryProfileEstimate(cohortCandidates, 'CATEGORY_COHORT', input.category) +} + +function currentCategoryPhysicalQuantity( + category: string, + lines: Array<{ + lengthMm: Prisma.Decimal | null + widthMm: Prisma.Decimal | null + quantity: Prisma.Decimal | null + }>, +) { + if (category !== 'BOARD') return null + const areaSqm = lines.reduce((sum, line) => { + const lengthMm = decimalNumber(line.lengthMm) + const widthMm = decimalNumber(line.widthMm) + const quantity = decimalNumber(line.quantity) + if (lengthMm === null || widthMm === null || quantity === null) return sum + if (lengthMm <= 0 || widthMm <= 0 || quantity <= 0) return sum + return sum + (lengthMm * widthMm * quantity) / 1_000_000 + }, 0) + return areaSqm > 0 ? areaSqm : null +} + +function categoryLineAllocationWeight( + category: string, + line: { + lengthMm: Prisma.Decimal | null + widthMm: Prisma.Decimal | null + quantity: Prisma.Decimal | null + }, +) { + if (category === 'BOARD') { + const area = currentCategoryPhysicalQuantity(category, [line]) + if (area !== null) return area + } + const quantity = decimalNumber(line.quantity) + return quantity !== null && quantity > 0 ? quantity : 1 } async function fillCategoryProfileFallbacks(input: { @@ -1471,6 +1540,8 @@ async function fillCategoryProfileFallbacks(input: { partName: string materialName: string quantity: Prisma.Decimal | null + lengthMm: Prisma.Decimal | null + widthMm: Prisma.Decimal | null }> prices: Record previewLines: DynamicBomLineCostResult[] @@ -1495,11 +1566,28 @@ async function fillCategoryProfileFallbacks(input: { }) if (!estimate) continue const categoryLines = input.lines.filter((line) => line.category === category) + const currentPhysicalQuantity = currentCategoryPhysicalQuantity(category, categoryLines) + const rawPhysicalScale = ( + currentPhysicalQuantity !== null && + estimate.medianPhysicalQuantity !== null && + estimate.medianPhysicalQuantity > 0 + ) + ? currentPhysicalQuantity / estimate.medianPhysicalQuantity + : 1 + // Similar BOMs are evidence, not an exact CAD match. Preserve physical + // scaling while limiting incomplete structures or extreme source records + // from multiplying a whole category without bound. + const physicalScale = Math.min(3, Math.max(0.35, rawPhysicalScale)) + const estimatedCategoryTotal = estimate.total * physicalScale + const estimatedCategoryRange = { + min: estimate.estimateRange.min * physicalScale, + max: estimate.estimateRange.max * physicalScale, + } let knownCost = categoryLines.reduce((sum, line) => ( sum + (previewById.get(line.id)?.subtotal ?? 0) ), 0) let linesToAllocate = missingLines - let remainingTotal = estimate.total - knownCost + let remainingTotal = estimatedCategoryTotal - knownCost let replacedExistingCategoryPrices = false if (remainingTotal <= 0.005) { // Semantic image facts are not physical BOM rows. If already-priced facts @@ -1508,18 +1596,17 @@ async function fillCategoryProfileFallbacks(input: { // placeholders or double-counting the historical category amount. linesToAllocate = categoryLines knownCost = 0 - remainingTotal = estimate.total + remainingTotal = estimatedCategoryTotal replacedExistingCategoryPrices = true for (const line of categoryLines) delete input.prices[line.id] } const remainingRange = { - min: Math.max(0, Math.min(remainingTotal, estimate.estimateRange.min - knownCost)), - max: Math.max(remainingTotal, estimate.estimateRange.max - knownCost), + min: Math.max(0, Math.min(remainingTotal, estimatedCategoryRange.min - knownCost)), + max: Math.max(remainingTotal, estimatedCategoryRange.max - knownCost), } - const weights = linesToAllocate.map((line) => { - const quantity = decimalNumber(line.quantity) - return quantity !== null && quantity > 0 ? quantity : 1 - }) + const weights = linesToAllocate.map((line) => ( + categoryLineAllocationWeight(category, line) + )) const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) linesToAllocate.forEach((line, index) => { const share = weights[index]! / totalWeight @@ -1528,7 +1615,7 @@ async function fillCategoryProfileFallbacks(input: { totalAmount: allocated, sourceType: 'LOCAL_MIRROR', sourceId: estimate.sourceId, - sourceLabel: `${estimate.basis === 'SKU_FAMILY' ? '同款 SKU' : estimate.basis === 'SIMILAR_HISTORY' ? '相似产品' : '同分类'}整套历史 BOM 分类总额补齐(${estimate.sampleCount} 套)`, + sourceLabel: `${estimate.basis === 'SKU_FAMILY' ? '同款 SKU' : estimate.basis === 'SIMILAR_HISTORY' ? '相似产品' : '同分类'}整套历史 BOM 分类总额补齐(${estimate.sampleCount} 套${estimate.physicalUnit === 'AREA_SQM' ? ',按当前板件面积缩放' : ''})`, materialName: line.materialName, referenceSku: estimate.referenceSkus[0] ?? '', confidence: estimate.basis === 'SKU_FAMILY' @@ -1543,7 +1630,9 @@ async function fillCategoryProfileFallbacks(input: { sourceFingerprint: estimate.sourceFingerprint, sourceCreatedAt: estimate.sourceCreatedAt, sourceSyncedAt: estimate.sourceSyncedAt, - calculationRule: 'reference-profile-category-gap-allocation/v1', + calculationRule: estimate.physicalUnit === 'AREA_SQM' + ? 'reference-profile-category-gap-allocation/v2-area-scaled' + : 'reference-profile-category-gap-allocation/v1', referenceRowIds: estimate.referenceRowIds, referenceSkus: estimate.referenceSkus, estimateRange: { @@ -1557,6 +1646,13 @@ async function fillCategoryProfileFallbacks(input: { category, historicalMedianCategoryTotal: estimate.total, historicalMedianLineCount: estimate.medianLineCount, + historicalMedianPhysicalQuantity: estimate.medianPhysicalQuantity, + historicalPhysicalSampleCount: estimate.physicalSampleCount, + currentPhysicalQuantity, + physicalUnit: estimate.physicalUnit, + rawPhysicalScale, + appliedPhysicalScale: physicalScale, + scaledCategoryEstimate: estimatedCategoryTotal, currentCategoryLineCount: categoryLines.length, alreadyPricedCategoryCost: knownCost, remainingCategoryEstimate: remainingTotal,