merge: area-scaled BOM fallback v6

This commit is contained in:
WuYongJie 2026-08-11 05:10:21 +08:00
commit c538e175bc
2 changed files with 172 additions and 14 deletions

View File

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

View File

@ -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<string, {
total: number
lineCount: number
physicalQuantity: number
rows: BomCostingScenarioReferenceCandidate[]
}>()
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<string, DynamicBomPriceQuote>
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,