fix(bom-costing): restore layered category estimates

This commit is contained in:
WuYongJie 2026-08-13 01:18:19 +08:00
parent de048075ef
commit eb0a6ad499
17 changed files with 757 additions and 88 deletions

View File

@ -210,10 +210,16 @@ export const bomCostingStructureComponentSlotSchema = z.object({
role: bomCostingStructureComponentRoleSchema,
category: bomCostingLineCategorySchema,
partName: z.string().trim().min(1).max(200),
// Unit is owned by the category Skill. Keep a default only for parsing
// historical v1/v2 snapshots that predate the persisted field.
unit: z.string().trim().min(1).max(40).default('件'),
required: z.boolean(),
quantityRule: z.string().trim().min(1).max(1_000),
dimensionRule: z.string().trim().min(1).max(2_000),
materialScope: z.string().trim().max(1_000).default(''),
// Runtime inheritance is denied by default. Only a category Skill may mark
// a physical slot as part of its safe primary-material surface.
primaryMaterialSlot: z.boolean().default(false),
evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]),
confidence: confidenceSchema,
reviewFlags: z.array(z.string().trim().min(1).max(500)).max(20).default([]),

View File

@ -218,11 +218,11 @@ const supportedV3AnalysisResult = {
schemaVersion: 'competitor-analysis/v3' as const,
structureBlueprint: {
schemaVersion: 'structure-blueprint/v2' as const,
knowledgeVersion: 'structure-priors/test-v2',
knowledgeVersion: 'structure-priors/2026-08-11-v2',
supportStatus: 'SUPPORTED' as const,
categorySkill: {
selectedSkillId: 'TABLE',
selectedSkillVersion: 'bom-category-skill/table/test-v1',
selectedSkillVersion: 'bom-category-skill/table/v1',
selectionMode: 'AUTO_SINGLE' as const,
requiredEvidenceCoverage: 0.55,
candidates: [],
@ -234,14 +234,16 @@ const supportedV3AnalysisResult = {
label: '桌台',
skuIds: ['sku-1'],
componentSlots: [{
componentKey: 'line-board',
componentKey: 'surface-main',
role: 'SURFACE' as const,
category: 'BOARD' as const,
partName: '桌面板',
unit: '块',
required: true,
quantityRule: '每件一块',
dimensionRule: '按 SKU 尺寸',
materialScope: '主体材质',
primaryMaterialSlot: true,
evidenceFactIds: [],
confidence: 0.8,
reviewFlags: [],
@ -432,7 +434,7 @@ describe('BomCostingProcessingService', () => {
jest.restoreAllMocks()
})
it('成本阶段中断后复用已保存结构继续处理,并让同一 run 完成', async () => {
it('当前 v6 单位与主体材质合同的结构快照可继续成本恢复,并让同一 run 完成', async () => {
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: 'analysis-run-recovered',
@ -485,6 +487,102 @@ describe('BomCostingProcessingService', () => {
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
})
it('旧 v3 快照缺少 Skill 明确单位时拒绝成本快速恢复并重建结构', async () => {
const legacyV3WithoutSlotUnit = structuredClone(supportedV3AnalysisResult)
delete (
legacyV3WithoutSlotUnit.structureBlueprint.structureGroups[0]!
.componentSlots[0]! as Partial<{ unit: string }>
).unit
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: 'analysis-run-legacy-unit-contract',
caseId: 'case-1',
revision: 2,
mode: BomCostingAnalysisRunMode.REUSE_FACTS,
status: BomCostingAnalysisRunStatus.PROCESSING,
factAnalysisId: 'fact-analysis-1',
selectedAssetIds: [],
instruction: '',
resultSnapshot: legacyV3WithoutSlotUnit,
} as never)
jest.spyOn(bomCostingRepository, 'findCaseForProcessing')
.mockResolvedValue({
...caseRecord(),
progress: 97,
statusMessage: '已恢复后台任务,继续生成分层成本',
skus: [{ id: 'sku-db-1', externalSkuId: 'sku-1' }],
} as never)
mockRunAnalysis.mockRejectedValueOnce(
new Error('停止在旧结构快照重建后的测试边界'),
)
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingScenarioService.calculateSameMaterialForCase)
.not.toHaveBeenCalled()
expect(bomCostingRepository.updateCaseStage).not.toHaveBeenCalledWith(
'case-1',
expect.objectContaining({ eventType: 'DYNAMIC_COST_RECOVERY_RESUMED' }),
)
expect(mockRunAnalysis).toHaveBeenCalledWith(
expect.objectContaining({
structureCheckpoint: null,
synthesisCheckpoint: null,
}),
)
})
it('旧 v3 快照缺少 Skill 主体材质槽声明时拒绝成本快速恢复并重建结构', async () => {
const legacyV3WithoutPrimaryMaterialContract = structuredClone(supportedV3AnalysisResult)
delete (
legacyV3WithoutPrimaryMaterialContract.structureBlueprint.structureGroups[0]!
.componentSlots[0]! as Partial<{ primaryMaterialSlot: boolean }>
).primaryMaterialSlot
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: 'analysis-run-legacy-primary-material-contract',
caseId: 'case-1',
revision: 2,
mode: BomCostingAnalysisRunMode.REUSE_FACTS,
status: BomCostingAnalysisRunStatus.PROCESSING,
factAnalysisId: 'fact-analysis-1',
selectedAssetIds: [],
instruction: '',
resultSnapshot: legacyV3WithoutPrimaryMaterialContract,
} as never)
jest.spyOn(bomCostingRepository, 'findCaseForProcessing')
.mockResolvedValue({
...caseRecord(),
progress: 97,
statusMessage: '已恢复后台任务,继续生成分层成本',
skus: [{ id: 'sku-db-1', externalSkuId: 'sku-1' }],
} as never)
mockRunAnalysis.mockRejectedValueOnce(
new Error('停止在旧主体材质槽合同重建后的测试边界'),
)
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingScenarioService.calculateSameMaterialForCase)
.not.toHaveBeenCalled()
expect(bomCostingRepository.updateCaseStage).not.toHaveBeenCalledWith(
'case-1',
expect.objectContaining({ eventType: 'DYNAMIC_COST_RECOVERY_RESUMED' }),
)
expect(mockRunAnalysis).toHaveBeenCalledWith(
expect.objectContaining({
structureCheckpoint: null,
synthesisCheckpoint: null,
}),
)
})
it('成本阶段恢复也不能绕过当前图片 fingerprint 门禁', async () => {
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({

View File

@ -41,6 +41,8 @@ import { prepareBomCostingAnalysis } from './bom-costing-result-preparer'
import { assertBomCostingResultQuality } from './bom-costing-result-quality'
import { bomCostingScenarioService } from './bom-costing-scenario.service'
import { getCompetitorAnalysisProvider } from './bom-costing-provider'
import { getBomCostingCategorySkill } from './category-skills'
import { BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION } from './bom-costing-structure-knowledge'
import {
readBomCostingStructureCheckpoint,
readBomCostingSynthesisCheckpoint,
@ -205,6 +207,75 @@ function buildResultStats(
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Dynamic-cost recovery intentionally inspects the raw persisted JSON instead
* of the compatibility-parsed result. Historical v3 snapshots predate the
* Skill-owned slot unit and Zod fills their missing unit with "件" so they can
* still be displayed. That compatibility default must never authorize a new
* v7 cost calculation.
*/
function hasCurrentStructureUnitContract(value: unknown) {
if (!isRecord(value) || value.schemaVersion !== 'competitor-analysis/v3') {
return false
}
const blueprint = value.structureBlueprint
if (
!isRecord(blueprint) ||
blueprint.schemaVersion !== 'structure-blueprint/v2' ||
blueprint.knowledgeVersion !== BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION ||
blueprint.supportStatus !== 'SUPPORTED'
) {
return false
}
const selection = blueprint.categorySkill
if (!isRecord(selection)) return false
const selectedSkillId = selection.selectedSkillId
const selectedSkillVersion = selection.selectedSkillVersion
if (
typeof selectedSkillId !== 'string' ||
typeof selectedSkillVersion !== 'string'
) {
return false
}
const skill = getBomCostingCategorySkill(selectedSkillId)
if (!skill || skill.version !== selectedSkillVersion) return false
const contractByComponentKey = new Map(
[...skill.requiredComponents, ...skill.optionalComponents]
.map((component) => [component.componentKey, {
unit: component.unit,
primaryMaterialSlot: component.primaryMaterialSlot === true,
}] as const),
)
if (!Array.isArray(blueprint.structureGroups) || blueprint.structureGroups.length === 0) {
return false
}
return blueprint.structureGroups.every((group) => {
if (!isRecord(group) || !Array.isArray(group.componentSlots)) return false
return group.componentSlots.length > 0 && group.componentSlots.every((slot) => {
if (!isRecord(slot)) return false
const componentKey = slot.componentKey
const unit = slot.unit
const primaryMaterialSlot = slot.primaryMaterialSlot
const componentContract = typeof componentKey === 'string'
? contractByComponentKey.get(componentKey)
: undefined
return (
typeof componentKey === 'string' &&
typeof unit === 'string' &&
unit.trim().length > 0 &&
typeof primaryMaterialSlot === 'boolean' &&
unit === componentContract?.unit &&
primaryMaterialSlot === componentContract.primaryMaterialSlot
)
})
})
}
async function completeDynamicCostingPhase(input: {
caseId: string
analysisRunId: string
@ -622,6 +693,7 @@ export class BomCostingProcessingService {
const savedSkuIds = record.skus.map((sku) => sku.externalSkuId).sort()
const canResumeDynamicCosting =
record.progress >= 97 &&
hasCurrentStructureUnitContract(analysisRun.resultSnapshot) &&
persistedAnalysis.success &&
persistedAnalysis.data.schemaVersion === 'competitor-analysis/v3' &&
persistedAnalysis.data.structureBlueprint.supportStatus === 'SUPPORTED' &&

View File

@ -276,7 +276,7 @@ async function runStructurePipeline(orderedFixture: readonly ProductionWardrobe2
}
const checkpoint = readBomCostingStructureCheckpoint({
kind: 'BOM_COSTING_STRUCTURE_CHECKPOINT',
version: 4,
version: 6,
sourceSkuIds: snapshot.skus.map((sku) => sku.externalSkuId),
blueprint,
completedGroupKeys: [],
@ -442,6 +442,33 @@ describe('生产形态 27 SKU 衣柜全链回归', () => {
expect(run.prepared.every((sku) => sku.estimatedCost === null)).toBe(true)
assertBlueprintCoverage(run.blueprint)
assertKnownDimensionSubsetQuality(run)
const slotsByGroup = new Map(run.blueprint.structureGroups.map((group) => [
group.structureGroupKey,
new Map(group.componentSlots.map((slot) => [slot.componentKey, slot] as const)),
] as const))
for (const sku of run.output.result.skus) {
const groupSlots = slotsByGroup.get(sku.structureGroupKey!)!
const linesById = new Map(sku.bomLines.map((line) => [line.externalLineId, line] as const))
for (const line of sku.bomLines) {
expect(line.unit).toBe(groupSlots.get(line.externalLineId)?.unit)
}
for (const mainComponentKey of [
'enclosure-side',
'enclosure-top',
'enclosure-bottom',
'partition-vertical',
'partition-horizontal',
'cover-door',
'top-cabinet-side',
'top-cabinet-horizontal',
]) {
const line = linesById.get(mainComponentKey)
if (line) expect(line.materialName).toBe('白蜡木')
}
expect(linesById.get('back-panel')?.materialName).toBeUndefined()
expect(linesById.get('drawer-box')?.materialName).toBeUndefined()
}
}
expect(fetchSpy).toHaveBeenCalledTimes(81)

View File

@ -2050,29 +2050,54 @@ describe('BomCostingScenarioService', () => {
})
})
it('板材存在应计但缺面尺寸的行时不按残余面积缩放成完整成本', async () => {
it('部分板材缺面尺寸时不按部分面积缩放整套总额,只用未缩放正残差补缺价行', async () => {
const bomLines = [
line({
id: 'complete-panel',
category: 'BOARD',
partName: '已知尺寸板件',
materialName: '橡胶木',
unit: '块',
quantity: 1,
lengthMm: 2_000,
widthMm: 500,
}),
line({
id: 'complete-unpriced-panel',
category: 'BOARD',
partName: '有尺寸缺价板件',
materialName: '橡胶木',
unit: '块',
quantity: 3,
lengthMm: 1_000,
widthMm: 500,
}),
line({
id: 'missing-depth-panel',
category: 'BOARD',
partName: '缺深度板件',
materialName: '橡胶木',
unit: '块',
quantity: 2,
quantity: 1,
lengthMm: 2_000,
widthMm: null,
}),
]
mockBase({ bomLines })
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockImplementation(
async (input) => input.partName === '已知尺寸板件'
? [candidate({
id: 'priced-complete-panel',
category: 'BOARD',
partName: '已知尺寸板件',
materialName: '橡胶木',
unit: '块',
unitPrice: 40,
lengthMm: 2_000,
widthMm: 500,
})]
: [],
)
jest.spyOn(bomCostingScenarioRepository, 'findCategoryCohortCandidates').mockImplementation(
async (input) => input.category === 'BOARD'
? [
@ -2092,24 +2117,103 @@ describe('BomCostingScenarioService', () => {
bomCostingScenarioRepository.saveSameMaterialScenario,
).mock.calls[0]![0]
expect(saved.sameMaterial).toMatchObject({
status: 'INSUFFICIENT_DATA',
total: null,
knownCost: 0,
unresolvedCount: 2,
status: 'PROVISIONAL',
total: 100,
knownCost: 100,
unresolvedCount: 0,
warning: expect.stringContaining('部分板材面尺寸缺失'),
})
const byLine = new Map(saved.sameMaterial.lines.map((item) => [item.sourceLineId, item]))
expect(byLine.get('complete-panel')).toMatchObject({
subtotal: 40,
unitPrice: 40,
})
expect(byLine.get('complete-unpriced-panel')).toMatchObject({
subtotal: 45,
status: 'PROVISIONAL',
confidence: 0.38,
calculationRule: 'reference-profile-category-gap-allocation/v3-partial-unscaled',
rawSnapshot: {
calculation: {
priceSourceAudit: expect.objectContaining({
allocatedShare: 0.75,
}),
},
},
})
expect(byLine.get('missing-depth-panel')).toMatchObject({
subtotal: 15,
status: 'PROVISIONAL',
confidence: 0.38,
calculationRule: 'reference-profile-category-gap-allocation/v3-partial-unscaled',
warning: expect.stringContaining('部分板材面尺寸缺失'),
rawSnapshot: {
calculation: {
priceSourceAudit: expect.objectContaining({
historicalMedianCategoryTotal: 100,
currentPhysicalQuantity: 2.5,
areaScaleApplied: false,
unscaledBoardFallbackReason: 'PARTIAL_FACE_DIMENSIONS',
partialPhysicalQuantityExcludedFromScale: true,
appliedPhysicalScale: 1,
alreadyPricedCategoryCost: 40,
remainingCategoryEstimate: 60,
existingPricesPreserved: true,
allocatedShare: 0.25,
}),
},
},
})
expect(saved.sameMaterial.lines).toEqual(expect.arrayContaining([
expect.objectContaining({ sourceLineId: 'complete-panel', subtotal: null }),
expect.objectContaining({ sourceLineId: 'missing-depth-panel', subtotal: null }),
]))
expect(saved.sameMaterial.lines).not.toEqual(expect.arrayContaining([
expect.objectContaining({
calculationRule: 'reference-profile-category-gap-allocation/v2-area-scaled',
}),
]))
expect(
jest.mocked(bomCostingScenarioRepository.findCategoryCohortCandidates)
.mock.calls.filter(([input]) => input.category === 'BOARD'),
).toHaveLength(0)
).not.toHaveLength(0)
})
it('部分板材缺面尺寸且没有完整历史 BOM 时不退回逐行宽泛估价', async () => {
const bomLines = [
line({
id: 'complete-panel',
category: 'BOARD',
partName: '已知尺寸板件',
materialName: '橡胶木',
unit: '块',
quantity: 1,
lengthMm: 2_000,
widthMm: 500,
}),
line({
id: 'missing-depth-panel',
category: 'BOARD',
partName: '缺深度板件',
materialName: '橡胶木',
unit: '块',
quantity: 1,
lengthMm: 2_000,
widthMm: null,
}),
]
mockBase({ bomLines })
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
jest.spyOn(bomCostingScenarioRepository, 'findCategoryCohortCandidates').mockResolvedValue([])
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
})
const saved = jest.mocked(
bomCostingScenarioRepository.saveSameMaterialScenario,
).mock.calls[0]![0]
expect(saved.sameMaterial).toMatchObject({
status: 'INSUFFICIENT_DATA',
total: null,
unresolvedCount: 2,
})
expect(
jest.mocked(bomCostingScenarioRepository.findCategoryCohortCandidates)
.mock.calls.filter(([input]) => input.category === 'BOARD'),
).toHaveLength(1)
})
it('板材面尺寸全部缺失时使用低可信的整套历史未缩放预估', async () => {

View File

@ -1760,29 +1760,29 @@ async function fillCategoryProfileFallbacks(input: {
const boardCoverage = category === 'BOARD'
? boardFaceDimensionCoverage(categoryLines)
: null
if (
category === 'BOARD' &&
const partialBoardDimensionFallback = category === 'BOARD' &&
boardCoverage !== null &&
boardCoverage.completeFaceCount > 0 &&
boardCoverage.incompleteFaceCount > 0
) {
// A partial board-area sum is not the physical quantity of the whole
// product. Do not scale a complete historical BOARD total by only the
// surviving dimensions, and prevent the later per-line cohort fallback
// from turning those unknown cuts into a falsely complete scenario.
for (const line of missingLines) handledLineIds.add(line.id)
continue
}
const estimate = await resolveCategoryProfileEstimate({
syncRunId: input.syncRunId,
category,
familyReferenceSkus: input.familyReferenceSkus,
similarReferenceSkus: input.similarReferenceSkus,
})
if (!estimate) continue
if (!estimate) {
// Partial BOARD dimensions may only be completed from a whole-BOM
// category profile. A later per-line cohort quote would bypass that
// safety boundary and make an incomplete structure appear complete.
if (partialBoardDimensionFallback) {
for (const line of missingLines) handledLineIds.add(line.id)
}
continue
}
for (const line of missingLines) handledLineIds.add(line.id)
const currentPhysicalQuantity = currentCategoryPhysicalQuantity(category, categoryLines)
const areaScaleApplied = (
!partialBoardDimensionFallback &&
currentPhysicalQuantity !== null &&
estimate.medianPhysicalQuantity !== null &&
estimate.medianPhysicalQuantity > 0
@ -1794,16 +1794,24 @@ async function fillCategoryProfileFallbacks(input: {
// 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 unscaledBoardFallback = category === 'BOARD' && (
const allBoardDimensionsMissingFallback = category === 'BOARD' && (
boardCoverage?.billableCount ?? 0
) > 0 && boardCoverage?.completeFaceCount === 0
if (unscaledBoardFallback) {
const unscaledBoardFallback = partialBoardDimensionFallback || allBoardDimensionsMissingFallback
if (allBoardDimensionsMissingFallback) {
warnings.push({
category,
lineIds: missingLines.map((line) => line.id),
warning: '全部板材面尺寸缺失,当前按相似整套历史 BOM 中位数进行未缩放预估;该结果可信度较低,需补充板件尺寸后复核',
})
}
if (partialBoardDimensionFallback) {
warnings.push({
category,
lineIds: missingLines.map((line) => line.id),
warning: '部分板材面尺寸缺失,未使用已知板件的部分面积缩放整套历史 BOM;当前按相似整套历史 BOM 分类中位数进行未缩放预估,并仅将高于已定价成本的正残差分配给缺价行,需补充缺失尺寸后复核',
})
}
const estimatedCategoryTotal = estimate.total * physicalScale
const estimatedCategoryRange = {
min: estimate.estimateRange.min * physicalScale,
@ -1840,9 +1848,16 @@ async function fillCategoryProfileFallbacks(input: {
min: Math.max(0, Math.min(remainingTotal, estimatedCategoryRange.min - knownCost)),
max: Math.max(remainingTotal, estimatedCategoryRange.max - knownCost),
}
const weights = missingLines.map((line) => (
categoryLineAllocationWeight(category, line)
))
const weights = missingLines.map((line) => {
if (partialBoardDimensionFallback) {
// Some missing rows have an area and some do not. Mixing square metres
// and piece counts in one allocation denominator would distort the
// category residual, so this low-confidence path uses pieces only.
const quantity = decimalNumber(line.quantity)
return quantity !== null && quantity > 0 ? quantity : 1
}
return categoryLineAllocationWeight(category, line)
})
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0)
missingLines.forEach((line, index) => {
const share = weights[index]! / totalWeight
@ -1851,7 +1866,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} 套${areaScaleApplied ? ',按当前板件面积缩放' : unscaledBoardFallback ? ',当前板件尺寸全缺、未缩放' : ''})`,
sourceLabel: `${estimate.basis === 'SKU_FAMILY' ? '同款 SKU' : estimate.basis === 'SIMILAR_HISTORY' ? '相似产品' : '同分类'}整套历史 BOM 分类总额补齐(${estimate.sampleCount} 套${areaScaleApplied ? ',按当前板件面积缩放' : partialBoardDimensionFallback ? ',部分板件尺寸缺失、未缩放' : allBoardDimensionsMissingFallback ? ',当前板件尺寸全缺、未缩放' : ''})`,
materialName: line.materialName,
referenceSku: estimate.referenceSkus[0] ?? '',
confidence: unscaledBoardFallback
@ -1870,7 +1885,9 @@ async function fillCategoryProfileFallbacks(input: {
sourceSyncedAt: estimate.sourceSyncedAt,
calculationRule: areaScaleApplied
? 'reference-profile-category-gap-allocation/v2-area-scaled'
: unscaledBoardFallback
: partialBoardDimensionFallback
? 'reference-profile-category-gap-allocation/v3-partial-unscaled'
: allBoardDimensionsMissingFallback
? 'reference-profile-category-gap-allocation/v2-unscaled'
: 'reference-profile-category-gap-allocation/v1',
referenceRowIds: estimate.referenceRowIds,
@ -1897,6 +1914,12 @@ async function fillCategoryProfileFallbacks(input: {
currentPhysicalQuantity,
physicalUnit: estimate.physicalUnit,
areaScaleApplied,
unscaledBoardFallbackReason: partialBoardDimensionFallback
? 'PARTIAL_FACE_DIMENSIONS'
: allBoardDimensionsMissingFallback
? 'ALL_FACE_DIMENSIONS_MISSING'
: null,
partialPhysicalQuantityExcludedFromScale: partialBoardDimensionFallback,
boardFaceDimensionCoverage: boardCoverage,
rawPhysicalScale,
appliedPhysicalScale: physicalScale,

View File

@ -195,7 +195,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
},
)
it('当前 v4/v2 检查点可恢复,旧分组策略 v3 检查点失效', async () => {
it('当前 v6/v2 检查点可恢复,旧主体材质槽合同 v5 检查点失效', async () => {
const snapshot = snapshotWithSkus(1)
const factSnapshot = {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
@ -218,7 +218,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
const group = blueprint.structureGroups[0]!
const checkpointValue = {
kind: 'BOM_COSTING_STRUCTURE_CHECKPOINT',
version: 4,
version: 6,
sourceSkuIds: ['sku-1'],
blueprint,
completedGroupKeys: [group.structureGroupKey],
@ -242,7 +242,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
}
expect(readBomCostingStructureCheckpoint({
...checkpointValue,
version: 3,
version: 5,
})).toBeNull()
const checkpoint = readBomCostingStructureCheckpoint(checkpointValue)
expect(checkpoint).not.toBeNull()
@ -289,7 +289,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
)
const checkpoint = readBomCostingStructureCheckpoint({
kind: 'BOM_COSTING_STRUCTURE_CHECKPOINT',
version: 4,
version: 6,
sourceSkuIds: snapshot.skus.map((sku) => sku.externalSkuId),
blueprint,
completedGroupKeys: blueprint.structureGroups.map((group) => group.structureGroupKey),
@ -634,7 +634,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
expect(boardArea(1)).toBeGreaterThan(boardArea(0))
})
it('同一 SKU 的主体白蜡木和松木背板仅绑定各自有证据的槽位', async () => {
it('同一 SKU 的主体白蜡木可安全继承,松木背板仍优先使用槽位专属事实', async () => {
const snapshot = snapshotWithSkus(1)
snapshot.title = '白蜡木主体搭配松木背板实木衣柜'
snapshot.skus[0]!.name = '白蜡木主体+松木辅材推拉门衣柜'
@ -706,7 +706,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
const byId = new Map(output.result.skus[0]!.bomLines.map((line) => [line.externalLineId, line]))
expect(byId.get('enclosure-side')?.materialName).toBe('白蜡木')
expect(byId.get('back-panel')?.materialName).toBe('松木')
expect(byId.get('enclosure-top')?.materialName).toBeUndefined()
expect(byId.get('enclosure-top')?.materialName).toBe('白蜡木')
expect(byId.get('processing-main')?.materialName).toBeUndefined()
expect(byId.get('processing-main')?.processName).toBeUndefined()
expect(byId.get('labor-assembly')?.materialName).toBeUndefined()
@ -776,6 +776,250 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
.every((line) => line.materialName === undefined)).toBe(true)
})
it('主体槽位自身包含多材质冲突时不使用成本组主体材质覆盖', async () => {
const snapshot = snapshotWithSkus(1)
snapshot.title = '白蜡木实木衣柜'
snapshot.skus[0]!.name = '白蜡木推拉门衣柜'
const factSnapshot = {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: 'conflicting-side-material',
sourceImageUrl: 'https://img.example.com/conflicting-side-material.jpg',
kind: 'DETAIL' as const,
label: '侧板材质说明',
externalSkuIds: ['sku-1'],
observedTexts: [],
facts: [{
factId: 'fact-conflicting-side-material',
kind: 'MATERIAL' as const,
subject: '柜体侧板',
value: '白蜡木与松木拼接',
relatedSkuIds: ['sku-1'],
basis: 'SOURCE_TEXT' as const,
confidence: 0.95,
}],
}],
failedImages: [],
extractedAt: new Date().toISOString(),
}
jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => {
const body = JSON.parse(String(init?.body)) as {
messages: Array<{ content: string | Array<{ type: string; text?: string }> }>
}
const content = body.messages[1]?.content ?? '{}'
const text = typeof content === 'string'
? content
: content.find((entry) => entry.type === 'text')?.text ?? '{}'
const payload = JSON.parse(text) as {
sku?: Array<[string, string, string, Record<string, unknown>]>
slots?: Array<[string, string, string]>
}
return streamingResponse(JSON.stringify({
skus: (payload.sku ?? []).map((sku) => ({
sku: sku[0],
d: [1_400, 620, 2_200],
lines: (payload.slots ?? []).map((slot) => ([
slot[0], 1, slot[2] === 'BOARD' ? '块' : '套',
null, null, null, null, [],
])),
})),
}))
})
const output = await buildStructuredBomCostingAnalysis({
config,
sourceUrl: snapshot.detailUrl,
snapshot,
factSnapshot,
requestedCategorySkillId: 'WARDROBE',
})
expect(output.result.skus[0]?.bomLines.find(
(line) => line.externalLineId === 'enclosure-side',
)?.materialName).toBeUndefined()
})
it('生产形态的唯一主体材质可安全继承,且模型 mm 单位不能覆盖 Skill 计数单位', async () => {
const snapshot = snapshotWithSkus(1)
snapshot.title = '现代简约白蜡木推拉门实木衣柜'
snapshot.skus[0]!.name = '1.4米宽 白蜡木柜体 推拉门衣柜'
snapshot.skus[0]!.attributes = { 尺寸: '140cm', 材质: '白蜡木' }
const factSnapshot = {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: 'generic-primary-material',
sourceImageUrl: 'https://img.example.com/generic-primary-material.jpg',
kind: 'DETAIL' as const,
label: '主体材质说明',
externalSkuIds: ['sku-1'],
observedTexts: [],
facts: [{
factId: 'fact-primary-white-ash',
kind: 'MATERIAL' as const,
subject: '柜体主体材质',
value: '白蜡木',
relatedSkuIds: ['sku-1'],
basis: 'SOURCE_TEXT' as const,
confidence: 0.98,
}],
}],
failedImages: [],
extractedAt: new Date().toISOString(),
}
jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => {
const body = JSON.parse(String(init?.body)) as {
messages: Array<{ content: string | Array<{ type: string; text?: string }> }>
}
const content = body.messages[1]?.content ?? '{}'
const text = typeof content === 'string'
? content
: content.find((entry) => entry.type === 'text')?.text ?? '{}'
const payload = JSON.parse(text) as {
sku?: Array<[string, string, string, Record<string, unknown>]>
slots?: Array<[string, string, string]>
}
return streamingResponse(JSON.stringify({
skus: (payload.sku ?? []).map((sku) => ({
sku: sku[0],
d: [1_400, 620, 2_200],
lines: (payload.slots ?? []).map((slot) => ([
slot[0], 1, 'mm', null, null, null,
slot[2] === 'BOARD' ? '白蜡木' : null,
slot[2] === 'BOARD' ? ['fact-primary-white-ash'] : [],
])),
})),
}))
})
const output = await buildStructuredBomCostingAnalysis({
config,
sourceUrl: snapshot.detailUrl,
snapshot,
factSnapshot,
requestedCategorySkillId: 'WARDROBE',
})
const skuResult = output.result.skus[0]!
const byId = new Map(skuResult.bomLines.map((line) => [line.externalLineId, line]))
for (const componentKey of [
'enclosure-side',
'enclosure-top',
'enclosure-bottom',
'partition-vertical',
'partition-horizontal',
'cover-door',
]) {
expect(byId.get(componentKey)).toMatchObject({ materialName: '白蜡木', unit: '块' })
}
expect(byId.get('back-panel')).toMatchObject({ unit: '块' })
expect(byId.get('back-panel')?.materialName).toBeUndefined()
expect(byId.get('processing-main')?.unit).toBe('套')
expect(skuResult.bomLines.some((line) => line.unit === 'mm')).toBe(false)
})
it.each([
{
skillId: 'TABLE',
title: '白蜡木桌面碳钢桌腿餐桌',
supportSubject: '碳钢桌腿',
primaryKey: 'surface-main',
supportKey: 'support-leg',
isolatedKey: 'support-apron',
},
{
skillId: 'HARD_SEATING',
title: '白蜡木座面碳钢椅腿餐椅',
supportSubject: '碳钢椅腿',
primaryKey: 'surface-seat',
supportKey: 'support-seat',
isolatedKey: 'support-seat-brace',
},
{
skillId: 'STORAGE_RACK',
title: '白蜡木层板碳钢立柱置物架',
supportSubject: '碳钢立柱',
primaryKey: 'partition-shelf',
supportKey: 'support-upright',
isolatedKey: 'support-cross-brace',
},
])(
'$skillId 混合材质仅向 Skill 明确声明的主体槽继承',
async ({ skillId, title, supportSubject, primaryKey, supportKey, isolatedKey }) => {
const snapshot = snapshotWithSkus(1)
snapshot.itemId = `mixed-${skillId.toLocaleLowerCase()}`
snapshot.title = title
snapshot.skus[0]!.name = title
snapshot.skus[0]!.attributes = { 材质: '白蜡木+碳钢' }
const factSnapshot = {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: `mixed-material-${skillId}`,
sourceImageUrl: `https://img.example.com/mixed-material-${skillId}.jpg`,
kind: 'DETAIL' as const,
label: '混合材质说明',
externalSkuIds: ['sku-1'],
observedTexts: [],
facts: [{
factId: `fact-primary-${skillId}`,
kind: 'MATERIAL' as const,
subject: '产品主体材质',
value: '白蜡木',
relatedSkuIds: ['sku-1'],
basis: 'SOURCE_TEXT' as const,
confidence: 0.98,
}, {
factId: `fact-support-${skillId}`,
kind: 'MATERIAL' as const,
subject: supportSubject,
value: '碳钢',
relatedSkuIds: ['sku-1'],
basis: 'SOURCE_TEXT' as const,
confidence: 0.98,
}],
}],
failedImages: [],
extractedAt: new Date().toISOString(),
}
jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => {
const body = JSON.parse(String(init?.body)) as {
messages: Array<{ content: string | Array<{ type: string; text?: string }> }>
}
const content = body.messages[1]?.content ?? '{}'
const text = typeof content === 'string'
? content
: content.find((entry) => entry.type === 'text')?.text ?? '{}'
const payload = JSON.parse(text) as {
sku?: Array<[string, string, string, Record<string, unknown>]>
slots?: Array<[string, string, string]>
}
return streamingResponse(JSON.stringify({
skus: (payload.sku ?? []).map((sku) => ({
sku: sku[0],
d: [1_200, 600, 750],
lines: (payload.slots ?? []).map((slot) => ([
slot[0], 1, '件', null, null, null, null, [],
])),
})),
}))
})
const output = await buildStructuredBomCostingAnalysis({
config,
sourceUrl: snapshot.detailUrl,
snapshot,
factSnapshot,
requestedCategorySkillId: skillId,
})
const group = output.result.structureBlueprint.structureGroups[0]!
const slotByKey = new Map(group.componentSlots.map((slot) => [slot.componentKey, slot]))
expect(slotByKey.get(primaryKey)?.primaryMaterialSlot).toBe(true)
expect(slotByKey.get(supportKey)?.primaryMaterialSlot).toBe(false)
expect(slotByKey.get(isolatedKey)?.primaryMaterialSlot).toBe(false)
const byId = new Map(output.result.skus[0]!.bomLines.map((line) => [line.externalLineId, line]))
expect(byId.get(primaryKey)?.materialName).toBe('白蜡木')
expect(byId.get(supportKey)?.materialName).toBe('碳钢')
expect(byId.get(isolatedKey)?.materialName).toBeUndefined()
},
)
it('没有当前槽位 MATERIAL 事实时丢弃模型猜测的板材材质', async () => {
const snapshot = snapshotWithSkus(1)
jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => {

View File

@ -140,9 +140,10 @@ const instantiationPayloadSchema = z.object({
const BOM_COSTING_STRUCTURE_CHECKPOINT_KIND =
'BOM_COSTING_STRUCTURE_CHECKPOINT' as const
// Version 4 invalidates blueprints created before SKU-local category grouping.
// Those checkpoints can contain product-wide variant signals on every SKU.
const BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION = 4 as const
// Version 6 invalidates checkpoints created before the explicit
// primaryMaterialSlot contract. Parsing a v5 checkpoint with the safe default
// would otherwise suppress legitimate primary-material inheritance.
const BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION = 6 as const
const bomCostingStructureCheckpointSchema = z.object({
kind: z.literal(BOM_COSTING_STRUCTURE_CHECKPOINT_KIND),
@ -825,42 +826,112 @@ function materialFamilyFromDescriptor(descriptor: string) {
return families.length === 1 ? families[0] : undefined
}
function selectSingleMaterialDescriptor(descriptors: readonly string[]) {
const byFamily = new Map<string, string>()
for (const descriptor of descriptors) {
const family = materialFamilyFromDescriptor(descriptor)
if (!family) continue
const existing = byFamily.get(family)
if (!existing || descriptor.length > existing.length) {
byFamily.set(family, descriptor)
}
}
// “实木” is only a broad class. A single named timber plus “实木” is
// not a conflict, while two named timbers must never be spread to slots.
if (byFamily.size > 1 && byFamily.has('SOLID_WOOD')) {
byFamily.delete('SOLID_WOOD')
}
return byFamily.size === 1 ? Array.from(byFamily.values())[0] : undefined
}
function canInheritPrimaryMaterial(
slot: BomCostingStructureBlueprintV2['structureGroups'][number]['componentSlots'][number],
) {
return slot.primaryMaterialSlot === true
}
function hasUnresolvedPrimaryMaterialConflict(input: {
primaryDescriptor: string
materialRecords: ReturnType<typeof listBomCostingFactRecords>
slots: BomCostingStructureBlueprintV2['structureGroups'][number]['componentSlots']
}) {
const primaryFamily = materialFamilyFromDescriptor(input.primaryDescriptor)
if (!primaryFamily) return true
return input.materialRecords.some((record) => {
const context = `${record.fact.subject} ${record.fact.value} ${record.fact.note ?? ''}`
const families: string[] = Array.from(new Set(materialDescriptorsFromText(context)
.flatMap((descriptor) => materialFamilyFromDescriptor(descriptor) ?? [])
.filter((family) => family !== 'SOLID_WOOD')))
const otherFamilies = families.filter((family) => family !== primaryFamily)
if (otherFamilies.length === 0) return false
// A single broad fact containing multiple named materials is ambiguous.
if (families.includes(primaryFamily)) return true
// A different material explicitly bound by the blueprint to one or more
// non-primary slots (for example a steel table leg or pine back panel) is
// not a conflict for the Skill's primary-material slots. The decision is
// based on the Skill-owned slot contract, never on a part-name regex.
const boundSlots = input.slots.filter((slot) => slot.evidenceFactIds.includes(record.factId))
return boundSlots.length === 0 || boundSlots.some((slot) => slot.primaryMaterialSlot)
})
}
function resolvePhysicalMaterialDescriptor(input: {
slot: BomCostingStructureBlueprintV2['structureGroups'][number]['componentSlots'][number]
sku: MarketSkuSnapshot
factSnapshot: BomCostingSourceFactSnapshot
modelLine?: z.infer<typeof compactInstantiatedBomLineSchema>
primaryMaterialDescriptor?: string
groupSlots: BomCostingStructureBlueprintV2['structureGroups'][number]['componentSlots']
}) {
if (!['BOARD', 'STEEL', 'ACCESSORY'].includes(input.slot.category)) return undefined
const allowedIds = new Set(input.slot.evidenceFactIds)
if (allowedIds.size === 0) return undefined
const records = listBomCostingFactRecords(input.factSnapshot).filter((record) => (
allowedIds.has(record.factId) &&
record.fact.kind === 'MATERIAL' &&
factAppliesToSku(record, input.sku.externalSkuId)
const allMaterialRecords = listBomCostingFactRecords(input.factSnapshot).filter((record) => (
record.fact.kind === 'MATERIAL' && factAppliesToSku(record, input.sku.externalSkuId)
))
const slotDescriptors = Array.from(new Set(records.flatMap((record) => (
const records = allMaterialRecords.filter((record) => allowedIds.has(record.factId))
const rawSlotDescriptors = records.flatMap((record) => (
materialDescriptorsFromText(`${record.fact.subject} ${record.fact.value} ${record.fact.note ?? ''}`)
))))
))
const slotDescriptor = selectSingleMaterialDescriptor(rawSlotDescriptors)
// Applicable slot facts outrank the model. This is what keeps a pine back
// panel pine even when the model copies the ash carcass material onto it.
if (slotDescriptors.length === 1) return slotDescriptors[0]
if (slotDescriptors.length > 1) return undefined
if (slotDescriptor) return slotDescriptor
if (rawSlotDescriptors.length > 0) return undefined
const modelDescriptor = input.modelLine?.descriptor?.trim()
const modelFamily = modelDescriptor ? materialFamilyFromDescriptor(modelDescriptor) : undefined
if (!modelDescriptor || !modelFamily) return undefined
const referencedFactIds = new Set(input.modelLine?.evidenceFactIds ?? [])
const supportedDescriptors = Array.from(new Set(records
.filter((record) => referencedFactIds.has(record.factId))
.flatMap((record) => materialDescriptorsFromText(
`${record.fact.subject} ${record.fact.value} ${record.fact.note ?? ''}`,
))))
const supportedFamilies = Array.from(new Set(
supportedDescriptors.flatMap((descriptor) => materialFamilyFromDescriptor(descriptor) ?? []),
if (modelDescriptor && modelFamily) {
const referencedFactIds = new Set(input.modelLine?.evidenceFactIds ?? [])
const supportedDescriptor = selectSingleMaterialDescriptor(records
.filter((record) => referencedFactIds.has(record.factId))
.flatMap((record) => materialDescriptorsFromText(
`${record.fact.subject} ${record.fact.value} ${record.fact.note ?? ''}`,
)))
if (
supportedDescriptor &&
materialFamilyFromDescriptor(supportedDescriptor) === modelFamily
) {
return supportedDescriptor
}
}
if (!canInheritPrimaryMaterial(input.slot)) return undefined
if (
input.primaryMaterialDescriptor &&
!hasUnresolvedPrimaryMaterialConflict({
primaryDescriptor: input.primaryMaterialDescriptor,
materialRecords: allMaterialRecords,
slots: input.groupSlots,
})
) {
return selectSingleMaterialDescriptor(
materialDescriptorsFromText(input.primaryMaterialDescriptor),
)
}
const inheritedDescriptors = allMaterialRecords.flatMap((record) => (
materialDescriptorsFromText(`${record.fact.subject} ${record.fact.value} ${record.fact.note ?? ''}`)
))
if (supportedDescriptors.length !== 1 || supportedFamilies.length !== 1) return undefined
return supportedFamilies[0] === modelFamily ? supportedDescriptors[0] : undefined
return selectSingleMaterialDescriptor(inheritedDescriptors)
}
function resolveNonMaterialModelDescriptor(input: {
@ -1005,6 +1076,9 @@ function normalizeInstantiatedSku(input: {
const modelByKey = new Map(
(input.model?.bomLines ?? []).map((line) => [line.externalLineId, line] as const),
)
const primaryMaterialDescriptor = input.group.costFamilies.find(
(family) => family.skuIds.includes(input.sku.externalSkuId),
)?.materialFamily
const verticalSlot = input.group.componentSlots.find(
(slot) => slot.componentKey === 'partition-vertical',
)
@ -1049,6 +1123,8 @@ function normalizeInstantiatedSku(input: {
sku: input.sku,
factSnapshot: input.factSnapshot,
modelLine,
primaryMaterialDescriptor,
groupSlots: input.group.componentSlots,
})
: resolveNonMaterialModelDescriptor({
category: slot.category,
@ -1057,13 +1133,9 @@ function normalizeInstantiatedSku(input: {
return compactInstantiatedBomLineSchema.parse({
externalLineId: slot.componentKey,
quantity,
unit: modelLine?.unit || (
slot.category === 'BOARD'
? '块'
: ['PROCESSING', 'LABOR', 'PACKAGING'].includes(slot.category)
? '套'
: '件'
),
// A model may confuse a dimension token such as mm/cm/m with a count
// unit. The category Skill contract is the sole owner of line units.
unit: slot.unit,
// For board slots, the shared blueprint formula must remain stable
// across sibling SKUs. The model can fill genuinely unknown geometry,
// but cannot override dimensions derived from the SKU's outer size.

View File

@ -63,8 +63,8 @@ describe('BOM 通用结构知识与确定性蓝图', () => {
snapshot.skus.map((item) => item.externalSkuId),
)
expect(result.structureGroups[0]?.componentSlots).toEqual(expect.arrayContaining([
expect.objectContaining({ componentKey: 'enclosure-bottom', required: true }),
expect.objectContaining({ componentKey: 'processing-main', required: true }),
expect.objectContaining({ componentKey: 'enclosure-bottom', required: true, unit: '块' }),
expect.objectContaining({ componentKey: 'processing-main', required: true, unit: '套' }),
expect.objectContaining({ componentKey: 'labor-assembly', required: true }),
expect.objectContaining({ componentKey: 'packaging-set', required: true }),
]))

View File

@ -145,10 +145,12 @@ function buildComponentSlot(input: {
role: input.component.role,
category: input.component.category,
partName: input.component.partName,
unit: input.component.unit,
required: input.draft ? input.definitionRequired : required,
quantityRule: input.component.quantityRule,
dimensionRule: input.component.dimensionRule,
materialScope: input.component.materialScope,
primaryMaterialSlot: input.component.primaryMaterialSlot === true,
evidenceFactIds,
confidence,
reviewFlags: facts.length === 0

View File

@ -34,17 +34,17 @@ export const cabinetBookshelfCategorySkill = {
{ signalKey: 'exclude-soft-cabinet', terms: ['布艺收纳柜', '折叠布柜', '塑料抽屉柜'], weight: 30, reason: '软体或塑料成品不适用板式柜体结构。', hard: true },
],
requiredComponents: [
{ componentKey: 'enclosure-side', role: 'ENCLOSURE', category: 'BOARD', partName: '左右侧板', stableQuantity: 2, unit: '块', quantityRule: '同一结构组固定 2 块。', dimensionRule: '高度按柜体高度,宽度按有效深度。', materialScope: '柜体主体材质。', keywords: ['侧板', '柜体侧板'] },
{ componentKey: 'enclosure-top', role: 'ENCLOSURE', category: 'BOARD', partName: '柜体顶板', stableQuantity: 1, unit: '块', quantityRule: '每个连续柜体固定 1 块。', dimensionRule: '长度随柜宽,宽度随有效深度。', materialScope: '柜体主体材质。', keywords: ['顶板', '柜顶'] },
{ componentKey: 'enclosure-bottom', role: 'ENCLOSURE', category: 'BOARD', partName: '柜体底板', stableQuantity: 1, unit: '块', quantityRule: '每个连续柜体固定 1 块。', dimensionRule: '长度随柜宽,宽度随有效深度。', materialScope: '柜体主体材质。', keywords: ['底板', '柜底'] },
{ componentKey: 'enclosure-side', role: 'ENCLOSURE', category: 'BOARD', partName: '左右侧板', stableQuantity: 2, unit: '块', primaryMaterialSlot: true, quantityRule: '同一结构组固定 2 块。', dimensionRule: '高度按柜体高度,宽度按有效深度。', materialScope: '柜体主体材质。', keywords: ['侧板', '柜体侧板'] },
{ componentKey: 'enclosure-top', role: 'ENCLOSURE', category: 'BOARD', partName: '柜体顶板', stableQuantity: 1, unit: '块', primaryMaterialSlot: true, quantityRule: '每个连续柜体固定 1 块。', dimensionRule: '长度随柜宽,宽度随有效深度。', materialScope: '柜体主体材质。', keywords: ['顶板', '柜顶'] },
{ componentKey: 'enclosure-bottom', role: 'ENCLOSURE', category: 'BOARD', partName: '柜体底板', stableQuantity: 1, unit: '块', primaryMaterialSlot: true, quantityRule: '每个连续柜体固定 1 块。', dimensionRule: '长度随柜宽,宽度随有效深度。', materialScope: '柜体主体材质。', keywords: ['底板', '柜底'] },
{ componentKey: 'back-panel', role: 'COVER', category: 'BOARD', partName: '背板', stableQuantity: 1, unit: '块', quantityRule: '固定 1 组背板;拼接缝没有证据时不拆数量。', dimensionRule: '覆盖背部开口,板厚必须单独取证。', materialScope: '背板可与主体不同。', keywords: ['背板', '后板'] },
{ componentKey: 'partition-shelf', role: 'PARTITION', category: 'BOARD', partName: '层板', stableQuantity: 3, unit: '块', quantityRule: '基础组固定 3 块;明确层数形成独立结构组。', dimensionRule: '长度按所在净开口,宽度按有效深度。', materialScope: '层板材质。', keywords: ['层板', '隔板', '搁板'], quantityByStructureKey: Object.fromEntries([2, 3, 4, 5, 6, 7, 8].map((count) => [`SHELF_COUNT_${count}`, count])) },
{ componentKey: 'partition-shelf', role: 'PARTITION', category: 'BOARD', partName: '层板', stableQuantity: 3, unit: '块', primaryMaterialSlot: true, quantityRule: '基础组固定 3 块;明确层数形成独立结构组。', dimensionRule: '长度按所在净开口,宽度按有效深度。', materialScope: '层板材质。', keywords: ['层板', '隔板', '搁板'], quantityByStructureKey: Object.fromEntries([2, 3, 4, 5, 6, 7, 8].map((count) => [`SHELF_COUNT_${count}`, count])) },
CONNECTOR_COMPONENT,
...COMMON_COST_COMPONENTS,
],
optionalComponents: [
{ componentKey: 'partition-vertical', role: 'PARTITION', category: 'BOARD', partName: '竖向隔板', stableQuantity: 1, unit: '块', quantityRule: '分栏结构组固定 1 块;更多分栏需专属事实。', dimensionRule: '按柜内净高和有效深度。', materialScope: '柜体主体材质。', keywords: ['竖隔板', '中竖板', '分栏'], requiredWhenStructureKeys: ['VERTICAL_DIVISION'] },
{ componentKey: 'cover-door', role: 'COVER', category: 'BOARD', partName: '柜门', stableQuantity: 2, unit: '块', quantityRule: '带门结构组固定 2 扇;明确门数需独立结构组或 SKU 专属事实。', dimensionRule: '按柜体开口范围推导。', materialScope: '门板材质和表面处理。', keywords: ['柜门', '门板'], requiredWhenStructureKeys: ['HINGED_DOOR', 'SLIDING_DOOR'] },
{ componentKey: 'partition-vertical', role: 'PARTITION', category: 'BOARD', partName: '竖向隔板', stableQuantity: 1, unit: '块', primaryMaterialSlot: true, quantityRule: '分栏结构组固定 1 块;更多分栏需专属事实。', dimensionRule: '按柜内净高和有效深度。', materialScope: '柜体主体材质。', keywords: ['竖隔板', '中竖板', '分栏'], requiredWhenStructureKeys: ['VERTICAL_DIVISION'] },
{ componentKey: 'cover-door', role: 'COVER', category: 'BOARD', partName: '柜门', stableQuantity: 2, unit: '块', primaryMaterialSlot: true, quantityRule: '带门结构组固定 2 扇;明确门数需独立结构组或 SKU 专属事实。', dimensionRule: '按柜体开口范围推导。', materialScope: '门板材质和表面处理。', keywords: ['柜门', '门板'], requiredWhenStructureKeys: ['HINGED_DOOR', 'SLIDING_DOOR'] },
{ componentKey: 'motion-hinge', role: 'MOTION', category: 'ACCESSORY', partName: '柜门铰链机构', stableQuantity: 1, unit: '套', quantityRule: '平开门结构组固定 1 套。', dimensionRule: '型号和杯孔无证据时待复核。', materialScope: '铰链和紧固件。', keywords: ['铰链', '合页'], requiredWhenStructureKeys: ['HINGED_DOOR'] },
{ componentKey: 'motion-sliding-door', role: 'MOTION', category: 'ACCESSORY', partName: '移门轨道机构', stableQuantity: 1, unit: '套', quantityRule: '移门结构组固定 1 套。', dimensionRule: '轨道长度随开口宽度。', materialScope: '轨道、滑轮和限位件。', keywords: ['移门', '推拉门', '轨道'], requiredWhenStructureKeys: ['SLIDING_DOOR'] },
{ componentKey: 'drawer-box', role: 'PARTITION', category: 'BOARD', partName: '抽屉盒板件', stableQuantity: 1, unit: '套', quantityRule: '带抽结构组固定 1 套;更多抽屉需专属事实。', dimensionRule: '按抽屉开口和柜内深度。', materialScope: '抽屉板件。', keywords: ['抽屉'], requiredWhenStructureKeys: ['DRAWER_PRESENT'] },

View File

@ -24,7 +24,7 @@ export const hardSeatingCategorySkill = {
{ signalKey: 'exclude-bench-storage', terms: ['储物凳', '收纳凳'], weight: 20, reason: '带软包或箱体的储物凳需要其他专用结构。', hard: true },
],
requiredComponents: [
{ componentKey: 'surface-seat', role: 'SURFACE', category: 'BOARD', partName: '座面或座板', stableQuantity: 1, unit: '件', quantityRule: '每个独立椅凳固定 1 个座面;套装件数进入成本组。', dimensionRule: '按座面标注或整机比例推导,厚度独立取证。', materialScope: '木板、塑料或金属硬质座面。', keywords: ['座面', '座板', '凳面'] },
{ componentKey: 'surface-seat', role: 'SURFACE', category: 'BOARD', partName: '座面或座板', stableQuantity: 1, unit: '件', primaryMaterialSlot: true, quantityRule: '每个独立椅凳固定 1 个座面;套装件数进入成本组。', dimensionRule: '按座面标注或整机比例推导,厚度独立取证。', materialScope: '木板、塑料或金属硬质座面。', keywords: ['座面', '座板', '凳面'] },
{ componentKey: 'support-seat', role: 'SUPPORT', category: 'BOARD', partName: '腿架与主体支撑', stableQuantity: 4, unit: '件', quantityRule: '普通四腿结构固定 4 件;框架/中柱结构独立分组。', dimensionRule: '由座高和支撑关系推导。', materialScope: '木材或金属支撑。', keywords: ['椅腿', '凳腿', '腿架', '支撑'], quantityByStructureKey: { FRAME_BASE: 1, PEDESTAL_BASE: 1 } },
{ componentKey: 'support-seat-brace', role: 'SUPPORT', category: 'BOARD', partName: '座下框与横撑', stableQuantity: 1, unit: '套', quantityRule: '同结构组固定 1 套座下框与横撑。', dimensionRule: '按座面下方连接边界推导。', materialScope: '木横撑、金属管或整体框架。', keywords: ['横撑', '座框', '下框'] },
CONNECTOR_COMPONENT,

View File

@ -138,6 +138,14 @@ function skillDefinitionIssues(skill: BomCostingCategorySkill) {
const componentKeys = allComponents.map((component) => component.componentKey)
if (componentKeys.length !== new Set(componentKeys).size) issues.push('存在重复 componentKey')
if (skill.requiredComponents.length === 0) issues.push('缺少必要部件')
if (!allComponents.some((component) => component.primaryMaterialSlot)) {
issues.push('缺少主体材质继承槽位')
}
for (const component of allComponents) {
if (component.primaryMaterialSlot && component.category !== 'BOARD') {
issues.push(`主体材质槽位 ${component.componentKey} 必须是 BOARD`)
}
}
const componentKeySet = new Set(componentKeys)
for (const relation of skill.relations) {
if (!componentKeySet.has(relation.fromComponentKey) || !componentKeySet.has(relation.toComponentKey)) {

View File

@ -35,7 +35,7 @@ export const storageRackCategorySkill = {
],
requiredComponents: [
{ componentKey: 'support-upright', role: 'SUPPORT', category: 'BOARD', partName: '立柱或侧架', stableQuantity: 4, unit: '件', quantityRule: '基础开放架固定 4 根立柱;双侧架结构组固定 2 组。', dimensionRule: '高度随整机高度,截面和壁厚必须单独取证。', materialScope: '木质或金属支撑。', keywords: ['立柱', '侧架', '支架'], quantityByStructureKey: { SIDE_FRAME: 2 } },
{ componentKey: 'partition-shelf', role: 'PARTITION', category: 'BOARD', partName: '置物层板', stableQuantity: 3, unit: '块', quantityRule: '基础组固定 3 层;明确层数形成独立结构组。', dimensionRule: '长宽按层架开口与整机深度。', materialScope: '木质、板材或金属层板。', keywords: ['层板', '置物板', '搁板'], quantityByStructureKey: Object.fromEntries([2, 3, 4, 5, 6, 7, 8].map((count) => [`TIER_COUNT_${count}`, count])) },
{ componentKey: 'partition-shelf', role: 'PARTITION', category: 'BOARD', partName: '置物层板', stableQuantity: 3, unit: '块', primaryMaterialSlot: true, quantityRule: '基础组固定 3 层;明确层数形成独立结构组。', dimensionRule: '长宽按层架开口与整机深度。', materialScope: '木质、板材或金属层板。', keywords: ['层板', '置物板', '搁板'], quantityByStructureKey: Object.fromEntries([2, 3, 4, 5, 6, 7, 8].map((count) => [`TIER_COUNT_${count}`, count])) },
{ componentKey: 'support-cross-brace', role: 'SUPPORT', category: 'STEEL', partName: '横撑与稳定件', stableQuantity: 2, unit: '件', quantityRule: '基础组固定 2 件横撑;斜撑结构按独立组冻结。', dimensionRule: '按立柱间净距和稳定关系推导。', materialScope: '木横撑、金属管或拉条。', keywords: ['横撑', '斜撑', '拉条'] },
{ componentKey: 'hardware-feet', role: 'HARDWARE', category: 'ACCESSORY', partName: '脚垫或调平脚', stableQuantity: 4, unit: '个', quantityRule: '按基础支撑点固定 4 个;侧架结构需专属事实调整。', dimensionRule: '规格无证据时待复核。', materialScope: '脚垫、调平脚和端塞。', keywords: ['脚垫', '调平脚', '端塞'] },
CONNECTOR_COMPONENT,

View File

@ -24,7 +24,7 @@ export const tableCategorySkill = {
{ signalKey: 'exclude-soft-table', terms: ['折叠布桌', '充气桌'], weight: 30, reason: '软体或充气产品不适用硬体桌 Skill。', hard: true },
],
requiredComponents: [
{ componentKey: 'surface-main', role: 'SURFACE', category: 'BOARD', partName: '主桌面', stableQuantity: 1, unit: '块', quantityRule: '普通桌结构组固定 1 块;拼接、折叠或延伸结构必须独立分组。', dimensionRule: '长宽以 SKU 外形和明确桌面标注为先;板厚独立取证。', materialScope: '当前 SKU 的桌面主体材质。', keywords: ['台面', '桌面', '面板'] },
{ componentKey: 'surface-main', role: 'SURFACE', category: 'BOARD', partName: '主桌面', stableQuantity: 1, unit: '块', primaryMaterialSlot: true, quantityRule: '普通桌结构组固定 1 块;拼接、折叠或延伸结构必须独立分组。', dimensionRule: '长宽以 SKU 外形和明确桌面标注为先;板厚独立取证。', materialScope: '当前 SKU 的桌面主体材质。', keywords: ['台面', '桌面', '面板'] },
{ componentKey: 'support-leg', role: 'SUPPORT', category: 'BOARD', partName: '桌腿或主体底座', stableQuantity: 4, unit: '件', quantityRule: '普通四腿桌固定 4 件;中柱底座结构组固定 1 件。', dimensionRule: '由桌高和连接关系推导,不复制整机长宽。', materialScope: '木质或金属承重支撑。', keywords: ['桌腿', '支腿', '底座', '中柱'], quantityByStructureKey: { PEDESTAL_BASE: 1, TRESTLE_BASE: 2 } },
{ componentKey: 'support-apron', role: 'SUPPORT', category: 'BOARD', partName: '桌面下框与横撑', stableQuantity: 1, unit: '套', quantityRule: '同一支撑结构组固定 1 套。', dimensionRule: '按桌面下方连接边界推导。', materialScope: '围板、横撑或金属桌架。', keywords: ['围板', '横撑', '桌架', '下框'] },
CONNECTOR_COMPONENT,
@ -32,7 +32,7 @@ export const tableCategorySkill = {
],
optionalComponents: [
{ componentKey: 'motion-extension', role: 'MOTION', category: 'ACCESSORY', partName: '伸缩与锁止机构', stableQuantity: 1, unit: '套', quantityRule: '伸缩桌结构组固定 1 套。', dimensionRule: '需核对收拢和展开尺寸、轨道行程及锁止位置。', materialScope: '伸缩轨、联动件、锁止和限位件。', keywords: ['伸缩', '延伸', '拉伸', '锁止'], requiredWhenStructureKeys: ['EXTENSION_TABLE'] },
{ componentKey: 'surface-extension-leaf', role: 'SURFACE', category: 'BOARD', partName: '延伸桌板', stableQuantity: 1, unit: '块', quantityRule: '伸缩桌结构组固定 1 块;多块延伸板需 SKU 专属事实。', dimensionRule: '按展开前后长度差和桌面宽度推导。', materialScope: '通常与主桌面同材质,仍需确认。', keywords: ['延伸板', '活动桌板', '加长板'], requiredWhenStructureKeys: ['EXTENSION_TABLE'] },
{ componentKey: 'surface-extension-leaf', role: 'SURFACE', category: 'BOARD', partName: '延伸桌板', stableQuantity: 1, unit: '块', primaryMaterialSlot: true, quantityRule: '伸缩桌结构组固定 1 块;多块延伸板需 SKU 专属事实。', dimensionRule: '按展开前后长度差和桌面宽度推导。', materialScope: '通常与主桌面同材质,仍需确认。', keywords: ['延伸板', '活动桌板', '加长板'], requiredWhenStructureKeys: ['EXTENSION_TABLE'] },
{ componentKey: 'motion-folding', role: 'MOTION', category: 'ACCESSORY', partName: '折叠铰接机构', stableQuantity: 1, unit: '套', quantityRule: '折叠桌结构组固定 1 套。', dimensionRule: '核对折叠轴、锁止和展开支撑。', materialScope: '铰链、转轴、锁止和紧固件。', keywords: ['折叠', '合页', '转轴'], requiredWhenStructureKeys: ['FOLDING_TABLE'] },
{ componentKey: 'drawer-box', role: 'PARTITION', category: 'BOARD', partName: '桌下抽屉盒', stableQuantity: 1, unit: '套', quantityRule: '带抽桌结构组固定 1 套。', dimensionRule: '按桌下净空与抽屉开口推导。', materialScope: '抽屉板件。', keywords: ['抽屉'], requiredWhenStructureKeys: ['DRAWER_PRESENT'] },
{ componentKey: 'motion-drawer', role: 'MOTION', category: 'ACCESSORY', partName: '桌下抽屉导轨', stableQuantity: 1, unit: '套', quantityRule: '每套抽屉盒配置成对导轨。', dimensionRule: '导轨长度不超过桌下有效深度。', materialScope: '导轨、拉手和紧固件。', keywords: ['抽屉', '导轨'], requiredWhenStructureKeys: ['DRAWER_PRESENT'] },

View File

@ -67,6 +67,11 @@ export interface BomCostingCategoryComponentDefinition {
keywords: readonly string[]
stableQuantity: number
unit: string
/**
* Explicit Skill-owned allowlist for inheriting the cost family's single
* primary material. Unmarked components require their own material fact.
*/
primaryMaterialSlot?: boolean
requiredWhenStructureKeys?: readonly string[]
quantityByStructureKey?: Readonly<Record<string, number>>
}

View File

@ -103,6 +103,7 @@ export const wardrobeCategorySkill = {
{
componentKey: 'enclosure-side', role: 'ENCLOSURE', category: 'BOARD',
partName: '左右侧板', stableQuantity: 2, unit: '块',
primaryMaterialSlot: true,
quantityRule: '同一结构组固定 2 块;只有明确分柜体或 SKU 专属数量事实才能改变。',
dimensionRule: '高度接近柜体高度,深度接近有效深度;板厚独立取证。',
materialScope: '柜体主体材质。', keywords: ['侧板', '柜体侧板', '侧框'],
@ -110,6 +111,7 @@ export const wardrobeCategorySkill = {
{
componentKey: 'enclosure-top', role: 'ENCLOSURE', category: 'BOARD',
partName: '柜体顶板', stableQuantity: 1, unit: '块',
primaryMaterialSlot: true,
quantityRule: '每个连续主柜体固定 1 块。',
dimensionRule: '长度随 SKU 柜体宽度,宽度随有效深度。',
materialScope: '柜体顶板材质。', keywords: ['顶板', '柜顶', '上横板'],
@ -117,6 +119,7 @@ export const wardrobeCategorySkill = {
{
componentKey: 'enclosure-bottom', role: 'ENCLOSURE', category: 'BOARD',
partName: '柜体底板', stableQuantity: 1, unit: '块',
primaryMaterialSlot: true,
quantityRule: '每个连续主柜体固定 1 块,不因宽度或颜色变化拆分。',
dimensionRule: '长度随 SKU 柜体宽度,宽度随有效深度。',
materialScope: '柜体底板材质。', keywords: ['底板', '柜底', '下横板'],
@ -131,6 +134,7 @@ export const wardrobeCategorySkill = {
{
componentKey: 'partition-vertical', role: 'PARTITION', category: 'BOARD',
partName: '主竖隔板', stableQuantity: 1, unit: '块',
primaryMaterialSlot: true,
quantityRule: '基础衣柜结构组固定 1 块;更多分区必须有 SKU 专属 factId 或独立布局组。',
dimensionRule: '由柜内高度和有效深度推导,并与上下横板闭合。',
materialScope: '柜内隔板材质。', keywords: ['竖隔板', '中竖板', '分区板'],
@ -138,6 +142,7 @@ export const wardrobeCategorySkill = {
{
componentKey: 'partition-horizontal', role: 'PARTITION', category: 'BOARD',
partName: '基础层板', stableQuantity: 1, unit: '块',
primaryMaterialSlot: true,
quantityRule: '基础衣柜结构组固定 1 块;层板增减必须有 SKU 专属 factId 或独立布局组。',
dimensionRule: '宽度按所在分区净宽,深度按柜内有效深度。',
materialScope: '柜内层板材质。', keywords: ['层板', '横隔板', '隔板'],
@ -145,6 +150,7 @@ export const wardrobeCategorySkill = {
{
componentKey: 'cover-door', role: 'COVER', category: 'BOARD',
partName: '衣柜门板', stableQuantity: 2, unit: '块',
primaryMaterialSlot: true,
quantityRule: '基础结构组固定 2 扇;明确门数形成独立结构组后按该组冻结。',
dimensionRule: '按柜体开口推导,推拉门保留重叠量;不能用门缝臆造门数。',
materialScope: '门板材质和表面处理。', keywords: ['门板', '柜门', '推拉门', '移门'],
@ -186,6 +192,7 @@ export const wardrobeCategorySkill = {
{
componentKey: 'top-cabinet-side', role: 'ENCLOSURE', category: 'BOARD',
partName: '顶柜左右侧板', stableQuantity: 2, unit: '块',
primaryMaterialSlot: true,
quantityRule: '带顶柜结构组固定 2 块。',
dimensionRule: '按顶柜高度和主柜有效深度推导。',
materialScope: '顶柜主体材质。', keywords: ['顶柜侧板', '顶柜'],
@ -194,6 +201,7 @@ export const wardrobeCategorySkill = {
{
componentKey: 'top-cabinet-horizontal', role: 'ENCLOSURE', category: 'BOARD',
partName: '顶柜上下板', stableQuantity: 2, unit: '块',
primaryMaterialSlot: true,
quantityRule: '带顶柜结构组固定 2 块。',
dimensionRule: '长度按顶柜宽度,宽度按主柜有效深度。',
materialScope: '顶柜主体材质。', keywords: ['顶柜顶板', '顶柜底板', '顶柜'],