fix(bom-costing): restore layered category estimates
This commit is contained in:
parent
de048075ef
commit
eb0a6ad499
@ -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([]),
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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' &&
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 }),
|
||||
]))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'] },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'] },
|
||||
|
||||
@ -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>>
|
||||
}
|
||||
|
||||
@ -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: ['顶柜顶板', '顶柜底板', '顶柜'],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user