merge: area-scaled BOM fallback v6
This commit is contained in:
commit
c538e175bc
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user