diff --git a/furniture-3d-system/src/lib/bom-costing/contracts.ts b/furniture-3d-system/src/lib/bom-costing/contracts.ts index fb3e289c..3ae6be3e 100644 --- a/furniture-3d-system/src/lib/bom-costing/contracts.ts +++ b/furniture-3d-system/src/lib/bom-costing/contracts.ts @@ -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([]), diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts index 6a05e5c4..ed7f126f 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts @@ -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({ diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts index 6f1c88be..e37af3b1 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts @@ -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 { + 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' && diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-production-27.integration.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-production-27.integration.test.ts index 8e57a7c5..7c493b97 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-production-27.integration.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-production-27.integration.test.ts @@ -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) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts index 0ded994b..30ba27f6 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts @@ -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 () => { diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts index b809510f..1835a3d6 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts @@ -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, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts index 050e0311..140f6ae7 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts @@ -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]> + 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]> + 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]> + 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) => { diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts index ac8641e0..e60853c4 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts @@ -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() + 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 + 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 + 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. diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.test.ts index bae72786..26329d70 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.test.ts @@ -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 }), ])) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.ts index 84525853..44b70641 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.ts @@ -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 diff --git a/furniture-3d-system/src/server/services/bom-costing/category-skills/cabinet-bookshelf.skill.ts b/furniture-3d-system/src/server/services/bom-costing/category-skills/cabinet-bookshelf.skill.ts index 012b29c3..c6736d6c 100644 --- a/furniture-3d-system/src/server/services/bom-costing/category-skills/cabinet-bookshelf.skill.ts +++ b/furniture-3d-system/src/server/services/bom-costing/category-skills/cabinet-bookshelf.skill.ts @@ -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'] }, diff --git a/furniture-3d-system/src/server/services/bom-costing/category-skills/hard-seating.skill.ts b/furniture-3d-system/src/server/services/bom-costing/category-skills/hard-seating.skill.ts index abb83470..53853490 100644 --- a/furniture-3d-system/src/server/services/bom-costing/category-skills/hard-seating.skill.ts +++ b/furniture-3d-system/src/server/services/bom-costing/category-skills/hard-seating.skill.ts @@ -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, diff --git a/furniture-3d-system/src/server/services/bom-costing/category-skills/registry.ts b/furniture-3d-system/src/server/services/bom-costing/category-skills/registry.ts index 78315eb4..50946050 100644 --- a/furniture-3d-system/src/server/services/bom-costing/category-skills/registry.ts +++ b/furniture-3d-system/src/server/services/bom-costing/category-skills/registry.ts @@ -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)) { diff --git a/furniture-3d-system/src/server/services/bom-costing/category-skills/storage-rack.skill.ts b/furniture-3d-system/src/server/services/bom-costing/category-skills/storage-rack.skill.ts index 82e0ca4f..41effd3c 100644 --- a/furniture-3d-system/src/server/services/bom-costing/category-skills/storage-rack.skill.ts +++ b/furniture-3d-system/src/server/services/bom-costing/category-skills/storage-rack.skill.ts @@ -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, diff --git a/furniture-3d-system/src/server/services/bom-costing/category-skills/table.skill.ts b/furniture-3d-system/src/server/services/bom-costing/category-skills/table.skill.ts index 16fad400..fd6ed1fe 100644 --- a/furniture-3d-system/src/server/services/bom-costing/category-skills/table.skill.ts +++ b/furniture-3d-system/src/server/services/bom-costing/category-skills/table.skill.ts @@ -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'] }, diff --git a/furniture-3d-system/src/server/services/bom-costing/category-skills/types.ts b/furniture-3d-system/src/server/services/bom-costing/category-skills/types.ts index 1ea90ace..1f4cd591 100644 --- a/furniture-3d-system/src/server/services/bom-costing/category-skills/types.ts +++ b/furniture-3d-system/src/server/services/bom-costing/category-skills/types.ts @@ -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> } diff --git a/furniture-3d-system/src/server/services/bom-costing/category-skills/wardrobe.skill.ts b/furniture-3d-system/src/server/services/bom-costing/category-skills/wardrobe.skill.ts index 8c70dedc..babef7e2 100644 --- a/furniture-3d-system/src/server/services/bom-costing/category-skills/wardrobe.skill.ts +++ b/furniture-3d-system/src/server/services/bom-costing/category-skills/wardrobe.skill.ts @@ -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: ['顶柜顶板', '顶柜底板', '顶柜'],