diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx index 8ff74431..34c02488 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -1673,6 +1673,7 @@ describe('BomCostingWorkbenchClient', () => { unitPrice: number | null subtotal: number | null secondarySubtotal?: number + rulesetVersion?: string }) => ({ id: input.id, skuId: 'sku-dynamic', @@ -1700,8 +1701,19 @@ describe('BomCostingWorkbenchClient', () => { syncRunId: 'run-success', syncStatus: 'SUCCEEDED', syncCompletedAt: now, - rulesetVersion: 'dynamic-bom-cost/v1', + rulesetVersion: input.rulesetVersion ?? 'dynamic-bom-cost/v1', createdAt: now, + boardCutting: [{ + sourceLineId: 'line-board', + materialName: input.kind === 'MATERIAL_SUBSTITUTION' ? '橡胶木' : '黑胡桃木', + rulesetVersion: 'board-sheet-maxrects/v2-batch', + selectedBatchSize: 8, + batchSheetCount: 8, + perUnitSheetCost: 800, + utilization: 0.82, + singleUnitSheetCount: 10, + singleUnitSheetCost: 1_000, + }], lines: [{ id: `${input.id}-line`, sourceLineId: 'line-board', @@ -1826,6 +1838,7 @@ describe('BomCostingWorkbenchClient', () => { unitPrice: 1_600, subtotal: 800, secondarySubtotal: 550, + rulesetVersion: 'dynamic-bom-cost/v6', }), scenario({ id: 'scenario-substitution-updated', @@ -1841,6 +1854,7 @@ describe('BomCostingWorkbenchClient', () => { unitPrice: 1_600, subtotal: 800, secondarySubtotal: 500, + rulesetVersion: 'dynamic-bom-cost/v6', }), ], } @@ -1862,6 +1876,59 @@ describe('BomCostingWorkbenchClient', () => { processingFinishedAt: now, createdAt: now, updatedAt: now, + structureAnalysis: { + revision: 1, + model: 'fixture-multimodal', + verificationRoundCount: 2, + unresolvedCount: 1, + unresolvedQuestions: [{ + questionId: 'q-top-thickness', + kind: 'THICKNESS', + question: '桌面板厚度是否为 20 mm?', + affectedComponentKeys: ['top-panel'], + evidenceFactIds: ['fact-size'], + confidence: 0.48, + status: 'REVIEW_REQUIRED', + structureGroupKey: 'desk', + structureGroupLabel: '桌类主体', + }], + blueprint: { + schemaVersion: 'structure-blueprint/v1', + knowledgeVersion: 'structure-priors/fixture', + productFamily: { code: 'TABLE', label: '桌类', confidence: 0.9 }, + mechanisms: [], + structureGroups: [{ + structureGroupKey: 'desk', + label: '桌类主体', + skuIds: ['COMPETITOR-SKU-DYNAMIC'], + componentSlots: [{ + componentKey: 'top-panel', + role: 'FRAME', + category: 'BOARD', + partName: '桌面板', + required: true, + quantityRule: '每套 1 块', + dimensionRule: '按 SKU 长宽展开', + materialScope: 'SKU 指定木材', + evidenceFactIds: ['fact-size'], + confidence: 0.48, + reviewFlags: ['板厚来自装配关系推导'], + }], + relations: [], + unresolvedQuestions: [{ + questionId: 'q-top-thickness', + kind: 'THICKNESS', + question: '桌面板厚度是否为 20 mm?', + affectedComponentKeys: ['top-panel'], + evidenceFactIds: ['fact-size'], + confidence: 0.48, + status: 'REVIEW_REQUIRED', + }], + }], + verificationRounds: [], + generatedAt: now, + }, + }, sourceSnapshot: { id: 'snapshot-dynamic', status: 'READY', @@ -1904,7 +1971,43 @@ describe('BomCostingWorkbenchClient', () => { unresolvedLineCount: 0, missingCostGroups: [], }, - bomLines: [], + bomLines: [{ + id: 'line-top-panel', + externalLineId: 'top-panel', + category: 'BOARD', + categoryLabel: '板材', + partName: '桌面板', + materialCode: '', + materialName: '黑胡桃木', + materialSpec: '20 mm', + processName: '', + packagingKind: '', + lengthMm: 1_200, + widthMm: 600, + heightMm: 20, + quantity: 1, + unit: '块', + weightKg: null, + lossFactor: 1, + sourceConfidence: 0.48, + evidence: [{ + basis: 'STRUCTURAL_INFERENCE', + factIds: ['fact-size'], + derivation: '按 SKU 长宽和装配关系推导', + reviewReason: '板厚需要人工复核', + confidence: 0.48, + }], + matchStatus: 'NEEDS_CONFIRMATION', + matchedLibraryType: '', + matchedLibraryRecordId: '', + matchedLibraryName: '', + matchedLibrarySpec: '', + resolvedUnitPrice: null, + resolvedPriceUnit: '', + calculatedQuantity: null, + subtotal: null, + updatedAt: now, + }], }], reviewInputs: [], analysisRuns: [], @@ -1959,6 +2062,15 @@ describe('BomCostingWorkbenchClient', () => { const skuButton = screen.getByRole('button', { name: /黑胡桃色 120cm/ }) expect(within(skuButton).getByText('旧版结果待重新核算')).toBeInTheDocument() expect(screen.getByText('旧规则留档:¥1,300.00')).toBeInTheDocument() + const structureSummary = screen.getByText('结构与推导依据') + fireEvent.click(structureSummary) + const structureDetails = structureSummary.closest('details') + expect(structureDetails).not.toBeNull() + expect(within(structureDetails as HTMLElement).getByText(/桌类 · 桌类主体/)).toBeInTheDocument() + expect(within(structureDetails as HTMLElement).getByText(/结构初判 1 轮 · 定向复核 2 轮/)).toBeInTheDocument() + expect(within(structureDetails as HTMLElement).getByText('待复核')).toBeInTheDocument() + expect(within(structureDetails as HTMLElement).getAllByText(/装配关系推导/).length).toBeGreaterThan(0) + expect(within(structureDetails as HTMLElement).getByRole('button', { name: '复核' })).toBeInTheDocument() await waitFor(() => { expect(screen.getByLabelText('选择替换目标材质')).toHaveValue( @@ -2009,6 +2121,13 @@ describe('BomCostingWorkbenchClient', () => { expect(screen.queryByText('暂无图片')).not.toBeInTheDocument() expect(screen.getByRole('heading', { name: '选择 SKU', level: 3 })).toBeInTheDocument() expect(screen.getAllByText('约 ¥1,300.00').length).toBeGreaterThan(0) + const costInfoButton = screen.getAllByRole('button', { + name: '查看预计出厂成本说明', + })[0] + fireEvent.click(costInfoButton) + expect(screen.getAllByText(/板材按 8 件稳定批量排版/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/批量利用率 82%/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/单件整板承担上限 ¥1,000.00/).length).toBeGreaterThan(0) expect(screen.getAllByText('预估 / 待复核').length).toBeGreaterThan(0) expect(screen.getByText(/成本高于竞品售价,金额已保留/)).toBeInTheDocument() expect(screen.getAllByText('约 ¥1,270.00').length).toBeGreaterThan(0) diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index d6bf28a9..c0c96855 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -331,7 +331,17 @@ const ESTIMATE_BASIS_LABELS = { CATEGORY_COHORT: '同分类历史分布', } as const -function scenarioEvidenceText(scenario: BomCostingScenarioDetail) { +const STRUCTURE_EVIDENCE_BASIS_LABELS = { + IMAGE: '图片明确证据', + SOURCE_TEXT: '商品文字', + SKU_METADATA: 'SKU 参数', + STRUCTURAL_INFERENCE: '装配关系推导', +} as const + +function scenarioEvidenceText( + scenario: BomCostingScenarioDetail, + structureAnalysis?: BomCostingCaseDetail['structureAnalysis'], +) { const basisLabels = Array.from(new Set(scenario.lines.flatMap((line) => ( line.estimateBasis ? [ESTIMATE_BASIS_LABELS[line.estimateBasis]] : [] )))) @@ -345,6 +355,24 @@ function scenarioEvidenceText(scenario: BomCostingScenarioDetail) { const sampleCount = referenceCount || historicalLineSampleCount || ( scenario.estimateBasis !== 'MASTER_DATA' ? scenario.sampleCount : 0 ) + const boardCutting = scenario.boardCutting ?? [] + const boardCostTotal = boardCutting.reduce( + (sum, item) => sum + item.perUnitSheetCost, + 0, + ) + const weightedBoardUtilization = boardCostTotal > 0 + ? boardCutting.reduce( + (sum, item) => sum + item.utilization * item.perUnitSheetCost, + 0, + ) / boardCostTotal + : null + const selectedBatchSizes = Array.from(new Set( + boardCutting.map((item) => item.selectedBatchSize), + )).sort((left, right) => left - right) + const singleUnitBoardUpper = boardCutting.reduce( + (sum, item) => sum + item.singleUnitSheetCost, + 0, + ) return [ `估算区间 ${formatEstimateRange(scenario.estimateRange)}`, `可信度 ${estimateConfidenceLabel(scenario.confidence)}${scenario.confidence !== null ? `(${Math.round(scenario.confidence * 100)}%)` : ''}`, @@ -355,12 +383,29 @@ function scenarioEvidenceText(scenario: BomCostingScenarioDetail) { : '历史样本数待确认', basisLabels.length > 0 ? `依据 ${basisLabels.join('、')}` : '', scenario.estimatedLineCount > 0 ? `${scenario.estimatedLineCount} 条明细采用分层预估` : '', + structureAnalysis + ? `结构初判 1 轮,定向复核 ${structureAnalysis.verificationRoundCount} 轮` + : '', + selectedBatchSizes.length > 0 + ? `板材按 ${selectedBatchSizes.join('、')} 件稳定批量排版` + : '', + weightedBoardUtilization !== null + ? `批量利用率 ${Math.round(weightedBoardUtilization * 100)}%` + : '', + singleUnitBoardUpper > 0 + ? `单件整板承担上限 ${formatMoney(singleUnitBoardUpper)}` + : '', scenario.warning ? `提醒 ${scenario.warning}` : '', ].filter(Boolean).join(';') } function missingScenarioLabel(scenario: BomCostingScenarioDetail | null) { - if (!scenario || !scenario.rulesetVersion.startsWith('dynamic-bom-cost/v5')) { + if ( + !scenario || + !['dynamic-bom-cost/v5', 'dynamic-bom-cost/v6'].some((version) => + scenario.rulesetVersion.startsWith(version), + ) + ) { return '旧版结果待重新核算' } return '核算未完成' @@ -1029,6 +1074,7 @@ function dynamicSyncLabel(state: BomCostingDynamicCostingDetail['sync']['state'] function DynamicCostingPanel({ sku, costing, + structureAnalysis, scenarioKind, targetMaterialId, calculating, @@ -1039,6 +1085,7 @@ function DynamicCostingPanel({ }: { sku: BomCostingSkuDetail costing: BomCostingDynamicCostingDetail | undefined + structureAnalysis: BomCostingCaseDetail['structureAnalysis'] scenarioKind: BomCostingDynamicScenarioKindValue targetMaterialId: string calculating: boolean @@ -1124,7 +1171,7 @@ function DynamicCostingPanel({ {!costing ? (
- 这条记录还没有 v5 预估结果。重新核算后会自动生成同材质基准;需要比较时再选择替换材质。 + 这条记录还没有 v6 预估结果。重新核算后会自动生成同材质基准;需要比较时再选择替换材质。
) : ( <> @@ -1232,7 +1279,7 @@ function DynamicCostingPanel({ : missingScenarioLabel(selectedScenario)} - {scenarioEvidenceText(selectedScenario)} + {scenarioEvidenceText(selectedScenario, structureAnalysis)} {selectedScenario.totalCost === null && @@ -1495,6 +1542,7 @@ function ResultCanvas({ const [dynamicCalculating, setDynamicCalculating] = useState(false) const [dynamicCalculationError, setDynamicCalculationError] = useState('') const [recalculationOpen, setRecalculationOpen] = useState(false) + const [structureEvidenceOpen, setStructureEvidenceOpen] = useState(false) const [reviewArchiveOpen, setReviewArchiveOpen] = useState(false) const [recalculationMode, setRecalculationMode] = useState('REUSE_FACTS') @@ -1909,7 +1957,36 @@ function ResultCanvas({ { label: '损耗', value: selectedDynamicScenario.lossCost, unresolved: selectedDynamicScenario.totalCost === null || selectedDynamicScenario.lossCost === null, missing: selectedDynamicScenario.totalCost === null || selectedDynamicScenario.lossCost === null }, ] : costSummaryRows - const currentSkuReviewInputs = (detail.reviewInputs ?? []).slice(0, 8) + const selectedStructureGroup = detail.structureAnalysis?.blueprint.structureGroups.find( + (group) => + group.structureGroupKey === selectedSku.structureGroupKey || + group.skuIds.includes(selectedSku.externalSkuId), + ) ?? null + const selectedStructureItems = (selectedStructureGroup?.componentSlots ?? []).map((slot) => { + const line = selectedSku.bomLines.find( + (candidate) => candidate.externalLineId === slot.componentKey, + ) ?? null + const reviewReasons = Array.from(new Set([ + ...slot.reviewFlags, + ...(line?.evidence.flatMap((item) => item.reviewReason ? [item.reviewReason] : []) ?? []), + ])) + const derivations = Array.from(new Set( + line?.evidence.flatMap((item) => item.derivation ? [item.derivation] : []) ?? [], + )) + return { + slot, + line, + confidence: line?.sourceConfidence ?? slot.confidence, + reviewReasons, + derivations, + } + }) + const lowConfidenceStructureCount = selectedStructureItems.filter( + (item) => item.confidence < 0.55 || item.reviewReasons.length > 0, + ).length + const currentSkuReviewInputs = (detail.reviewInputs ?? []) + .filter((input) => !input.skuId || input.skuId === selectedSku.id) + .slice(0, 8) const recalculationPanelOpen = recalculationOpen && detail.status === 'READY' @@ -2061,6 +2138,7 @@ function ResultCanvas({ {selectedDynamicScenario - ? `${scenarioEvidenceText(selectedDynamicScenario)};含材料、加工、人工、包材和损耗,不含税费、物流、平台费用和利润。` + ? `${scenarioEvidenceText(selectedDynamicScenario, detail.structureAnalysis)};含材料、加工、人工、包材和损耗,不含税费、物流、平台费用和利润。` : requiresRecalculation ? '当前留档由旧规则生成,不能用于价格判断;重新核算后会保留新的版本。' : hasPendingCost @@ -2651,7 +2729,7 @@ function ResultCanvas({ {selectedDynamicScenario ? selectedDynamicScenario.unresolvedCount > 0 ? `${selectedDynamicScenario.unresolvedCount} 条明细没有形成预估;本次核算未完成。` - : `${scenarioEvidenceText(selectedDynamicScenario)};规则 ${selectedDynamicScenario.rulesetVersion}。板材按整板裁切利用率计算,竞品售价只做合理性检查。` + : `${scenarioEvidenceText(selectedDynamicScenario, detail.structureAnalysis)};规则 ${selectedDynamicScenario.rulesetVersion}。板材按整板裁切利用率计算,竞品售价只做合理性检查。` : hasPendingCost ? 'SKU 结构已就绪;补齐对应成本资料后会形成完整成本。' : incompleteCostMessage @@ -2663,6 +2741,105 @@ function ResultCanvas({ + {detail.structureAnalysis && selectedStructureGroup ? ( +
setStructureEvidenceOpen(event.currentTarget.open)} + > + + 结构与推导依据 + + {selectedStructureItems.length} 个部件 + {lowConfidenceStructureCount > 0 ? ` · ${lowConfidenceStructureCount} 项待复核` : ''} + + + + + +
+
+
+ {detail.structureAnalysis.blueprint.productFamily.label} · {selectedStructureGroup.label} +
+
+ 结构初判 1 轮 · 定向复核 {detail.structureAnalysis.verificationRoundCount} 轮 · 规则 {detail.structureAnalysis.blueprint.schemaVersion} +
+
+ + {selectedStructureGroup.unresolvedQuestions.length > 0 ? ( +
+
仍需确认
+
    + {selectedStructureGroup.unresolvedQuestions.slice(0, 4).map((question) => ( +
  • · {question.question}
  • + ))} +
+
+ ) : null} + +
+ {selectedStructureItems.map(({ slot, line, confidence, reviewReasons, derivations }) => { + const evidenceBasis = Array.from(new Set( + line?.evidence.flatMap((item) => item.basis ? [item.basis] : []) ?? [], + )) + const needsReview = confidence < 0.55 || reviewReasons.length > 0 + return ( +
+
+
+
+ {line?.partName || slot.partName} + {line?.categoryLabel || slot.category} + {needsReview ? ( + 待复核 + ) : ( + 已形成依据 + )} +
+
+ {line + ? `${[line.lengthMm, line.widthMm, line.heightMm].map((value) => value ?? '--').join(' × ')} mm · ${line.quantity} ${line.unit}` + : `${slot.dimensionRule} · ${slot.quantityRule}`} +
+
+ {line?.materialName || line?.materialSpec || slot.materialScope || '材质待确认'} + {' · '} + 可信度 {Math.round(confidence * 100)}% +
+ {evidenceBasis.length > 0 ? ( +
+ 来源:{evidenceBasis.map((basis) => STRUCTURE_EVIDENCE_BASIS_LABELS[basis]).join('、')} +
+ ) : null} + {derivations.length > 0 ? ( +
推导:{derivations.slice(0, 2).join(';')}
+ ) : null} + {reviewReasons.length > 0 ? ( +
原因:{reviewReasons.slice(0, 2).join(';')}
+ ) : null} +
+ {line && needsReview ? ( + + ) : null} +
+
+ ) + })} +
+
+
+ ) : null} +
setReviewArchiveOpen(event.currentTarget.open)} diff --git a/furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.test.ts b/furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.test.ts index ccb1602b..c5639110 100644 --- a/furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.test.ts +++ b/furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.test.ts @@ -1,5 +1,7 @@ import { + BOARD_BATCH_CUTTING_RULESET_VERSION, BOARD_SHEET_CUTTING_RULESET_VERSION, + calculateBoardBatchCuttingPlan, calculateBoardSheetCuttingPlan, } from './board-sheet-cutting' @@ -58,4 +60,43 @@ describe('板材整板裁切成本', () => { expect(result?.allocations[0]?.pieceCount).toBe(2) expect(result?.warnings[0]).toContain('向上取整') }) + + it('连续批量排版后采用稳定批量的单位成本,同时保留单件最坏成本', () => { + const result = calculateBoardBatchCuttingPlan(sheet, [ + { lineId: 'small-panel', lengthMm: 600, widthMm: 600, quantity: 1 }, + ]) + + expect(result).toMatchObject({ + rulesetVersion: BOARD_BATCH_CUTTING_RULESET_VERSION, + selectedBatchSize: 16, + singleUnit: { batchSize: 1, perUnitSheetCost: 200 }, + }) + expect(result?.selectedBatch.perUnitSheetCost).toBeLessThan( + result?.singleUnit.perUnitSheetCost ?? 0, + ) + expect(result?.testedBatches.map((item) => item.batchSize)).toEqual([ + 1, 2, 4, 8, 16, + ]) + }) + + it('连续两档单位成本变化低于百分之一时提前停止', () => { + const result = calculateBoardBatchCuttingPlan(sheet, [ + { lineId: 'quarter-sheet', lengthMm: 600, widthMm: 600, quantity: 4 }, + ]) + + expect(result?.stabilized).toBe(true) + expect(result?.selectedBatchSize).toBeLessThan(16) + expect(result?.testedBatches.length).toBeLessThan(5) + }) + + it('更大批量超过两千板件安全上限时使用上一档有效结果', () => { + const result = calculateBoardBatchCuttingPlan(sheet, [ + { lineId: 'tiny-strip', lengthMm: 100, widthMm: 30, quantity: 150 }, + ]) + + expect(result?.selectedBatchSize).toBe(4) + expect(result?.warnings).toEqual(expect.arrayContaining([ + expect.stringContaining('安全上限'), + ])) + }) }) diff --git a/furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.ts b/furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.ts index 059b6081..50b6a8e0 100644 --- a/furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.ts +++ b/furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.ts @@ -1,5 +1,7 @@ export const BOARD_SHEET_CUTTING_RULESET_VERSION = 'board-sheet-maxrects/v1' as const +export const BOARD_BATCH_CUTTING_RULESET_VERSION = + 'board-sheet-maxrects/v2-batch' as const export interface BoardCuttingPartInput { lineId: string @@ -43,6 +45,35 @@ export interface BoardSheetCuttingPlan { warnings: string[] } +export interface BoardBatchCuttingTest { + batchSize: number + sheetCount: number + totalSheetCost: number + perUnitSheetCost: number + perUnitUsedAreaSqm: number + perUnitPurchasedAreaSqm: number + utilization: number + improvementFromPrevious: number | null + allocations: BoardCuttingAllocation[] +} + +export interface BoardBatchCuttingPlan { + rulesetVersion: typeof BOARD_BATCH_CUTTING_RULESET_VERSION + substrateId: string + materialName: string + sheetLengthMm: number + sheetWidthMm: number + fullSheetPrice: number + kerfMm: number + edgeMarginMm: number + selectedBatchSize: number + stabilized: boolean + testedBatches: BoardBatchCuttingTest[] + singleUnit: BoardBatchCuttingTest + selectedBatch: BoardBatchCuttingTest + warnings: string[] +} + type Rect = { x: number y: number @@ -240,9 +271,9 @@ export function calculateBoardSheetCuttingPlan( const kerfMm = Math.max(0, Number.isFinite(sheetInput.kerfMm ?? 4) ? sheetInput.kerfMm ?? 4 : 4) - const edgeMarginMm = Math.max(0, Number.isFinite(sheetInput.edgeMarginMm ?? 10) - ? sheetInput.edgeMarginMm ?? 10 - : 10) + const edgeMarginMm = Math.max(0, Number.isFinite(sheetInput.edgeMarginMm ?? 5) + ? sheetInput.edgeMarginMm ?? 5 + : 5) const usableLength = sheetLengthMm - edgeMarginMm * 2 + kerfMm const usableWidth = sheetWidthMm - edgeMarginMm * 2 + kerfMm if (usableLength <= 0 || usableWidth <= 0) return null @@ -315,3 +346,99 @@ export function calculateBoardSheetCuttingPlan( warnings: Array.from(new Set(warnings)), } } + +function perUnitBatchTest( + plan: BoardSheetCuttingPlan, + batchSize: number, + previous: BoardBatchCuttingTest | null, +): BoardBatchCuttingTest { + const perUnitSheetCost = round(plan.totalSheetCost / batchSize, 2) + const improvementFromPrevious = previous && previous.perUnitSheetCost > 0 + ? round(Math.max( + 0, + Math.abs(previous.perUnitSheetCost - perUnitSheetCost) / + previous.perUnitSheetCost, + ), 6) + : null + return { + batchSize, + sheetCount: plan.sheetCount, + totalSheetCost: plan.totalSheetCost, + perUnitSheetCost, + perUnitUsedAreaSqm: round(plan.usedAreaSqm / batchSize, 6), + perUnitPurchasedAreaSqm: round(plan.purchasedAreaSqm / batchSize, 6), + utilization: plan.utilization, + improvementFromPrevious, + allocations: plan.allocations.map((allocation) => ({ + lineId: allocation.lineId, + pieceCount: Math.max(1, Math.round(allocation.pieceCount / batchSize)), + partAreaSqm: round(allocation.partAreaSqm / batchSize, 6), + allocatedCost: round(allocation.allocatedCost / batchSize, 2), + })), + } +} + +export function calculateBoardBatchCuttingPlan( + sheetInput: BoardSheetInput, + partInputs: BoardCuttingPartInput[], + batchSizes: number[] = [1, 2, 4, 8, 16], +): BoardBatchCuttingPlan | null { + const normalizedBatchSizes = Array.from(new Set(batchSizes)) + .filter((value) => Number.isInteger(value) && value > 0 && value <= 64) + .sort((left, right) => left - right) + if (normalizedBatchSizes[0] !== 1) normalizedBatchSizes.unshift(1) + + const testedBatches: BoardBatchCuttingTest[] = [] + const warnings: string[] = [] + let consecutiveStableSteps = 0 + let stabilized = false + for (const batchSize of normalizedBatchSizes) { + const plan = calculateBoardSheetCuttingPlan( + sheetInput, + partInputs.map((part) => ({ + ...part, + quantity: part.quantity * batchSize, + })), + ) + if (!plan) { + warnings.push( + `批量 ${batchSize} 件的板件数量或排版超出安全上限,采用上一档有效结果`, + ) + break + } + const previous = testedBatches.at(-1) ?? null + const test = perUnitBatchTest(plan, batchSize, previous) + testedBatches.push(test) + if ( + test.improvementFromPrevious !== null && + test.improvementFromPrevious < 0.01 + ) { + consecutiveStableSteps += 1 + } else { + consecutiveStableSteps = 0 + } + if (consecutiveStableSteps >= 2) { + stabilized = true + break + } + } + const singleUnit = testedBatches.find((test) => test.batchSize === 1) + const selectedBatch = testedBatches.at(-1) + if (!singleUnit || !selectedBatch) return null + return { + rulesetVersion: BOARD_BATCH_CUTTING_RULESET_VERSION, + substrateId: sheetInput.substrateId, + materialName: sheetInput.materialName, + sheetLengthMm: sheetInput.sheetLengthMm, + sheetWidthMm: sheetInput.sheetWidthMm, + fullSheetPrice: sheetInput.fullSheetPrice, + kerfMm: sheetInput.kerfMm ?? 4, + edgeMarginMm: sheetInput.edgeMarginMm ?? 5, + selectedBatchSize: selectedBatch.batchSize, + stabilized, + testedBatches, + singleUnit, + selectedBatch, + warnings: Array.from(new Set(warnings)), + } +} diff --git a/furniture-3d-system/src/lib/bom-costing/client-types.ts b/furniture-3d-system/src/lib/bom-costing/client-types.ts index fc6e2dcc..00010db5 100644 --- a/furniture-3d-system/src/lib/bom-costing/client-types.ts +++ b/furniture-3d-system/src/lib/bom-costing/client-types.ts @@ -1,4 +1,7 @@ -import type { BomCostingCapabilities } from './contracts' +import type { + BomCostingCapabilities, + BomCostingStructureBlueprintV1, +} from './contracts' export type BomCostingCaseStatusValue = | 'DRAFT' @@ -86,7 +89,11 @@ export interface BomCostingLineDetail { evidence: Array<{ sourceUrl?: string imageUrl?: string + basis?: 'IMAGE' | 'SOURCE_TEXT' | 'SKU_METADATA' | 'STRUCTURAL_INFERENCE' note?: string + factIds?: string[] + derivation?: string + reviewReason?: string confidence: number }> matchStatus: 'UNMATCHED' | 'MATCHED' | 'NEEDS_CONFIRMATION' @@ -163,6 +170,19 @@ export interface BomCostingCaseDetail { processingFinishedAt: string | null createdAt: string updatedAt: string + structureAnalysis: { + revision: number + model: string + blueprint: BomCostingStructureBlueprintV1 + verificationRoundCount: number + unresolvedCount: number + unresolvedQuestions: Array< + BomCostingStructureBlueprintV1['structureGroups'][number]['unresolvedQuestions'][number] & { + structureGroupKey: string + structureGroupLabel: string + } + > + } | null sourceSnapshot: BomCostingSourceSnapshotDetail | null skus: BomCostingSkuDetail[] reviewInputs?: Array<{ @@ -313,6 +333,17 @@ export interface BomCostingScenarioDetail { syncCompletedAt: string | null rulesetVersion: string createdAt: string + boardCutting: Array<{ + sourceLineId: string | null + materialName: string + rulesetVersion: string + selectedBatchSize: number + batchSheetCount: number + perUnitSheetCost: number + utilization: number + singleUnitSheetCount: number + singleUnitSheetCost: number + }> lines: BomCostingScenarioLineDetail[] } diff --git a/furniture-3d-system/src/lib/bom-costing/contracts.test.ts b/furniture-3d-system/src/lib/bom-costing/contracts.test.ts index 5f66a365..ef955416 100644 --- a/furniture-3d-system/src/lib/bom-costing/contracts.test.ts +++ b/furniture-3d-system/src/lib/bom-costing/contracts.test.ts @@ -1,5 +1,5 @@ import { - BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, competitorAnalysisResultV1Schema, recalculateBomCostingCaseSchema, updateBomCostingLineSchema, @@ -8,7 +8,7 @@ import { describe('CompetitorAnalysisResultV1', () => { it('接受全 SKU、部件 BOM、证据与置信度的规范结果', () => { const result = competitorAnalysisResultV1Schema.parse({ - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl: 'https://example.com/product/1', product: { title: '竞品书桌', @@ -41,7 +41,7 @@ describe('CompetitorAnalysisResultV1', () => { it('拒绝越界置信度和没有 BOM 的 SKU', () => { const invalid = competitorAnalysisResultV1Schema.safeParse({ - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl: 'https://example.com/product/1', product: { title: '竞品', imageUrls: [] }, skus: [{ @@ -58,7 +58,7 @@ describe('CompetitorAnalysisResultV1', () => { it('SKU 没有尺寸证据时规范化为空尺寸对象', () => { const result = competitorAnalysisResultV1Schema.parse({ - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl: 'https://example.com/product/1', product: { title: '竞品', imageUrls: [] }, skus: [{ @@ -84,7 +84,7 @@ describe('CompetitorAnalysisResultV1', () => { it('允许归档后的站内对象地址,但拒绝任意相对路径', () => { const base = { - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl: 'https://example.com/product/1', product: { title: '竞品', @@ -119,7 +119,7 @@ describe('CompetitorAnalysisResultV1', () => { it('结构经验可以进入估算,但不能伪装成高置信度事实', () => { const base = { - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl: 'https://example.com/product/1', product: { title: '带抽屉收纳柜', imageUrls: [] }, skus: [{ diff --git a/furniture-3d-system/src/lib/bom-costing/contracts.ts b/furniture-3d-system/src/lib/bom-costing/contracts.ts index 1b248e63..2078de0e 100644 --- a/furniture-3d-system/src/lib/bom-costing/contracts.ts +++ b/furniture-3d-system/src/lib/bom-costing/contracts.ts @@ -1,12 +1,16 @@ import { z } from 'zod' -export const BOM_COSTING_ANALYSIS_SCHEMA_VERSION = 'competitor-analysis/v1' as const +export const BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION = + 'competitor-analysis/v1' as const +export const BOM_COSTING_ANALYSIS_SCHEMA_VERSION = 'competitor-analysis/v2' as const // This is deliberately separate from the AI response schema. It identifies // the end-to-end business rules that produced a persisted cost result so an // older result can be kept as an audit record without being presented as a // current calculation. -export const BOM_COSTING_RESULT_RULESET_VERSION = 'bom-costing-rules/v2' as const +export const BOM_COSTING_RESULT_RULESET_VERSION = 'bom-costing-rules/v3' as const export const BOM_COSTING_CONCEPT_MODEL_SCHEMA_VERSION = 'concept-model/v1' as const +export const BOM_COSTING_STRUCTURE_BLUEPRINT_SCHEMA_VERSION = + 'structure-blueprint/v1' as const export const BOM_COSTING_TARGET_ERROR_PERCENT = 10 export const bomCostingLineCategorySchema = z.enum([ @@ -56,6 +60,12 @@ export const competitorEvidenceSchema = z.object({ ]).optional(), ), note: optionalTrimmedStringSchema(1_000), + factIds: z.preprocess( + nullToUndefined, + z.array(z.string().trim().min(1).max(160)).max(100).optional(), + ), + derivation: optionalTrimmedStringSchema(2_000), + reviewReason: optionalTrimmedStringSchema(1_000), confidence: confidenceSchema, }) @@ -140,7 +150,7 @@ export const competitorSkuSchema = z.object({ }) export const competitorAnalysisResultV1Schema = z.object({ - schemaVersion: z.literal(BOM_COSTING_ANALYSIS_SCHEMA_VERSION), + schemaVersion: z.literal(BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION), sourceUrl: z.url(), product: z.object({ title: z.string().trim().min(1).max(500), @@ -164,6 +174,138 @@ export type CompetitorAnalysisResultV1 = z.infer export type CompetitorBomLineV1 = z.infer +export const bomCostingStructureComponentRoleSchema = z.enum([ + 'ENCLOSURE', + 'SUPPORT', + 'PARTITION', + 'COVER', + 'MOTION', + 'HARDWARE', + 'SURFACE', + 'PACKAGING', + 'PROCESSING', + 'LABOR', + 'OTHER', +]) + +export const bomCostingStructureQuestionKindSchema = z.enum([ + 'THICKNESS', + 'COMPONENT_BOUNDARY', + 'COMPONENT_COUNT', + 'DIMENSION_SCOPE', + 'MATERIAL_SCOPE', + 'MECHANISM', + 'STRUCTURE_CLOSURE', + 'SKU_DIFFERENCE', + 'OTHER', +]) + +export const bomCostingStructureComponentSlotSchema = z.object({ + componentKey: z.string().trim().min(1).max(120), + role: bomCostingStructureComponentRoleSchema, + category: bomCostingLineCategorySchema, + partName: z.string().trim().min(1).max(200), + 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(''), + 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([]), +}) + +export const bomCostingStructureRelationSchema = z.object({ + relationType: z.enum([ + 'CONTAINS', + 'SUPPORTS', + 'PARTITIONS', + 'COVERS', + 'CONNECTS', + 'MOVES_WITH', + 'ALIGNS_WITH', + ]), + fromComponentKey: z.string().trim().min(1).max(120), + toComponentKey: z.string().trim().min(1).max(120), + rule: z.string().trim().min(1).max(1_000), + evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), + confidence: confidenceSchema, +}) + +export const bomCostingStructureQuestionSchema = z.object({ + questionId: z.string().trim().min(1).max(120), + kind: bomCostingStructureQuestionKindSchema, + question: z.string().trim().min(1).max(1_000), + affectedComponentKeys: z.array(z.string().trim().min(1).max(120)).max(100).default([]), + evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), + confidence: confidenceSchema, + status: z.enum(['OPEN', 'RESOLVED', 'REVIEW_REQUIRED']).default('OPEN'), +}) + +export const bomCostingStructureGroupSchema = z.object({ + structureGroupKey: z.string().trim().min(1).max(160), + label: z.string().trim().min(1).max(240), + skuIds: z.array(z.string().trim().min(1).max(160)).min(1).max(600), + componentSlots: z.array(bomCostingStructureComponentSlotSchema).min(1).max(500), + relations: z.array(bomCostingStructureRelationSchema).max(1_000).default([]), + unresolvedQuestions: z.array(bomCostingStructureQuestionSchema).max(100).default([]), +}) + +export const bomCostingVerificationRoundSchema = z.object({ + round: z.number().int().min(1).max(20), + structureGroupKey: z.string().trim().min(1).max(160), + questionIds: z.array(z.string().trim().min(1).max(120)).min(1).max(100), + evidenceImageIds: z.array(z.string().trim().min(1).max(160)).max(20).default([]), + newFactCount: z.number().int().min(0).max(500), + confidenceBefore: confidenceSchema, + confidenceAfter: confidenceSchema, + status: z.enum(['RESOLVED', 'IMPROVED', 'NO_NEW_EVIDENCE', 'FAILED']), + message: z.string().trim().max(1_000).default(''), +}) + +export const bomCostingStructureBlueprintV1Schema = z.object({ + schemaVersion: z.literal(BOM_COSTING_STRUCTURE_BLUEPRINT_SCHEMA_VERSION), + knowledgeVersion: z.string().trim().min(1).max(160), + productFamily: z.object({ + code: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(240), + confidence: confidenceSchema, + }), + mechanisms: z.array(z.object({ + code: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(240), + evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), + confidence: confidenceSchema, + })).max(100).default([]), + structureGroups: z.array(bomCostingStructureGroupSchema).min(1).max(200), + verificationRounds: z.array(bomCostingVerificationRoundSchema).max(1_000).default([]), + generatedAt: z.iso.datetime(), +}) + +export type BomCostingStructureBlueprintV1 = z.infer< + typeof bomCostingStructureBlueprintV1Schema +> + +export const competitorAnalysisResultV2Schema = z.object({ + schemaVersion: z.literal(BOM_COSTING_ANALYSIS_SCHEMA_VERSION), + sourceUrl: z.url(), + product: competitorAnalysisResultV1Schema.shape.product, + structureBlueprint: bomCostingStructureBlueprintV1Schema, + skus: z.array(competitorSkuSchema).min(1).max(600), + analyzedAt: z.iso.datetime(), +}) + +export const competitorAnalysisResultSchema = z.union([ + competitorAnalysisResultV2Schema, + competitorAnalysisResultV1Schema, +]) + +export type CompetitorAnalysisResultV2 = z.infer< + typeof competitorAnalysisResultV2Schema +> +export type CompetitorAnalysisResult = z.infer< + typeof competitorAnalysisResultSchema +> + export const conceptModelSpecV1Schema = z.object({ schemaVersion: z.literal(BOM_COSTING_CONCEPT_MODEL_SCHEMA_VERSION), structureGroupKey: z.string().trim().min(1), diff --git a/furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts b/furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts index 3342767a..d2a8a3a1 100644 --- a/furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts +++ b/furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts @@ -10,7 +10,7 @@ import { Prisma } from '@prisma/client' * `estimatedUnitPrice` field. */ -export const DYNAMIC_BOM_COST_RULESET_VERSION = 'dynamic-bom-cost/v5' as const +export const DYNAMIC_BOM_COST_RULESET_VERSION = 'dynamic-bom-cost/v6' as const export type DynamicBomCategory = | 'BOARD' diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-detail-image-stitcher.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-detail-image-stitcher.ts index 88fefab7..2ad5d3d0 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-detail-image-stitcher.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-detail-image-stitcher.ts @@ -398,33 +398,37 @@ export async function stitchBomCostingDetailImages( throw new Error('详情图切片数量不完整,未生成合成图') } - const normalized = await Promise.all(sourceBuffers.map(async (buffer, index) => { - const output = await sharp(buffer, { + const sources: Array<{ + buffer: Buffer + width: number + height: number + expectedCropHeight: number | null + }> = [] + for (let index = 0; index < sourceBuffers.length; index += 1) { + const buffer = sourceBuffers[index]! + const metadata = await sharp(buffer, { limitInputPixels: MAX_STITCH_SOURCE_PIXELS, - }) - .rotate() - .flatten({ background: '#ffffff' }) - .toColourspace('srgb') - .png() - .toBuffer({ resolveWithObject: true }) + sequentialRead: true, + }).metadata() + const dimensions = readRotatedDimensions(metadata) const parsed = parseCropSlice(plan.sourceUrls[index]!, plan.sourceIndexes[index] ?? index) if ( - !output.info.width || - !output.info.height || + !dimensions.width || + !dimensions.height || (plan.strategy === 'EXPLICIT_CROP_SEQUENCE_V1' && !parsed) ) { throw new Error('详情图切片尺寸无法识别,未生成合成图') } - return { - buffer: output.data, - width: output.info.width, - height: output.info.height, + sources.push({ + buffer, + width: dimensions.width, + height: dimensions.height, expectedCropHeight: parsed?.height ?? null, - } - })) + }) + } - const sourceWidth = normalized[0]!.width - for (const image of normalized) { + const sourceWidth = sources[0]!.width + for (const image of sources) { if (image.width !== sourceWidth) { throw new Error('详情图切片的实际尺寸与裁切坐标不一致,未生成合成图') } @@ -441,7 +445,7 @@ export async function stitchBomCostingDetailImages( } } - const sourceHeight = normalized.reduce((sum, image) => sum + image.height, 0) + const sourceHeight = sources.reduce((sum, image) => sum + image.height, 0) if (sourceWidth * sourceHeight > MAX_STITCH_SOURCE_PIXELS) { throw new Error('连续详情图合成后的尺寸过大,未生成合成图') } @@ -452,27 +456,75 @@ export async function stitchBomCostingDetailImages( ) const targetWidth = Math.max(1, Math.round(sourceWidth * outputScale)) const targetHeight = Math.max(1, Math.round(sourceHeight * outputScale)) + let remainingTargetHeight = targetHeight + const targetSliceHeights = sources.map((source, index) => { + const remainingSlices = sources.length - index - 1 + if (remainingSlices === 0) return remainingTargetHeight + const proposed = Math.max(1, Math.round(source.height * outputScale)) + const bounded = Math.min( + proposed, + Math.max(1, remainingTargetHeight - remainingSlices), + ) + remainingTargetHeight -= bounded + return bounded + }) + + // Normalize one slice at a time directly to its final display dimensions. + // The old implementation decoded all slices at full size, built another + // full-size PNG, and only then resized it. With eight tall slices that kept + // several hundred MB of duplicate pixel buffers alive in the worker. + const normalized: Array<{ + buffer: Buffer + width: number + height: number + channels: 3 + }> = [] + for (let index = 0; index < sources.length; index += 1) { + const source = sources[index]! + const sliceHeight = targetSliceHeights[index]! + const output = await sharp(source.buffer, { + limitInputPixels: MAX_STITCH_SOURCE_PIXELS, + sequentialRead: true, + }) + .rotate() + .flatten({ background: '#ffffff' }) + .toColourspace('srgb') + .resize(targetWidth, sliceHeight, { fit: 'fill' }) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }) + normalized.push({ + buffer: output.data, + width: output.info.width, + height: output.info.height, + channels: 3, + }) + } + let top = 0 const composite = normalized.map((image) => { - const entry = { input: image.buffer, left: 0, top } + const entry = { + input: image.buffer, + raw: { + width: image.width, + height: image.height, + channels: image.channels, + }, + left: 0, + top, + } top += image.height return entry }) - const combined = await sharp({ + const output = await sharp({ create: { - width: sourceWidth, - height: sourceHeight, + width: targetWidth, + height: targetHeight, channels: 3, background: '#ffffff', }, }) .composite(composite) - .png() - .toBuffer() - const output = await sharp(combined, { - limitInputPixels: MAX_STITCH_SOURCE_PIXELS, - }) - .resize(targetWidth, targetHeight, { fit: 'fill' }) .jpeg({ quality: 92, chromaSubsampling: '4:4:4' }) .toBuffer({ resolveWithObject: true }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.ts index 4b0f4703..92057357 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.ts @@ -1,7 +1,7 @@ import 'server-only' import { - BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, competitorAnalysisResultV1Schema, type CompetitorBomLineV1, type CompetitorSkuV1, @@ -426,7 +426,7 @@ export function buildFactBasedCompetitorAnalysis(input: { ])).slice(0, 30) return competitorAnalysisResultV1Schema.parse({ - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl: input.sourceUrl, product: { title: input.snapshot.title, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts index 8c58f7d1..432236e8 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts @@ -231,6 +231,7 @@ describe('BOM 多模态分析器', () => { process.env.BOM_COSTING_SYNTHESIS_BATCH_MAX_DURATION_MS const originalMaxSkusPerCase = process.env.BOM_COSTING_MAX_SKUS_PER_CASE const originalDetailedSynthesis = process.env.BOM_COSTING_DETAILED_SYNTHESIS + const originalStructureBlueprint = process.env.BOM_COSTING_STRUCTURE_BLUEPRINT_V6 beforeEach(() => { process.env.BOM_COSTING_AI_FACT_BATCH_SIZE = '6' @@ -242,6 +243,10 @@ describe('BOM 多模态分析器', () => { // The historical cases below specifically exercise the optional advanced // synthesis path. Production now defaults to fact-based local assembly. process.env.BOM_COSTING_DETAILED_SYNTHESIS = 'true' + // This legacy analyzer suite keeps covering the old compact/detailed + // contracts. The v6 structure-blueprint pipeline has its own focused + // tests and remains enabled by default in production. + process.env.BOM_COSTING_STRUCTURE_BLUEPRINT_V6 = 'false' delete process.env.BOM_COSTING_AI_TIMEOUT_MS jest.spyOn(providerConfigService, 'getActive').mockResolvedValue({ id: 'config-1', @@ -294,6 +299,11 @@ describe('BOM 多模态分析器', () => { } else { process.env.BOM_COSTING_DETAILED_SYNTHESIS = originalDetailedSynthesis } + if (originalStructureBlueprint === undefined) { + delete process.env.BOM_COSTING_STRUCTURE_BLUEPRINT_V6 + } else { + process.env.BOM_COSTING_STRUCTURE_BLUEPRINT_V6 = originalStructureBlueprint + } jest.restoreAllMocks() }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts index abbd2cd6..5311421d 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts @@ -2,13 +2,13 @@ import 'server-only' import { z } from 'zod' import { - BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, bomCostingLineCategorySchema, competitorBomLineSchema, competitorAnalysisResultV1Schema, competitorResourceUrlSchema, competitorSkuSchema, - type CompetitorAnalysisResultV1, + type CompetitorAnalysisResult, } from '@/lib/bom-costing/contracts' import type { BomCostingSourceFactSnapshot, @@ -36,6 +36,17 @@ import { type BomCostingFactExtractionTrace, } from './bom-costing-image-fact-extractor' import { buildFactBasedCompetitorAnalysis } from './bom-costing-fact-based-structure' +import { + buildStructuredBomCostingAnalysis, + type BomCostingStructureCheckpoint, + type BomCostingStructureStageProgress, +} from './bom-costing-structure-blueprint' + +export { readBomCostingStructureCheckpoint } from './bom-costing-structure-blueprint' +export type { + BomCostingStructureCheckpoint, + BomCostingStructureStageProgress, +} from './bom-costing-structure-blueprint' import { callBomCostingMultimodalModel, getBomCostingMultimodalProviderConfig, @@ -319,6 +330,10 @@ function isDetailedSynthesisEnabled() { return process.env.BOM_COSTING_DETAILED_SYNTHESIS?.trim().toLowerCase() === 'true' } +function isStructureBlueprintEnabled() { + return process.env.BOM_COSTING_STRUCTURE_BLUEPRINT_V6?.trim().toLowerCase() !== 'false' +} + function describeHighCardinalitySkuCatalog( skus: MarketSkuSnapshot[], safetyLimit: number, @@ -354,7 +369,11 @@ function describeHighCardinalitySkuCatalog( } export interface BomCostingMultimodalTrace { - pipeline?: 'FACT_BASED_STRUCTURE' | 'COMPACT_COSTING' | 'DETAILED_SYNTHESIS' + pipeline?: + | 'FACT_BASED_STRUCTURE' + | 'COMPACT_COSTING' + | 'DETAILED_SYNTHESIS' + | 'STRUCTURE_BLUEPRINT_V1' model: string callId: string | null imageCount: number @@ -368,10 +387,16 @@ export interface BomCostingMultimodalTrace { promptTokens: number | null completionTokens: number | null compactCostingFallbackSkuIds?: string[] + blueprintFallback?: boolean + verificationRoundCount?: number + instantiationCallCount?: number + memoryRssStartBytes?: number + memoryRssPeakBytes?: number + memoryRssEndBytes?: number } export interface BomCostingMultimodalOutput { - result: CompetitorAnalysisResultV1 + result: CompetitorAnalysisResult trace: BomCostingMultimodalTrace } @@ -1415,7 +1440,7 @@ async function runCompactCostingSynthesis(input: { ...analyzerInput.snapshot.skuImages, ])).slice(0, 30) const result = competitorAnalysisResultV1Schema.parse({ - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl: analyzerInput.sourceUrl, product: { title: analyzerInput.snapshot.title, @@ -1461,6 +1486,7 @@ export interface BomCostingMultimodalAnalyzeInput { existingFactSnapshot?: BomCostingSourceFactSnapshot | null reuseExistingFactsOnly?: boolean synthesisCheckpoint?: BomCostingSynthesisCheckpoint | null + structureCheckpoint?: BomCostingStructureCheckpoint | null analysisInstruction?: string signal?: AbortSignal onFactBatchStarted?: ( @@ -1473,6 +1499,12 @@ export interface BomCostingMultimodalAnalyzeInput { snapshot: BomCostingSourceFactSnapshot trace: BomCostingFactExtractionTrace }) => Promise + onStructureStage?: ( + progress: BomCostingStructureStageProgress, + ) => Promise + onStructureCheckpoint?: ( + checkpoint: BomCostingStructureCheckpoint, + ) => Promise onSynthesisBatchStarted?: ( progress: Pick< BomCostingSynthesisBatchProgress, @@ -1487,7 +1519,7 @@ export interface BomCostingMultimodalAnalyzeInput { export class BomCostingMultimodalAnalyzer { async analyze( input: BomCostingMultimodalAnalyzeInput, - ): Promise { + ): Promise { return (await this.analyzeWithTrace(input)).result } @@ -1514,6 +1546,14 @@ export class BomCostingMultimodalAnalyzer { ) } + const memoryRssStartBytes = process.memoryUsage().rss + let memoryRssPeakBytes = memoryRssStartBytes + const sampleMemoryRss = () => { + const rss = process.memoryUsage().rss + memoryRssPeakBytes = Math.max(memoryRssPeakBytes, rss) + return rss + } + const factOutput = await extractBomCostingImageFacts({ config, snapshot: input.snapshot, @@ -1521,15 +1561,75 @@ export class BomCostingMultimodalAnalyzer { reuseExistingFactsOnly: input.reuseExistingFactsOnly, analysisInstruction: input.analysisInstruction, signal: input.signal, - onBatchStarted: input.onFactBatchStarted, - onBatchCompleted: input.onFactBatchCompleted, + onBatchStarted: async (progress) => { + sampleMemoryRss() + await input.onFactBatchStarted?.(progress) + }, + onBatchCompleted: async (progress) => { + sampleMemoryRss() + await input.onFactBatchCompleted?.(progress) + }, }) + sampleMemoryRss() await input.onFactExtractionCompleted?.(factOutput) - // The normal path estimates one real SKU at a time from the durable image - // facts. It is intentionally compact: no combination generation, no - // second image pass, and a malformed answer for one SKU only leaves that - // SKU pending instead of failing the whole competitor case. + // The normal path first establishes one product-level topology, then + // expands each real SKU from that shared blueprint. Targeted visual + // verification is allowed when a critical structure question remains; + // calls are evidence-scoped and checkpointed instead of repeating a giant + // all-image request or independently guessing a BOM for every SKU. + if (!isDetailedSynthesisEnabled() && isStructureBlueprintEnabled()) { + const structured = await buildStructuredBomCostingAnalysis({ + config, + sourceUrl: input.sourceUrl, + snapshot: input.snapshot, + factSnapshot: factOutput.snapshot, + signal: input.signal, + analysisInstruction: input.analysisInstruction, + onStage: async (progress) => { + sampleMemoryRss() + await input.onStructureStage?.(progress) + }, + checkpoint: input.structureCheckpoint, + onCheckpoint: async (checkpoint) => { + sampleMemoryRss() + await input.onStructureCheckpoint?.(checkpoint) + }, + }) + const memoryRssEndBytes = sampleMemoryRss() + return { + result: structured.result, + trace: { + pipeline: 'STRUCTURE_BLUEPRINT_V1', + model: structured.trace.model, + callId: structured.trace.callIds[0] ?? factOutput.trace.callIds[0] ?? null, + imageCount: factOutput.snapshot.imageFacts.length, + inputSkuCount: input.snapshot.skus.length, + factBatchCount: factOutput.trace.batchCount, + synthesisBatchCount: structured.trace.instantiationCallCount, + synthesisCallIds: structured.trace.callIds, + reusedImageCount: factOutput.trace.reusedImageCount, + analyzedImageCount: factOutput.trace.analyzedImageCount, + durationMs: factOutput.trace.durationMs + structured.trace.durationMs, + promptTokens: addNullableNumber( + factOutput.trace.promptTokens, + structured.trace.promptTokens, + ), + completionTokens: addNullableNumber( + factOutput.trace.completionTokens, + structured.trace.completionTokens, + ), + blueprintFallback: structured.trace.blueprintFallback, + verificationRoundCount: structured.trace.verificationRoundCount, + instantiationCallCount: structured.trace.instantiationCallCount, + compactCostingFallbackSkuIds: structured.trace.fallbackSkuIds, + memoryRssStartBytes, + memoryRssPeakBytes, + memoryRssEndBytes, + }, + } + } + if (!isDetailedSynthesisEnabled()) { return runCompactCostingSynthesis({ analyzerInput: input, @@ -1912,7 +2012,7 @@ export class BomCostingMultimodalAnalyzer { ).slice(0, 30) const result = competitorAnalysisResultV1Schema.parse({ - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl: input.sourceUrl, product: { title: input.snapshot.title, 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 5e3dfdb3..29feef5b 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 @@ -12,7 +12,7 @@ import { } from '@prisma/client' import { BOM_COSTING_RESULT_RULESET_VERSION, - competitorAnalysisResultV1Schema, + competitorAnalysisResultSchema, } from '@/lib/bom-costing/contracts' import { BOM_COSTING_FACT_ANALYSIS_STATUS, @@ -38,6 +38,7 @@ import { assertBomCostingResultQuality } from './bom-costing-result-quality' import { bomCostingScenarioService } from './bom-costing-scenario.service' import { getCompetitorAnalysisProvider } from './bom-costing-provider' import { + readBomCostingStructureCheckpoint, readBomCostingSynthesisCheckpoint, } from './bom-costing-multimodal-analyzer' import { isBomCostingMultimodalTransientError } from './bom-costing-multimodal-gateway' @@ -208,7 +209,7 @@ function replaceAnalysisImageUrlsWithStableArchive( } } - return competitorAnalysisResultV1Schema.parse({ + return competitorAnalysisResultSchema.parse({ ...result, product: { ...result.product, @@ -869,6 +870,9 @@ export class BomCostingProcessingService { synthesisCheckpoint: readBomCostingSynthesisCheckpoint( analysisRun.resultSnapshot, ), + structureCheckpoint: readBomCostingStructureCheckpoint( + analysisRun.resultSnapshot, + ), analysisInstruction: analysisRun.instruction, signal: options.signal, onFactBatchStarted: @@ -1036,6 +1040,34 @@ export class BomCostingProcessingService { }, }) }, + onStructureStage: async (stage) => { + const stageProgress = stage.stage === 'BLUEPRINT' + ? 78 + : stage.stage === 'VERIFY' + ? 81 + : stage.stage === 'INSTANTIATE' + ? 83 + : 85 + await bomCostingRepository.updateCaseStage(caseId, { + eventType: `MODEL_STRUCTURE_${stage.stage}`, + status: BomCostingCaseStatus.ANALYZING, + progress: stageProgress, + message: stage.message, + payload: { + runId: analysisRunId, + factAnalysisId, + stage: stage.stage, + structureGroupKey: stage.structureGroupKey ?? null, + round: stage.round ?? null, + }, + }) + }, + onStructureCheckpoint: async (checkpoint) => { + await bomCostingRepository.saveAnalysisRunSynthesisCheckpoint({ + id: analysisRunId, + checkpoint: checkpoint as unknown as Prisma.InputJsonValue, + }) + }, onSynthesisBatchStarted: async (progress) => { const caseProgress = 78 + diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-provider.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-provider.ts index 9787e46e..3fd49391 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-provider.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-provider.ts @@ -1,7 +1,7 @@ import 'server-only' import { - BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, BOM_COSTING_TARGET_ERROR_PERCENT, competitorAnalysisResultV1Schema, type BomCostingCapabilities, @@ -17,6 +17,8 @@ import { isBomCostingMultimodalConfigured, type BomCostingMultimodalInputStats, type BomCostingMultimodalOutput, + type BomCostingStructureCheckpoint, + type BomCostingStructureStageProgress, type BomCostingSynthesisCheckpoint, type BomCostingSynthesisBatchProgress, } from './bom-costing-multimodal-analyzer' @@ -39,6 +41,7 @@ export interface CompetitorAnalysisProvider { existingFactSnapshot?: BomCostingSourceFactSnapshot | null reuseExistingFactsOnly?: boolean synthesisCheckpoint?: BomCostingSynthesisCheckpoint | null + structureCheckpoint?: BomCostingStructureCheckpoint | null analysisInstruction?: string signal?: AbortSignal onFactBatchStarted?: ( @@ -51,6 +54,12 @@ export interface CompetitorAnalysisProvider { snapshot: BomCostingSourceFactSnapshot trace: BomCostingFactExtractionTrace }) => Promise + onStructureStage?: ( + progress: BomCostingStructureStageProgress, + ) => Promise + onStructureCheckpoint?: ( + checkpoint: BomCostingStructureCheckpoint, + ) => Promise onSynthesisBatchStarted?: ( progress: Pick< BomCostingSynthesisBatchProgress, @@ -65,7 +74,7 @@ export interface CompetitorAnalysisProvider { function createFixtureResult(sourceUrl: string): CompetitorAnalysisResultV1 { return competitorAnalysisResultV1Schema.parse({ - schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + schemaVersion: BOM_COSTING_LEGACY_ANALYSIS_SCHEMA_VERSION, sourceUrl, product: { title: '开发环境竞品书桌', @@ -331,6 +340,7 @@ class MarketDetailMultimodalProvider implements CompetitorAnalysisProvider { existingFactSnapshot?: BomCostingSourceFactSnapshot | null reuseExistingFactsOnly?: boolean synthesisCheckpoint?: BomCostingSynthesisCheckpoint | null + structureCheckpoint?: BomCostingStructureCheckpoint | null analysisInstruction?: string signal?: AbortSignal onFactBatchStarted?: ( @@ -343,6 +353,12 @@ class MarketDetailMultimodalProvider implements CompetitorAnalysisProvider { snapshot: BomCostingSourceFactSnapshot trace: BomCostingFactExtractionTrace }) => Promise + onStructureStage?: ( + progress: BomCostingStructureStageProgress, + ) => Promise + onStructureCheckpoint?: ( + checkpoint: BomCostingStructureCheckpoint, + ) => Promise onSynthesisBatchStarted?: ( progress: Pick< BomCostingSynthesisBatchProgress, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts index b90b861a..f1c790d2 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts @@ -4,7 +4,7 @@ import { Prisma, } from '@prisma/client' import type { - CompetitorAnalysisResultV1, + CompetitorAnalysisResult, CompetitorBomLineV1, } from '@/lib/bom-costing/contracts' import { @@ -209,7 +209,7 @@ function prepareLine( } export function prepareBomCostingAnalysis( - analysis: CompetitorAnalysisResultV1, + analysis: CompetitorAnalysisResult, candidates: Record, ) { return analysis.skus.map((sku, skuIndex) => { diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts index 2320337f..de64eb6d 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts @@ -1,6 +1,9 @@ /** @jest-environment node */ -import type { CompetitorAnalysisResultV1 } from '@/lib/bom-costing/contracts' +import type { + CompetitorAnalysisResultV1, + CompetitorAnalysisResultV2, +} from '@/lib/bom-costing/contracts' import type { BomCostingPreparedSku } from '@/server/repositories/bom-costing.repository' import type { MarketDetailSnapshot } from './market-detail-client' import { assertBomCostingResultQuality } from './bom-costing-result-quality' @@ -81,6 +84,45 @@ function buildPreparedSku( } } +function buildV2Analysis(skuIds: string[]): CompetitorAnalysisResultV2 { + const legacy = buildAnalysis(skuIds.map((externalSkuId) => ({ + externalSkuId, + name: externalSkuId, + }))) + return { + ...legacy, + schemaVersion: 'competitor-analysis/v2', + structureBlueprint: { + schemaVersion: 'structure-blueprint/v1', + knowledgeVersion: 'structure-priors/test', + productFamily: { code: 'TEST', label: '测试家具', confidence: 0.8 }, + mechanisms: [], + structureGroups: [{ + structureGroupKey: 'same-structure', + label: '同结构组', + skuIds, + componentSlots: [{ + componentKey: 'board-top', + role: 'FRAME', + category: 'BOARD', + partName: '顶板', + required: true, + quantityRule: '每套 1 块', + dimensionRule: '随 SKU 宽度变化', + materialScope: '主体材质', + evidenceFactIds: [], + confidence: 0.7, + reviewFlags: [], + }], + relations: [], + unresolvedQuestions: [], + }], + verificationRounds: [], + generatedAt: new Date().toISOString(), + }, + } +} + describe('BOM 核算结果质量保护', () => { it('来源 SKU 的尺寸、材质或是否含镜不同却生成相同 BOM 时拒绝保存', () => { const sourceSnapshot = buildSourceSnapshot([ @@ -190,4 +232,37 @@ describe('BOM 核算结果质量保护', () => { })], })).not.toThrow() }) + + it('v2 蓝图要求每个兄弟 SKU 都保留必要部件', () => { + const analysis = buildV2Analysis(['sku-small', 'sku-large']) + analysis.skus[1]!.bomLines = [] + + expect(() => assertBomCostingResultQuality({ + analysis, + sourceSnapshot: null, + preparedSkus: [ + buildPreparedSku('sku-small', null), + buildPreparedSku('sku-large', null), + ], + allowIncompleteCosting: true, + })).toThrow('sku-large缺少board-top') + }) + + it('v2 同结构组尺寸变大时拒绝板材面积反而大幅减少', () => { + const analysis = buildV2Analysis(['sku-small', 'sku-large']) + analysis.skus[0]!.dimensions = { lengthMm: 800, widthMm: 400, heightMm: 750 } + analysis.skus[1]!.dimensions = { lengthMm: 1_200, widthMm: 600, heightMm: 750 } + analysis.skus[1]!.bomLines[0]!.lengthMm = 300 + analysis.skus[1]!.bomLines[0]!.widthMm = 200 + + expect(() => assertBomCostingResultQuality({ + analysis, + sourceSnapshot: null, + preparedSkus: [ + buildPreparedSku('sku-small', null), + buildPreparedSku('sku-large', null), + ], + allowIncompleteCosting: true, + })).toThrow('尺寸更大但板材面积反而减少超过15%') + }) }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts index 9329612b..0fcc9c26 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts @@ -1,4 +1,4 @@ -import type { CompetitorAnalysisResultV1, CompetitorSkuV1 } from '@/lib/bom-costing/contracts' +import type { CompetitorAnalysisResult, CompetitorSkuV1 } from '@/lib/bom-costing/contracts' import type { BomCostingPreparedSku } from '@/server/repositories/bom-costing.repository' import type { MarketDetailSnapshot, @@ -144,7 +144,7 @@ function buildBomFingerprint(sku: CompetitorSkuV1) { } function assertDistinctSourceSkuStructures( - analysis: CompetitorAnalysisResultV1, + analysis: CompetitorAnalysisResult, source: MarketDetailSnapshot, ) { const analyzedById = new Map( @@ -161,6 +161,7 @@ function assertDistinctSourceSkuStructures( if (!left) continue const leftAnalysis = analyzedById.get(left.sku.externalSkuId) if (!leftAnalysis) continue + if (leftAnalysis.attributes.structureAnalysisStatus === 'PENDING_REVIEW') continue const leftFingerprint = buildBomFingerprint(leftAnalysis) for (let rightIndex = leftIndex + 1; rightIndex < sourceSkus.length; rightIndex += 1) { const right = sourceSkus[rightIndex] @@ -171,6 +172,7 @@ function assertDistinctSourceSkuStructures( ) if (differences.length === 0) continue const rightAnalysis = analyzedById.get(right.sku.externalSkuId) + if (rightAnalysis?.attributes.structureAnalysisStatus === 'PENDING_REVIEW') continue if (!rightAnalysis || leftFingerprint !== buildBomFingerprint(rightAnalysis)) { continue } @@ -217,8 +219,78 @@ function assertPreparedCostRange(skus: BomCostingPreparedSku[]) { } +function boardAreaSqm(sku: CompetitorSkuV1) { + return sku.bomLines + .filter((line) => line.category === 'BOARD') + .reduce((sum, line) => ( + sum + ((line.lengthMm ?? 0) * (line.widthMm ?? 0) * line.quantity) / 1_000_000 + ), 0) +} + +function horizontalSizeMm(sku: CompetitorSkuV1) { + const values = [sku.dimensions.lengthMm, sku.dimensions.widthMm] + .filter((value): value is number => typeof value === 'number' && value > 0) + return values.length > 0 ? Math.max(...values) : null +} + +function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult) { + if (analysis.schemaVersion !== 'competitor-analysis/v2') return + const analyzedById = new Map( + analysis.skus.map((sku) => [sku.externalSkuId, sku] as const), + ) + const blueprintSkuIds = analysis.structureBlueprint.structureGroups + .flatMap((group) => group.skuIds) + if ( + blueprintSkuIds.length !== new Set(blueprintSkuIds).size || + blueprintSkuIds.length !== analysis.skus.length || + analysis.skus.some((sku) => !blueprintSkuIds.includes(sku.externalSkuId)) + ) { + throw new Error('产品结构蓝图与真实 SKU 数量不一致,系统未接受该结果') + } + + const violations: string[] = [] + for (const group of analysis.structureBlueprint.structureGroups) { + const requiredKeys = group.componentSlots + .filter((slot) => slot.required) + .map((slot) => slot.componentKey) + const groupSkus = group.skuIds + .map((skuId) => analyzedById.get(skuId)) + .filter((sku): sku is CompetitorSkuV1 => Boolean(sku)) + for (const sku of groupSkus) { + const lineKeys = new Set(sku.bomLines.map((line) => line.externalLineId)) + const missing = requiredKeys.filter((key) => !lineKeys.has(key)) + if (missing.length > 0) { + violations.push(`${sku.externalSkuId}缺少${missing.slice(0, 3).join('、')}`) + } + } + const sized = groupSkus + .map((sku) => ({ sku, size: horizontalSizeMm(sku), area: boardAreaSqm(sku) })) + .filter((item): item is { sku: CompetitorSkuV1; size: number; area: number } => ( + item.size !== null && item.area > 0 + )) + .sort((left, right) => left.size - right.size) + for (let index = 1; index < sized.length; index += 1) { + const previous = sized[index - 1]! + const current = sized[index]! + if ( + current.size > previous.size * 1.05 && + current.area < previous.area * 0.85 + ) { + violations.push( + `${current.sku.externalSkuId}尺寸更大但板材面积反而减少超过15%`, + ) + } + } + } + if (violations.length > 0) { + throw new Error( + `结构蓝图一致性校验未通过:${violations.slice(0, 5).join(';')}`, + ) + } +} + export function assertBomCostingResultQuality(input: { - analysis: CompetitorAnalysisResultV1 + analysis: CompetitorAnalysisResult sourceSnapshot: MarketDetailSnapshot | null preparedSkus: BomCostingPreparedSku[] /** @@ -236,6 +308,7 @@ export function assertBomCostingResultQuality(input: { */ allowUnverifiedSkuStructures?: boolean }) { + assertStructureBlueprintConsistency(input.analysis) if (!input.allowIncompleteCosting) { assertPreparedCostRange(input.preparedSkus) } 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 b73dd439..56e5663d 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 @@ -343,18 +343,18 @@ describe('BomCostingScenarioService', () => { const saved = jest.mocked(bomCostingScenarioRepository.saveScenarioPair).mock.calls[0]![0] expect(saved.sameMaterial).toMatchObject({ - total: 200, + total: 100, unresolvedCount: 0, }) expect(saved.sameMaterial.lines[0]).toMatchObject({ - subtotal: 200, - priceSource: expect.stringContaining('基础资料库整板裁切'), - calculationRule: 'board-sheet-maxrects/v1', + subtotal: 100, + priceSource: expect.stringContaining('批量裁切'), + calculationRule: 'board-sheet-maxrects/v2-batch', rawSnapshot: { calculation: { priceSourceAudit: expect.objectContaining({ - sheetCount: 1, - totalSheetCost: 200, + selectedBatchSize: 8, + perUnitSheetCost: 100, utilization: expect.any(Number), thicknessEvidence: 'DIRECT', }), @@ -363,6 +363,69 @@ describe('BomCostingScenarioService', () => { }) }) + it('小板件主成本采用同款批量排版,单件整板成本只保留在区间上限', async () => { + const bomLines = [line({ + id: 'small-board', + category: 'BOARD', + partName: '小层板', + materialName: '橡胶木', + materialCode: 'RUBBERWOOD', + materialSpec: '明确标注板厚 18mm', + unit: '块', + quantity: 1, + lengthMm: 600, + widthMm: 600, + })] + mockBase({ bomLines }) + jest.mocked(bomCostingScenarioRepository.listSubstrateSheetCandidates).mockResolvedValue([{ + id: 'substrate-rubberwood-18', + code: 'RW-18', + name: '橡胶木指接板 18mm', + categoryName: '实木板', + systemName: '橡胶木', + environmentGrade: 'E0', + aliases: ['RW-18', '橡胶木', '橡胶木指接板'], + thicknessMm: 18, + sheetLengthMm: 2_440, + sheetWidthMm: 1_220, + fullSheetPrice: 200, + updatedAt: now, + }]) + jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([ + candidate({ + id: 'historical-area-price', + category: 'BOARD', + partName: '小层板', + materialName: '橡胶木', + unit: '㎡', + unitPrice: 50, + areaSqm: 0.36, + }), + ]) + const service = new BomCostingScenarioService() + jest.spyOn(service, 'getCaseDynamicCosting').mockResolvedValue({} as never) + + await service.calculate({ + caseId: 'case-1', + skuId: 'sku-1', + targetMaterialId: 'material-rubberwood', + }) + + const saved = jest.mocked(bomCostingScenarioRepository.saveScenarioPair).mock.calls[0]![0] + const board = saved.sameMaterial.lines[0]! + expect(board.subtotal).toBeLessThan(200) + expect(board.estimateHigh).toBeGreaterThanOrEqual(200) + expect(saved.sameMaterial.lossCost).toBeLessThan(saved.sameMaterial.categoryTotals.MATERIAL) + expect(board.rawSnapshot).toMatchObject({ + calculation: { + priceSourceAudit: expect.objectContaining({ + selectedBatchSize: 16, + singleUnit: expect.objectContaining({ perUnitSheetCost: 200 }), + }), + }, + }) + }) + it('板厚未识别时仍按目标材质整板价格中位数生成低可信裁切预估', async () => { const bomLines = [line({ id: 'wardrobe-board', @@ -434,14 +497,14 @@ describe('BomCostingScenarioService', () => { const saved = jest.mocked(bomCostingScenarioRepository.saveScenarioPair).mock.calls[0]![0] expect(saved.substitution).toMatchObject({ - total: 240, + total: 120, unresolvedCount: 0, estimateBasis: 'MASTER_DATA', sampleCount: 3, }) expect(saved.substitution.lines[0]).toMatchObject({ appliedMaterial: '橡胶木', - subtotal: 240, + subtotal: 120, status: 'PROVISIONAL', estimateBasis: 'MASTER_DATA', sampleCount: 3, @@ -550,13 +613,13 @@ describe('BomCostingScenarioService', () => { const saved = jest.mocked(bomCostingScenarioRepository.saveScenarioPair).mock.calls[0]![0] expect(saved.substitution).toMatchObject({ - total: 250, + total: 125, unresolvedCount: 0, estimateBasis: 'MASTER_DATA', sampleCount: 3, }) expect(saved.substitution.lines[0]).toMatchObject({ - subtotal: 250, + subtotal: 125, status: 'PROVISIONAL', sampleCount: 3, priceSource: expect.stringContaining('图片比例估算板厚约 19.5mm'), @@ -2004,11 +2067,21 @@ describe('BomCostingScenarioService', () => { sourceFingerprint: 'fingerprint-1', sourceCreatedAt: now, sourceSyncedAt: now, - calculationRule: 'reference-weighted-median-unit-price/v1', + calculationRule: 'board-sheet-maxrects/v2-batch', confidence: 0.82, status: 'PROVISIONAL', warning: '计价数量由尺寸推导', - rawSnapshot: [], + rawSnapshot: { + calculation: { + priceSourceAudit: { + selectedBatchSize: 16, + sheetCount: 2, + perUnitSheetCost: 25, + utilization: 0.9675, + singleUnit: { sheetCount: 1, perUnitSheetCost: 200 }, + }, + }, + }, }], }] as never) const service = new BomCostingScenarioService() @@ -2022,12 +2095,23 @@ describe('BomCostingScenarioService', () => { priceDelta: 52, syncRunId: 'run-success', syncStatus: 'SUCCEEDED', + boardCutting: [{ + sourceLineId: 'board', + materialName: '黑胡桃木', + rulesetVersion: 'board-sheet-maxrects/v2-batch', + selectedBatchSize: 16, + batchSheetCount: 2, + perUnitSheetCost: 25, + utilization: 0.9675, + singleUnitSheetCount: 1, + singleUnitSheetCost: 200, + }], }) expect(scenario?.lines[0]).toMatchObject({ sourceRowId: 'reference-row-1', sourceCreatedAt: now.toISOString(), sourceSyncedAt: now.toISOString(), - calculationRule: 'reference-weighted-median-unit-price/v1', + calculationRule: 'board-sheet-maxrects/v2-batch', }) }) 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 7fa6ede1..dac5a753 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 @@ -13,7 +13,7 @@ import { type DynamicBomScenarioResult, } from '@/lib/bom-costing/dynamic-bom-cost-domain' import { - calculateBoardSheetCuttingPlan, + calculateBoardBatchCuttingPlan, } from '@/lib/bom-costing/board-sheet-cutting' import { bomCostingScenarioRepository, @@ -532,14 +532,18 @@ function buildBoardSheetPriceQuotes(input: { const quotes: Record = {} for (const group of groups.values()) { const candidate = group[0]!.candidate - const plan = calculateBoardSheetCuttingPlan({ + const plan = calculateBoardBatchCuttingPlan({ substrateId: candidate.id, materialName: candidate.systemName || candidate.name, sheetLengthMm: candidate.sheetLengthMm, sheetWidthMm: candidate.sheetWidthMm, fullSheetPrice: candidate.fullSheetPrice, kerfMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_KERF_MM', 4), - edgeMarginMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_EDGE_MARGIN_MM', 10), + // Five millimetres on each edge keeps a real trimming allowance while + // still allowing two 600 mm parts on a common 1220 mm sheet with kerf. + // The former 10 mm-per-edge default falsely doubled sheet counts for + // this very common layout and surfaced the trim as excessive loss. + edgeMarginMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_EDGE_MARGIN_MM', 5), }, group.map(({ line, lengthMm, widthMm, quantity }) => ({ lineId: line.id, lengthMm, @@ -547,7 +551,7 @@ function buildBoardSheetPriceQuotes(input: { quantity, }))) if (!plan) continue - for (const allocation of plan.allocations) { + for (const allocation of plan.selectedBatch.allocations) { const assignedLine = group.find((entry) => entry.line.id === allocation.lineId) if (!assignedLine) continue const { selection } = assignedLine @@ -559,17 +563,23 @@ function buildBoardSheetPriceQuotes(input: { candidate.fullSheetPrice, 'MASTER_DATA', ) - const allocatedSheetRatio = allocation.allocatedCost / candidate.fullSheetPrice + const selectedSheetRatio = allocation.allocatedCost / candidate.fullSheetPrice + const singleUnitAllocation = plan.singleUnit.allocations.find( + (entry) => entry.lineId === allocation.lineId, + ) + const singleUnitSheetRatio = singleUnitAllocation + ? singleUnitAllocation.allocatedCost / candidate.fullSheetPrice + : selectedSheetRatio quotes[allocation.lineId] = { totalAmount: allocation.allocatedCost, priceUnit: '整板', sourceType: 'MASTER_DATA', sourceId: candidate.id, sourceLabel: thicknessEvidence === 'VISUAL_ESTIMATE' - ? `基础资料库整板裁切预估(图片比例估算板厚约 ${selection.observedThicknessMm}mm,匹配 ${candidate.thicknessMm}mm 整板;${candidate.sheetLengthMm}×${candidate.sheetWidthMm}mm,${plan.sheetCount} 张,利用率 ${Math.round(plan.utilization * 100)}%)` + ? `基础资料库同款批量裁切预估(图片比例估算板厚约 ${selection.observedThicknessMm}mm,匹配 ${candidate.thicknessMm}mm 整板;按 ${plan.selectedBatchSize} 件连续生产,单位利用率 ${Math.round(plan.selectedBatch.utilization * 100)}%)` : thicknessEvidence === 'NONE' - ? `基础资料库同材质整板中位规格裁切预估(板厚无可核验依据,${candidate.sheetLengthMm}×${candidate.sheetWidthMm}mm,${plan.sheetCount} 张,利用率 ${Math.round(plan.utilization * 100)}%)` - : `基础资料库整板裁切(明确板厚 ${selection.observedThicknessMm}mm,匹配 ${candidate.thicknessMm}mm 整板;${candidate.sheetLengthMm}×${candidate.sheetWidthMm}mm,${plan.sheetCount} 张,利用率 ${Math.round(plan.utilization * 100)}%)`, + ? `基础资料库同材质整板中位规格批量裁切预估(板厚无可核验依据;按 ${plan.selectedBatchSize} 件连续生产,单位利用率 ${Math.round(plan.selectedBatch.utilization * 100)}%)` + : `基础资料库同款批量裁切(明确板厚 ${selection.observedThicknessMm}mm,匹配 ${candidate.thicknessMm}mm 整板;按 ${plan.selectedBatchSize} 件连续生产,单位利用率 ${Math.round(plan.selectedBatch.utilization * 100)}%)`, materialCode: candidate.code, materialName: candidate.systemName || candidate.name, confidence: thicknessEvidence === 'DIRECT' @@ -579,22 +589,26 @@ function buildBoardSheetPriceQuotes(input: { : 0.48, approximate: true, coefficientIncluded: true, - lossFactor: plan.usedAreaSqm > 0 - ? Math.max(1, plan.purchasedAreaSqm / plan.usedAreaSqm) + lossFactor: plan.selectedBatch.perUnitUsedAreaSqm > 0 + ? Math.max( + 1, + plan.selectedBatch.perUnitPurchasedAreaSqm / + plan.selectedBatch.perUnitUsedAreaSqm, + ) : 1, sourceFingerprint: `${candidate.id}:${candidate.updatedAt.toISOString()}`, sourceSyncedAt: candidate.updatedAt.toISOString(), calculationRule: plan.rulesetVersion, - estimateRange: uncertainThickness - ? { - min: sheetPriceRange.min * allocatedSheetRatio, - max: sheetPriceRange.max * allocatedSheetRatio, - } - : priceEstimateRange( - [allocation.allocatedCost], - allocation.allocatedCost, - 'MASTER_DATA', - ), + estimateRange: { + min: Math.max(0, sheetPriceRange.min * selectedSheetRatio), + // The upper bound deliberately retains the single-piece, full-sheet + // purchase case. Reusable offcuts lower the production estimate but + // are never silently deleted from the auditable range. + max: Math.max( + allocation.allocatedCost, + sheetPriceRange.max * singleUnitSheetRatio, + ), + }, estimateBasis: 'MASTER_DATA', sampleCount: uncertainThickness ? compatibleCandidates.length : 1, sourceAudit: { @@ -619,11 +633,16 @@ function buildBoardSheetPriceQuotes(input: { sheetLengthMm: candidate.sheetLengthMm, sheetWidthMm: candidate.sheetWidthMm, fullSheetPrice: candidate.fullSheetPrice, - sheetCount: plan.sheetCount, - totalSheetCost: plan.totalSheetCost, - usedAreaSqm: plan.usedAreaSqm, - purchasedAreaSqm: plan.purchasedAreaSqm, - utilization: plan.utilization, + selectedBatchSize: plan.selectedBatchSize, + stabilized: plan.stabilized, + sheetCount: plan.selectedBatch.sheetCount, + totalSheetCost: plan.selectedBatch.totalSheetCost, + perUnitSheetCost: plan.selectedBatch.perUnitSheetCost, + usedAreaSqm: plan.selectedBatch.perUnitUsedAreaSqm, + purchasedAreaSqm: plan.selectedBatch.perUnitPurchasedAreaSqm, + utilization: plan.selectedBatch.utilization, + singleUnit: plan.singleUnit, + testedBatches: plan.testedBatches, kerfMm: plan.kerfMm, edgeMarginMm: plan.edgeMarginMm, allocation, @@ -1807,6 +1826,65 @@ function readMaterialAliases(value: unknown) { ))) } +function readBoardCuttingSummary( + lines: BomCostingScenarioRecord['lines'], +) { + const summaries = new Map() + for (const line of lines) { + if (line.category !== 'BOARD' || line.calculationRule !== 'board-sheet-maxrects/v2-batch') { + continue + } + if (typeof line.rawSnapshot !== 'object' || line.rawSnapshot === null || Array.isArray(line.rawSnapshot)) { + continue + } + const calculation = (line.rawSnapshot as Record).calculation + if (typeof calculation !== 'object' || calculation === null || Array.isArray(calculation)) continue + const audit = (calculation as Record).priceSourceAudit + if (typeof audit !== 'object' || audit === null || Array.isArray(audit)) continue + const source = audit as Record + const singleUnit = typeof source.singleUnit === 'object' && source.singleUnit !== null && !Array.isArray(source.singleUnit) + ? source.singleUnit as Record + : null + const selectedBatchSize = Number(source.selectedBatchSize) + const batchSheetCount = Number(source.sheetCount) + const perUnitSheetCost = Number(source.perUnitSheetCost) + const utilization = Number(source.utilization) + const singleUnitSheetCount = Number(singleUnit?.sheetCount) + const singleUnitSheetCost = Number(singleUnit?.perUnitSheetCost) + if (![ + selectedBatchSize, + batchSheetCount, + perUnitSheetCost, + utilization, + singleUnitSheetCount, + singleUnitSheetCost, + ].every(Number.isFinite)) continue + const key = `${line.sourceFingerprint}:${line.materialSpec}:${selectedBatchSize}` + summaries.set(key, { + sourceLineId: line.sourceLineId, + materialName: line.appliedMaterial, + rulesetVersion: line.calculationRule, + selectedBatchSize, + batchSheetCount, + perUnitSheetCost, + utilization, + singleUnitSheetCount, + singleUnitSheetCost, + }) + } + return Array.from(summaries.values()) +} + function serializeScenario(record: BomCostingScenarioRecord) { const totalCost = record.totalCost === null ? null : Number(record.totalCost) const storedCompetitorPrice = record.competitorPrice === null @@ -1903,6 +1981,7 @@ function serializeScenario(record: BomCostingScenarioRecord) { syncCompletedAt: record.syncRun?.completedAt?.toISOString() ?? null, rulesetVersion: record.rulesetVersion, createdAt: record.createdAt.toISOString(), + boardCutting: readBoardCuttingSummary(record.lines), lines: record.lines.map((line) => ({ id: line.id, sourceLineId: line.sourceLineId, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-source-archive.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-source-archive.service.ts index d44df6f9..6f8fff9d 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-source-archive.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-source-archive.service.ts @@ -541,12 +541,35 @@ export class BomCostingSourceArchiveService { }) const stitchPlans = [...explicitPlans, ...pixelBoundaryPlans] .sort((left, right) => left.sourceIndexes[0]! - right.sourceIndexes[0]!) + const remainingStitchUses = new Map() + for (const plan of stitchPlans) { + for (const url of plan.sourceUrls) { + remainingStitchUses.set(url, (remainingStitchUses.get(url) ?? 0) + 1) + } + } + for (const url of downloadedDetailImages.keys()) { + if (!remainingStitchUses.has(url)) downloadedDetailImages.delete(url) + } + const releasePlanBuffers = (plan: (typeof stitchPlans)[number]) => { + for (const url of plan.sourceUrls) { + const next = (remainingStitchUses.get(url) ?? 1) - 1 + if (next <= 0) { + remainingStitchUses.delete(url) + downloadedDetailImages.delete(url) + } else { + remainingStitchUses.set(url, next) + } + } + } const detailImageComposites: MarketDetailImageComposite[] = [] for (const plan of stitchPlans) { const buffers = plan.sourceUrls.map((url) => downloadedDetailImages.get(url), ) - if (buffers.some((buffer) => !buffer)) continue + if (buffers.some((buffer) => !buffer)) { + releasePlanBuffers(plan) + continue + } try { const stitched = await stitchBomCostingDetailImages( plan, @@ -581,8 +604,11 @@ export class BomCostingSourceArchiveService { errorName: error instanceof Error ? error.name : 'UnknownError', errorMessage: error instanceof Error ? error.message : '详情图合成失败', }) + } finally { + releasePlanBuffers(plan) } } + downloadedDetailImages.clear() const hasFailedDetailAsset = latest.assets.some( (asset) => asset.kind === BomCostingSourceAssetKind.DETAIL && 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 new file mode 100644 index 00000000..6be29af8 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts @@ -0,0 +1,390 @@ +/** @jest-environment node */ + +import { BOM_COSTING_FACT_SCHEMA_VERSION } from '@/lib/bom-costing/fact-contracts' +import type { MarketDetailSnapshot } from './market-detail-client' +import type { BomCostingMultimodalProviderConfig } from './bom-costing-multimodal-gateway' +import { + buildStructuredBomCostingAnalysis, + type BomCostingStructureCheckpoint, + type BomCostingStructureStageProgress, +} from './bom-costing-structure-blueprint' + +function streamingResponse(content: string) { + return new Response( + `data: ${JSON.stringify({ + id: 'call-structure-v6', + model: 'test-vision', + choices: [{ delta: { content }, finish_reason: null }], + })}\n\ndata: ${JSON.stringify({ + id: 'call-structure-v6', + model: 'test-vision', + choices: [{ delta: {}, finish_reason: 'stop' }], + })}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ) +} + +function snapshotWithSkus(count: number): MarketDetailSnapshot { + return { + itemId: 'wardrobe-1', + channel: 'taobao', + title: '现代简约推拉门衣柜', + shopName: '测试店铺', + detailUrl: 'https://item.taobao.com/item.htm?id=wardrobe-1', + mainImages: ['https://img.example.com/main.jpg'], + detailImages: [], + skuImages: [], + requestId: 'request-1', + skus: Array.from({ length: count }, (_, index) => ({ + externalSkuId: `sku-${index + 1}`, + name: `${140 + index * 10}cm 胡桃色推拉门衣柜`, + size: `${140 + index * 10}cm`, + imageUrl: null, + attributes: { 尺寸: `${140 + index * 10}cm`, 颜色: '胡桃色' }, + priceRaw: '8352.00', + originalPriceRaw: null, + quantity: null, + })), + } +} + +const config: BomCostingMultimodalProviderConfig = { + baseUrl: 'https://ai.example.com/v1', + apiKey: 'test-key', + model: 'test-vision', +} + +const componentSlots = [ + { + componentKey: 'enclosure-side', + role: 'ENCLOSURE', + category: 'BOARD', + partName: '左右侧板', + required: true, + quantityRule: '每套两块', + dimensionRule: '按柜体高和深度计算', + materialScope: '柜体主体材质', + evidenceFactIds: [], + confidence: 0.7, + reviewFlags: [], + }, + { + componentKey: 'processing-main', + role: 'PROCESSING', + category: 'PROCESSING', + partName: '主体加工', + required: true, + quantityRule: '每套一套', + dimensionRule: '按结构复杂度', + materialScope: '', + evidenceFactIds: [], + confidence: 0.6, + reviewFlags: [], + }, + { + componentKey: 'labor-assembly', + role: 'LABOR', + category: 'LABOR', + partName: '生产装配人工', + required: true, + quantityRule: '每套一套', + dimensionRule: '不适用', + materialScope: '', + evidenceFactIds: [], + confidence: 0.6, + reviewFlags: [], + }, + { + componentKey: 'packaging-set', + role: 'PACKAGING', + category: 'PACKAGING', + partName: '整套运输包材', + required: true, + quantityRule: '每套一套', + dimensionRule: '按柜体外形计算', + materialScope: '纸箱与缓冲材料', + evidenceFactIds: [], + confidence: 0.6, + reviewFlags: [], + }, +] as const + +describe('BOM v6 结构蓝图与 SKU 展开', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('一个结构组分批展开真实 SKU,复用稳定部件编号且剥离模型价格', async () => { + const snapshot = snapshotWithSkus(7) + const stages: BomCostingStructureStageProgress[] = [] + const checkpoints: BomCostingStructureCheckpoint[] = [] + const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { + messages: Array<{ content: string }> + } + const userPayload = JSON.parse(body.messages[1]?.content ?? '{}') as { + outputContractVersion?: string + skuTruth?: Array<{ externalSkuId: string; name: string }> + } + if (userPayload.outputContractVersion === 'structure-blueprint/draft-v1') { + return streamingResponse(JSON.stringify({ + productFamily: { code: 'WARDROBE', label: '衣柜类', confidence: 0.8 }, + mechanisms: [], + structureGroups: [{ + structureGroupKey: 'wardrobe-sliding', + label: '推拉门衣柜', + skuIds: snapshot.skus.map((sku) => sku.externalSkuId), + componentSlots, + relations: [], + unresolvedQuestions: [], + }], + })) + } + const skuTruth = userPayload.skuTruth ?? [] + return streamingResponse(JSON.stringify({ + skus: skuTruth.map((sku) => { + const widthMm = Number(sku.name.match(/(\d+)cm/)?.[1] ?? 140) * 10 + const inferredEvidence = [{ + basis: 'STRUCTURAL_INFERENCE', + note: '依据柜体高和深度推导', + factIds: [], + derivation: '侧板高度=整机高度;侧板宽度=柜体深度', + reviewReason: '未发现侧板单独尺寸标注', + confidence: 0.5, + }] + return { + externalSkuId: sku.externalSkuId, + dimensions: { lengthMm: widthMm, widthMm: 620, heightMm: 2200 }, + bomLines: [ + { + externalLineId: 'enclosure-side', category: 'BOARD', partName: '左右侧板', + materialName: '橡木', lengthMm: 2200, widthMm: 620, heightMm: 19.95, + quantity: 2, unit: '块', estimatedUnitPrice: 9_999, + suggestedLossFactor: 1.8, evidence: inferredEvidence, confidence: 0.5, + }, + { + externalLineId: 'processing-main', category: 'PROCESSING', partName: '主体加工', + quantity: 1, unit: '套', estimatedUnitPrice: 888, + evidence: inferredEvidence, confidence: 0.5, + }, + { + externalLineId: 'labor-assembly', category: 'LABOR', partName: '生产装配人工', + quantity: 1, unit: '套', estimatedUnitPrice: 777, + evidence: inferredEvidence, confidence: 0.5, + }, + { + externalLineId: 'packaging-set', category: 'PACKAGING', partName: '整套运输包材', + materialName: '瓦楞纸箱', quantity: 1, unit: '套', estimatedUnitPrice: 666, + evidence: inferredEvidence, confidence: 0.5, + }, + ], + } + }), + })) + }) + + const output = await buildStructuredBomCostingAnalysis({ + config, + sourceUrl: snapshot.detailUrl, + snapshot, + factSnapshot: { + schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, + imageFacts: [], + failedImages: [], + extractedAt: new Date().toISOString(), + }, + onStage: async (stage) => { stages.push(stage) }, + onCheckpoint: async (checkpoint) => { checkpoints.push(checkpoint) }, + }) + + expect(fetchSpy).toHaveBeenCalledTimes(3) + expect(output.result.schemaVersion).toBe('competitor-analysis/v2') + expect(output.result.skus).toHaveLength(7) + expect(output.result.skus.map((sku) => sku.externalSkuId)).toEqual( + snapshot.skus.map((sku) => sku.externalSkuId), + ) + expect(output.result.skus.every((sku) => ( + sku.bomLines.map((line) => line.externalLineId).join('|') === + 'enclosure-side|processing-main|labor-assembly|packaging-set' + ))).toBe(true) + expect(output.result.skus.flatMap((sku) => sku.bomLines).every((line) => ( + line.estimatedUnitPrice === undefined && line.suggestedLossFactor === undefined + ))).toBe(true) + expect(output.trace.instantiationCallCount).toBe(2) + expect(stages.map((stage) => stage.stage)).toEqual(expect.arrayContaining([ + 'BLUEPRINT', + 'INSTANTIATE', + ])) + expect(checkpoints.at(-1)).toMatchObject({ + completedGroupKeys: ['wardrobe-sliding'], + skus: expect.arrayContaining([ + expect.objectContaining({ externalSkuId: 'sku-7' }), + ]), + }) + }) + + it('单个结构组展开失败时保留低可信必要部件,不让整条任务失败', async () => { + const snapshot = snapshotWithSkus(2) + let callIndex = 0 + jest.spyOn(global, 'fetch').mockImplementation(async () => { + callIndex += 1 + if (callIndex === 1) { + return streamingResponse(JSON.stringify({ + productFamily: { code: 'WARDROBE', label: '衣柜类', confidence: 0.8 }, + mechanisms: [], + structureGroups: [{ + structureGroupKey: 'wardrobe-sliding', + label: '推拉门衣柜', + skuIds: snapshot.skus.map((sku) => sku.externalSkuId), + componentSlots, + relations: [], + unresolvedQuestions: [], + }], + })) + } + return streamingResponse(JSON.stringify({ skus: [] })) + }) + + const output = await buildStructuredBomCostingAnalysis({ + config, + sourceUrl: snapshot.detailUrl, + snapshot, + factSnapshot: { + schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, + imageFacts: [], + failedImages: [], + extractedAt: new Date().toISOString(), + }, + }) + + expect(output.result.skus).toHaveLength(2) + expect(output.trace.fallbackSkuIds).toEqual(['sku-1', 'sku-2']) + expect(output.result.skus.every((sku) => ( + sku.attributes.structureAnalysisStatus === 'PENDING_REVIEW' && + sku.bomLines.length === componentSlots.length && + sku.bomLines.every((line) => line.confidence <= 0.35) + ))).toBe(true) + }) + + it('疑点验证每轮只发送相关图片,并在连续两轮没有进展后停止', async () => { + const snapshot = snapshotWithSkus(1) + const verificationImageCounts: number[] = [] + const factSnapshot = { + schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, + imageFacts: Array.from({ length: 6 }, (_, index) => ({ + imageId: `image-${index + 1}`, + sourceImageUrl: `https://img.example.com/detail-${index + 1}.jpg`, + kind: 'DETAIL' as const, + label: `详情图 ${index + 1}`, + externalSkuIds: [], + observedTexts: [], + facts: [{ + factId: `fact-${index + 1}`, + kind: 'DIMENSION' as const, + subject: '柜体侧板厚度', + value: '板厚待确认', + relatedSkuIds: [], + basis: 'VISUAL_ESTIMATE' as const, + confidence: 0.3, + }], + })), + failedImages: [], + extractedAt: new Date().toISOString(), + } + const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { + messages: Array<{ + content: string | Array< + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } } + > + }> + } + const content = body.messages[1]?.content ?? '' + const userText = typeof content === 'string' + ? content + : content.find((item) => item.type === 'text')?.text ?? '{}' + const userPayload = JSON.parse(userText) as { + outputContractVersion?: string + skuTruth?: Array<{ externalSkuId: string }> + } + if (userPayload.outputContractVersion === 'structure-blueprint/draft-v1') { + return streamingResponse(JSON.stringify({ + productFamily: { code: 'WARDROBE', label: '衣柜类', confidence: 0.8 }, + mechanisms: [], + structureGroups: [{ + structureGroupKey: 'wardrobe-sliding', + label: '推拉门衣柜', + skuIds: ['sku-1'], + componentSlots, + relations: [], + unresolvedQuestions: [{ + questionId: 'q-thickness', + kind: 'THICKNESS', + question: '侧板厚度是多少?', + affectedComponentKeys: ['enclosure-side'], + evidenceFactIds: ['fact-1'], + confidence: 0.3, + status: 'OPEN', + }], + }], + })) + } + if (userPayload.outputContractVersion === 'structure-verification/v1') { + verificationImageCounts.push( + Array.isArray(content) + ? content.filter((item) => item.type === 'image_url').length + : 0, + ) + return streamingResponse(JSON.stringify({ + questionResults: [{ + questionId: 'q-thickness', + status: 'UNRESOLVED', + finding: '', + evidenceFactIds: [], + confidence: 0.3, + }], + componentUpdates: [], + })) + } + return streamingResponse(JSON.stringify({ + skus: (userPayload.skuTruth ?? []).map((sku) => ({ + externalSkuId: sku.externalSkuId, + dimensions: { lengthMm: 1_400, widthMm: 620, heightMm: 2_200 }, + bomLines: componentSlots.map((slot) => ({ + externalLineId: slot.componentKey, + category: slot.category, + partName: slot.partName, + materialName: slot.category === 'BOARD' ? '橡木' : '', + lengthMm: slot.category === 'BOARD' ? 2_200 : undefined, + widthMm: slot.category === 'BOARD' ? 620 : undefined, + heightMm: slot.category === 'BOARD' ? 18 : undefined, + quantity: slot.componentKey === 'enclosure-side' ? 2 : 1, + unit: slot.category === 'BOARD' ? '块' : '套', + evidence: [{ + basis: 'STRUCTURAL_INFERENCE', + factIds: ['fact-1'], + derivation: slot.dimensionRule, + reviewReason: '板厚未解决', + confidence: 0.3, + }], + confidence: 0.3, + })), + })), + })) + }) + + const output = await buildStructuredBomCostingAnalysis({ + config, + sourceUrl: snapshot.detailUrl, + snapshot, + factSnapshot, + }) + + expect(fetchSpy).toHaveBeenCalledTimes(4) + expect(verificationImageCounts).toEqual([4, 4]) + expect(output.trace.verificationRoundCount).toBe(2) + expect(output.result.structureBlueprint.structureGroups[0]?.unresolvedQuestions[0]?.status) + .toBe('REVIEW_REQUIRED') + }) +}) 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 new file mode 100644 index 00000000..44d28f4e --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts @@ -0,0 +1,933 @@ +import 'server-only' + +import { z } from 'zod' +import { + BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + BOM_COSTING_STRUCTURE_BLUEPRINT_SCHEMA_VERSION, + bomCostingStructureBlueprintV1Schema, + bomCostingStructureComponentSlotSchema, + bomCostingStructureQuestionSchema, + competitorAnalysisResultV2Schema, + competitorBomLineSchema, + competitorSkuSchema, + type BomCostingStructureBlueprintV1, + type CompetitorAnalysisResultV2, +} from '@/lib/bom-costing/contracts' +import type { BomCostingSourceFactSnapshot } from '@/lib/bom-costing/fact-contracts' +import type { MarketDetailSnapshot, MarketSkuSnapshot } from './market-detail-client' +import { + buildBomCostingStructureKnowledge, + buildDeterministicStructureBlueprint, + BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION, +} from './bom-costing-structure-knowledge' +import { + callBomCostingMultimodalModel, + parseBomCostingJsonObjects, + readBomCostingBoundedInteger, + type BomCostingMultimodalCallResult, + type BomCostingMultimodalProviderConfig, + type BomCostingMultimodalUserContent, +} from './bom-costing-multimodal-gateway' + +const MAX_BLUEPRINT_CONTRACT_ATTEMPTS = 2 +const MAX_VERIFICATION_ROUNDS_PER_GROUP = 3 +const MAX_QUESTIONS_PER_VERIFICATION_ROUND = 8 +const MAX_IMAGES_PER_VERIFICATION_ROUND = 4 +const MAX_SKUS_PER_INSTANTIATION_CALL = 6 + +const BLUEPRINT_SYSTEM_PROMPT = `你是家具产品结构工程师。根据系统保存的商品事实、真实 SKU 和版本化结构知识,先建立产品级结构蓝图,不输出价格,不输出每个 SKU 的最终 BOM。 + +硬规则: +1. 只输出一个 JSON 对象,不要 Markdown 或解释。 +2. skuIds 必须完整覆盖 skuTruth,每个真实 SKU 出现且只出现一次;不得组合、拆分、补造 SKU。 +3. structureGroupKey 表示共同装配拓扑,允许同组 SKU 尺寸不同;材质、门数、机构、是否含镜或套餐组成不同且改变部件时必须分组。 +4. componentKey 在同一结构组内唯一,表示兄弟 SKU 共享的部件角色;尺寸变化不能令核心 componentKey 消失。 +5. 当前商品直接事实高于结构知识。结构知识只是检查清单;缺少直接证据时 confidence 不得高于 0.55,并产生 unresolvedQuestions。 +6. 明确尺寸或板厚只绑定其原始适用部件;没有“柜体通用”等明确范围时不得扩散到其他板件。 +7. 每个结构组至少包含主体材料、PROCESSING、LABOR 和 PACKAGING 的必要槽位。 +8. quantityRule 和 dimensionRule 必须写成可执行的中文推导规则,不得填写价格或损耗。` + +const VERIFICATION_SYSTEM_PROMPT = `你是家具结构证据复核员。当前只核对给定问题和图片,不重做整个产品,不输出价格。 + +只输出一个 JSON 对象。每个问题必须给出 RESOLVED、IMPROVED 或 UNRESOLVED;只能引用输入中存在的 factId。图片可以补充视觉判断,但不能把目测写成明确测量。明确尺寸只绑定被测部件。没有新证据时必须返回 UNRESOLVED。` + +const INSTANTIATION_SYSTEM_PROMPT = `你是家具 BOM 结构工程师。根据已经确认的结构蓝图,为输入中的每个真实 SKU 独立展开 BOM,不输出价格和损耗。 + +硬规则: +1. 只输出一个 JSON 对象,不要 Markdown 或解释。 +2. externalSkuId 必须完整覆盖本批 skuTruth,每个 SKU 一次;不得组合或补造 SKU。 +3. 每个 required componentSlot 都必须生成一条 BOM,externalLineId 必须等于 componentKey。同组兄弟 SKU 使用相同核心 componentKey。 +4. 每条 SKU 的尺寸、数量、材质和套餐组成独立计算。颜色不改变结构时不能让基础部件消失。 +5. BOARD 的 lengthMm/widthMm 是单块板件尺寸,heightMm 只能是板厚。明确板厚只应用于证据指定部件;目测厚度 confidence 不高于 0.45。 +6. 由整机尺寸推导板件时,evidence.basis 使用 STRUCTURAL_INFERENCE,factIds 引用支持事实,derivation 写清公式或装配关系,reviewReason 写明未知项。 +7. 公司资料库是后续唯一正式价格来源;不得输出价格、售价倒推或损耗系数。` + +const blueprintDraftSchema = z.object({ + productFamily: z.object({ + code: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(240), + confidence: z.number().finite().min(0).max(1), + }), + mechanisms: z.array(z.object({ + code: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(240), + evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), + confidence: z.number().finite().min(0).max(1), + })).max(100).default([]), + structureGroups: z.array(z.object({ + structureGroupKey: z.string().trim().min(1).max(160), + label: z.string().trim().min(1).max(240), + skuIds: z.array(z.string().trim().min(1).max(160)).min(1).max(600), + componentSlots: z.array(bomCostingStructureComponentSlotSchema).min(1).max(500), + relations: z.array(z.object({ + relationType: z.enum([ + 'CONTAINS', 'SUPPORTS', 'PARTITIONS', 'COVERS', 'CONNECTS', + 'MOVES_WITH', 'ALIGNS_WITH', + ]), + fromComponentKey: z.string().trim().min(1).max(120), + toComponentKey: z.string().trim().min(1).max(120), + rule: z.string().trim().min(1).max(1_000), + evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), + confidence: z.number().finite().min(0).max(1), + })).max(1_000).default([]), + unresolvedQuestions: z.array(bomCostingStructureQuestionSchema).max(100).default([]), + })).min(1).max(200), +}) + +const verificationPayloadSchema = z.object({ + questionResults: z.array(z.object({ + questionId: z.string().trim().min(1).max(120), + status: z.enum(['RESOLVED', 'IMPROVED', 'UNRESOLVED']), + finding: z.string().trim().max(2_000).default(''), + evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), + confidence: z.number().finite().min(0).max(1), + })).max(100).default([]), + componentUpdates: z.array(z.object({ + componentKey: z.string().trim().min(1).max(120), + quantityRule: z.string().trim().max(1_000).optional(), + dimensionRule: z.string().trim().max(2_000).optional(), + materialScope: z.string().trim().max(1_000).optional(), + evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), + confidence: z.number().finite().min(0).max(1), + reviewFlag: z.string().trim().max(500).optional(), + })).max(100).default([]), +}) + +const instantiatedSkuSchema = z.object({ + externalSkuId: z.string().trim().min(1).max(160), + dimensions: z.object({ + lengthMm: z.number().finite().positive().optional(), + widthMm: z.number().finite().positive().optional(), + heightMm: z.number().finite().positive().optional(), + }).default({}), + weightKg: z.number().finite().positive().optional(), + packaging: z.object({ + lengthMm: z.number().finite().positive().optional(), + widthMm: z.number().finite().positive().optional(), + heightMm: z.number().finite().positive().optional(), + weightKg: z.number().finite().positive().optional(), + }).optional(), + bomLines: z.array(competitorBomLineSchema).min(1).max(500), +}) + +const instantiationPayloadSchema = z.object({ + skus: z.array(instantiatedSkuSchema).min(1).max(MAX_SKUS_PER_INSTANTIATION_CALL), +}) + +function removeModelPricing( + payload: z.infer, +): z.infer { + return { + skus: payload.skus.map((sku) => ({ + ...sku, + bomLines: sku.bomLines.map((line) => { + const { + estimatedUnitPrice: _estimatedUnitPrice, + suggestedLossFactor: _suggestedLossFactor, + ...structureLine + } = line + return structureLine + }), + })), + } +} + +const BOM_COSTING_STRUCTURE_CHECKPOINT_KIND = + 'BOM_COSTING_STRUCTURE_CHECKPOINT' as const +const BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION = 1 as const + +const bomCostingStructureCheckpointSchema = z.object({ + kind: z.literal(BOM_COSTING_STRUCTURE_CHECKPOINT_KIND), + version: z.literal(BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION), + sourceSkuIds: z.array(z.string().trim().min(1).max(160)).min(1).max(600), + blueprint: bomCostingStructureBlueprintV1Schema, + completedGroupKeys: z.array(z.string().trim().min(1).max(160)).max(200), + skus: z.array(competitorSkuSchema).max(600), +}) + +export type BomCostingStructureCheckpoint = z.infer< + typeof bomCostingStructureCheckpointSchema +> + +export function readBomCostingStructureCheckpoint(value: unknown) { + if ( + typeof value !== 'object' || + value === null || + (value as { kind?: unknown }).kind !== BOM_COSTING_STRUCTURE_CHECKPOINT_KIND + ) { + return null + } + return bomCostingStructureCheckpointSchema.parse(value) +} + +export interface BomCostingStructureStageProgress { + stage: 'BLUEPRINT' | 'VERIFY' | 'INSTANTIATE' | 'RECONCILE' + structureGroupKey?: string + round?: number + message: string + checkpoint?: BomCostingStructureCheckpoint +} + +export interface BomCostingStructureTrace { + model: string + callIds: string[] + durationMs: number + promptTokens: number | null + completionTokens: number | null + blueprintFallback: boolean + verificationRoundCount: number + instantiationCallCount: number + fallbackSkuIds: string[] +} + +function addNullable(left: number | null, right: number | null) { + if (left === null && right === null) return null + return (left ?? 0) + (right ?? 0) +} + +function classifyStructureFailure(error: unknown) { + const name = error instanceof Error ? error.name : 'UnknownError' + const message = error instanceof Error ? error.message : String(error) + const cause = error instanceof Error && typeof error.cause === 'object' && error.cause !== null + ? error.cause as { code?: unknown } + : null + const code = typeof cause?.code === 'string' ? cause.code : '' + if (/timeout|timed out|超时/i.test(`${name} ${message} ${code}`)) return 'TIMEOUT' + if (/ECONN|EAI_AGAIN|UND_ERR|socket|网络|无法连接/i.test(`${message} ${code}`)) return 'TRANSPORT' + if (/Zod|JSON|合同|唯一且符合合同|格式/i.test(`${name} ${message}`)) return 'STRUCTURED_OUTPUT' + return 'BUSINESS_VALIDATION' +} + +function parseSingle(content: string, schema: z.ZodType) { + const candidates = parseBomCostingJsonObjects(content) + const parsed = candidates.flatMap((candidate) => { + const result = schema.safeParse(candidate) + return result.success ? [result.data] : [] + }) + if (parsed.length !== 1) throw new Error('模型没有返回唯一且符合合同的 JSON 结果') + return parsed[0]! +} + +function serializeSku(sku: MarketSkuSnapshot) { + return { + externalSkuId: sku.externalSkuId, + name: sku.name, + size: sku.size, + attributes: sku.attributes, + imageUrl: sku.imageUrl, + } +} + +function compactFacts(snapshot: BomCostingSourceFactSnapshot, maximum = 320) { + return snapshot.imageFacts + .flatMap((image) => image.facts.map((fact) => ({ + factId: fact.factId, + imageId: image.imageId, + imageKind: image.kind, + relatedSkuIds: Array.from(new Set([...image.externalSkuIds, ...fact.relatedSkuIds])), + kind: fact.kind, + subject: fact.subject, + value: fact.value, + numericValue: fact.numericValue, + unit: fact.unit, + basis: fact.basis, + confidence: fact.confidence, + note: fact.note, + }))) + .sort((left, right) => right.confidence - left.confidence) + .slice(0, maximum) +} + +function assertBlueprintCoverage( + blueprint: BomCostingStructureBlueprintV1, + snapshot: MarketDetailSnapshot, +) { + const expected = snapshot.skus.map((sku) => sku.externalSkuId).sort() + const actual = blueprint.structureGroups.flatMap((group) => group.skuIds).sort() + if (actual.length !== new Set(actual).size) throw new Error('结构蓝图重复分配了 SKU') + if (expected.length !== actual.length || expected.some((id, index) => id !== actual[index])) { + throw new Error('结构蓝图没有完整覆盖真实 SKU') + } + for (const group of blueprint.structureGroups) { + const keys = group.componentSlots.map((slot) => slot.componentKey) + if (keys.length !== new Set(keys).size) throw new Error(`${group.label}存在重复 componentKey`) + const keySet = new Set(keys) + for (const relation of group.relations) { + if (!keySet.has(relation.fromComponentKey) || !keySet.has(relation.toComponentKey)) { + throw new Error(`${group.label}的结构关系引用了不存在的部件`) + } + } + const categories = new Set(group.componentSlots.map((slot) => slot.category)) + if (![...categories].some((category) => ['BOARD', 'STEEL', 'ACCESSORY'].includes(category))) { + throw new Error(`${group.label}缺少主体材料槽位`) + } + for (const required of ['PROCESSING', 'LABOR', 'PACKAGING']) { + if (!categories.has(required as 'PROCESSING' | 'LABOR' | 'PACKAGING')) { + throw new Error(`${group.label}缺少${required}槽位`) + } + } + } +} + +function knownFactIds(snapshot: BomCostingSourceFactSnapshot) { + return new Set(snapshot.imageFacts.flatMap((image) => image.facts.map((fact) => fact.factId))) +} + +function sanitizeBlueprintFactIds( + blueprint: BomCostingStructureBlueprintV1, + factSnapshot: BomCostingSourceFactSnapshot, +) { + const known = knownFactIds(factSnapshot) + return { + ...blueprint, + mechanisms: blueprint.mechanisms.map((mechanism) => ({ + ...mechanism, + evidenceFactIds: mechanism.evidenceFactIds.filter((factId) => known.has(factId)), + })), + structureGroups: blueprint.structureGroups.map((group) => ({ + ...group, + componentSlots: group.componentSlots.map((slot) => ({ + ...slot, + evidenceFactIds: slot.evidenceFactIds.filter((factId) => known.has(factId)), + })), + relations: group.relations.map((relation) => ({ + ...relation, + evidenceFactIds: relation.evidenceFactIds.filter((factId) => known.has(factId)), + })), + unresolvedQuestions: group.unresolvedQuestions.map((question) => ({ + ...question, + evidenceFactIds: question.evidenceFactIds.filter((factId) => known.has(factId)), + })), + })), + } +} + +function getTimeoutMs() { + return readBomCostingBoundedInteger( + process.env.BOM_COSTING_STRUCTURE_TIMEOUT_MS, + 100_000, + 30_000, + 180_000, + ) +} + +function recordResponse(trace: BomCostingStructureTrace, response: BomCostingMultimodalCallResult) { + trace.model = response.model || trace.model + if (response.id) trace.callIds.push(response.id) + trace.durationMs += response.durationMs + trace.promptTokens = addNullable(trace.promptTokens, response.promptTokens) + trace.completionTokens = addNullable(trace.completionTokens, response.completionTokens) +} + +async function generateBlueprint(input: { + config: BomCostingMultimodalProviderConfig + snapshot: MarketDetailSnapshot + factSnapshot: BomCostingSourceFactSnapshot + signal?: AbortSignal + analysisInstruction?: string + trace: BomCostingStructureTrace +}) { + const knowledge = buildBomCostingStructureKnowledge(input.snapshot) + for (let attempt = 1; attempt <= MAX_BLUEPRINT_CONTRACT_ATTEMPTS; attempt += 1) { + const response = await callBomCostingMultimodalModel({ + config: input.config, + operation: 'SYNTHESIS', + systemPrompt: BLUEPRINT_SYSTEM_PROMPT, + userContent: JSON.stringify({ + task: '建立整个商品的共享结构蓝图;先确定拓扑,再交给后续步骤逐 SKU 展开。', + outputContractVersion: 'structure-blueprint/draft-v1', + contractRetry: attempt > 1, + productTruth: { + itemId: input.snapshot.itemId, + title: input.snapshot.title, + shopName: input.snapshot.shopName, + }, + skuTruth: input.snapshot.skus.map(serializeSku), + facts: compactFacts(input.factSnapshot), + structureKnowledge: knowledge, + reviewInstruction: input.analysisInstruction?.trim() || undefined, + }), + maxTokens: 8_000, + timeoutMs: getTimeoutMs(), + retrySynthesisTimeout: true, + signal: input.signal, + }) + recordResponse(input.trace, response) + try { + const draft = parseSingle(response.content, blueprintDraftSchema) + const parsed = bomCostingStructureBlueprintV1Schema.parse({ + schemaVersion: BOM_COSTING_STRUCTURE_BLUEPRINT_SCHEMA_VERSION, + knowledgeVersion: BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION, + ...draft, + verificationRounds: [], + generatedAt: new Date().toISOString(), + }) + const sanitized = bomCostingStructureBlueprintV1Schema.parse( + sanitizeBlueprintFactIds(parsed, input.factSnapshot), + ) + assertBlueprintCoverage(sanitized, input.snapshot) + return sanitized + } catch (error) { + if (attempt >= MAX_BLUEPRINT_CONTRACT_ATTEMPTS) throw error + } + } + throw new Error('产品结构蓝图未生成') +} + +function selectVerificationImages(input: { + questions: BomCostingStructureBlueprintV1['structureGroups'][number]['unresolvedQuestions'] + factSnapshot: BomCostingSourceFactSnapshot +}) { + const targetFactIds = new Set(input.questions.flatMap((question) => question.evidenceFactIds)) + return input.factSnapshot.imageFacts + .map((image) => ({ + image, + score: image.facts.reduce((score, fact) => ( + score + (targetFactIds.has(fact.factId) ? 20 : 0) + + (['DIMENSION', 'STRUCTURE', 'PART', 'MECHANISM', 'HARDWARE', 'MATERIAL'].includes(fact.kind) ? 2 : 0) + ), 0), + })) + .filter(({ image }) => /^https?:\/\//i.test(image.sourceImageUrl)) + .sort((left, right) => right.score - left.score) + .slice(0, MAX_IMAGES_PER_VERIFICATION_ROUND) + .map(({ image }) => image) +} + +async function verifyBlueprintGroup(input: { + config: BomCostingMultimodalProviderConfig + blueprint: BomCostingStructureBlueprintV1 + groupIndex: number + factSnapshot: BomCostingSourceFactSnapshot + signal?: AbortSignal + trace: BomCostingStructureTrace + onStage?: (progress: BomCostingStructureStageProgress) => Promise + onCheckpoint?: (checkpoint: BomCostingStructureCheckpoint) => Promise + sourceSkuIds: string[] +}) { + let blueprint = input.blueprint + let stagnantRounds = 0 + for (let round = 1; round <= MAX_VERIFICATION_ROUNDS_PER_GROUP; round += 1) { + const group = blueprint.structureGroups[input.groupIndex] + if (!group) break + const questions = group.unresolvedQuestions + .filter((question) => question.status === 'OPEN' && question.confidence < 0.55) + .slice(0, MAX_QUESTIONS_PER_VERIFICATION_ROUND) + if (questions.length === 0) break + const confidenceBefore = questions.reduce((sum, question) => sum + question.confidence, 0) / questions.length + const images = selectVerificationImages({ questions, factSnapshot: input.factSnapshot }) + await input.onStage?.({ + stage: 'VERIFY', + structureGroupKey: group.structureGroupKey, + round, + message: `正在定向确认${questions.map((question) => question.kind).join('、')}`, + }) + let verification: z.infer + let response: BomCostingMultimodalCallResult + try { + const content: BomCostingMultimodalUserContent = [ + { + type: 'text', + text: JSON.stringify({ + task: '只核对当前结构组的未解决问题。', + outputContractVersion: 'structure-verification/v1', + structureGroup: group, + questions, + facts: compactFacts(input.factSnapshot, 180), + imageReferences: images.map((image) => ({ imageId: image.imageId, label: image.label })), + }), + }, + ...images.map((image) => ({ + type: 'image_url' as const, + image_url: { url: image.sourceImageUrl, detail: 'high' as const }, + })), + ] + response = await callBomCostingMultimodalModel({ + config: input.config, + operation: 'SYNTHESIS', + systemPrompt: VERIFICATION_SYSTEM_PROMPT, + userContent: content, + maxTokens: 4_000, + timeoutMs: getTimeoutMs(), + retrySynthesisTimeout: true, + signal: input.signal, + }) + recordResponse(input.trace, response) + verification = parseSingle(response.content, verificationPayloadSchema) + } catch (error) { + blueprint = { + ...blueprint, + verificationRounds: [...blueprint.verificationRounds, { + round, + structureGroupKey: group.structureGroupKey, + questionIds: questions.map((question) => question.questionId), + evidenceImageIds: images.map((image) => image.imageId), + newFactCount: 0, + confidenceBefore, + confidenceAfter: confidenceBefore, + status: 'FAILED', + message: `${classifyStructureFailure(error)}: ${ + error instanceof Error ? error.message.slice(0, 950) : '定向复核失败' + }`, + }], + } + input.trace.verificationRoundCount += 1 + await input.onCheckpoint?.({ + kind: BOM_COSTING_STRUCTURE_CHECKPOINT_KIND, + version: BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION, + sourceSkuIds: input.sourceSkuIds, + blueprint, + completedGroupKeys: [], + skus: [], + }) + break + } + const resultsById = new Map(verification.questionResults.map((result) => [result.questionId, result] as const)) + const updatesByKey = new Map(verification.componentUpdates.map((update) => [update.componentKey, update] as const)) + const known = knownFactIds(input.factSnapshot) + const nextQuestions = group.unresolvedQuestions.map((question) => { + const result = resultsById.get(question.questionId) + if (!result) return question + const confidence = Math.max(question.confidence, result.confidence) + return { + ...question, + confidence, + evidenceFactIds: Array.from(new Set([ + ...question.evidenceFactIds, + ...result.evidenceFactIds.filter((factId) => known.has(factId)), + ])), + status: result.status === 'RESOLVED' && confidence >= 0.55 + ? 'RESOLVED' as const + : round === MAX_VERIFICATION_ROUNDS_PER_GROUP + ? 'REVIEW_REQUIRED' as const + : 'OPEN' as const, + } + }) + const nextSlots = group.componentSlots.map((slot) => { + const update = updatesByKey.get(slot.componentKey) + if (!update) return slot + return { + ...slot, + quantityRule: update.quantityRule || slot.quantityRule, + dimensionRule: update.dimensionRule || slot.dimensionRule, + materialScope: update.materialScope ?? slot.materialScope, + evidenceFactIds: Array.from(new Set([ + ...slot.evidenceFactIds, + ...update.evidenceFactIds.filter((factId) => known.has(factId)), + ])), + confidence: Math.max(slot.confidence, update.confidence), + reviewFlags: update.reviewFlag + ? Array.from(new Set([...slot.reviewFlags, update.reviewFlag])) + : slot.reviewFlags, + } + }) + const affectedResults = questions.flatMap((question) => { + const result = resultsById.get(question.questionId) + return result ? [result] : [] + }) + const confidenceAfter = nextQuestions + .filter((question) => questions.some((target) => target.questionId === question.questionId)) + .reduce((sum, question) => sum + question.confidence, 0) / questions.length + const newFactCount = affectedResults.filter((result) => ( + result.status !== 'UNRESOLVED' && (result.finding || result.evidenceFactIds.length > 0) + )).length + const improvement = confidenceAfter - confidenceBefore + stagnantRounds = newFactCount === 0 || improvement < 0.05 ? stagnantRounds + 1 : 0 + const nextGroups = [...blueprint.structureGroups] + nextGroups[input.groupIndex] = { + ...group, + componentSlots: nextSlots, + unresolvedQuestions: stagnantRounds >= 2 + ? nextQuestions.map((question) => question.status === 'OPEN' + ? { ...question, status: 'REVIEW_REQUIRED' as const } + : question) + : nextQuestions, + } + blueprint = bomCostingStructureBlueprintV1Schema.parse({ + ...blueprint, + structureGroups: nextGroups, + verificationRounds: [...blueprint.verificationRounds, { + round, + structureGroupKey: group.structureGroupKey, + questionIds: questions.map((question) => question.questionId), + evidenceImageIds: images.map((image) => image.imageId), + newFactCount, + confidenceBefore, + confidenceAfter, + status: newFactCount === 0 + ? 'NO_NEW_EVIDENCE' + : nextQuestions.every((question) => question.status !== 'OPEN') + ? 'RESOLVED' + : 'IMPROVED', + message: affectedResults.map((result) => result.finding).filter(Boolean).join(';').slice(0, 1_000), + }], + }) + input.trace.verificationRoundCount += 1 + await input.onCheckpoint?.({ + kind: BOM_COSTING_STRUCTURE_CHECKPOINT_KIND, + version: BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION, + sourceSkuIds: input.sourceSkuIds, + blueprint, + completedGroupKeys: [], + skus: [], + }) + if (stagnantRounds >= 2) break + } + return blueprint +} + +function groupFacts( + factSnapshot: BomCostingSourceFactSnapshot, + skuIds: string[], +) { + const skuSet = new Set(skuIds) + return compactFacts(factSnapshot, 260).filter((fact) => ( + fact.relatedSkuIds.length === 0 || fact.relatedSkuIds.some((skuId) => skuSet.has(skuId)) + )) +} + +function imageUrlByFactId(factSnapshot: BomCostingSourceFactSnapshot) { + return new Map(factSnapshot.imageFacts.flatMap((image) => ( + image.facts.map((fact) => [fact.factId, image.sourceImageUrl] as const) + ))) +} + +function hydrateLineEvidence( + lines: z.infer[], + factSnapshot: BomCostingSourceFactSnapshot, +) { + const urlByFact = imageUrlByFactId(factSnapshot) + return lines.map((line) => ({ + ...line, + evidence: line.evidence.map((evidence) => { + const factIds = (evidence.factIds ?? []).filter((factId) => urlByFact.has(factId)) + return { + ...evidence, + factIds, + imageUrl: evidence.imageUrl ?? (factIds[0] ? urlByFact.get(factIds[0]) : undefined), + } + }), + })) +} + +function assertInstantiation(input: { + group: BomCostingStructureBlueprintV1['structureGroups'][number] + skuBatch: MarketSkuSnapshot[] + output: z.infer +}) { + const expectedIds = input.skuBatch.map((sku) => sku.externalSkuId).sort() + const actualIds = input.output.skus.map((sku) => sku.externalSkuId).sort() + if (actualIds.length !== new Set(actualIds).size || + expectedIds.length !== actualIds.length || + expectedIds.some((id, index) => id !== actualIds[index])) { + throw new Error('SKU 展开结果没有完整覆盖当前真实 SKU 批次') + } + const requiredKeys = input.group.componentSlots + .filter((slot) => slot.required) + .map((slot) => slot.componentKey) + for (const sku of input.output.skus) { + const keys = sku.bomLines.map((line) => line.externalLineId) + if (keys.length !== new Set(keys).size) throw new Error(`${sku.externalSkuId}存在重复部件编号`) + const missing = requiredKeys.filter((key) => !keys.includes(key)) + if (missing.length > 0) throw new Error(`${sku.externalSkuId}缺少核心部件:${missing.join('、')}`) + } +} + +function splitBatch(items: T[], size: number) { + const batches: T[][] = [] + for (let index = 0; index < items.length; index += size) { + batches.push(items.slice(index, index + size)) + } + return batches +} + +function buildFallbackStructuredSku(input: { + sku: MarketSkuSnapshot + group: BomCostingStructureBlueprintV1['structureGroups'][number] + failure: unknown +}): CompetitorAnalysisResultV2['skus'][number] { + const failureKind = classifyStructureFailure(input.failure) + const failureMessage = input.failure instanceof Error + ? input.failure.message.slice(0, 500) + : String(input.failure).slice(0, 500) + return { + externalSkuId: input.sku.externalSkuId, + name: input.sku.name, + structureGroupKey: input.group.structureGroupKey, + imageUrl: input.sku.imageUrl ?? undefined, + attributes: { + ...input.sku.attributes, + structureAnalysisStatus: 'PENDING_REVIEW', + structureAnalysisFailureKind: failureKind, + structureAnalysisWarning: + '当前 SKU 已保留共享结构蓝图的低可信必要部件;可单独复核,不影响其他 SKU。', + }, + dimensions: {}, + bomLines: input.group.componentSlots + .filter((slot) => slot.required) + .map((slot) => ({ + externalLineId: slot.componentKey, + category: slot.category, + partName: slot.partName, + quantity: 1, + unit: ['PROCESSING', 'LABOR', 'PACKAGING'].includes(slot.category) + ? '套' + : slot.category === 'BOARD' + ? '块' + : '件', + evidence: [{ + basis: 'STRUCTURAL_INFERENCE' as const, + note: '模型展开失败后仅保留结构蓝图中的必要部件,不假装已经确认尺寸或数量。', + factIds: slot.evidenceFactIds, + derivation: slot.quantityRule, + reviewReason: `${failureKind}: ${failureMessage}`, + confidence: Math.min(0.35, Math.max(0.2, slot.confidence)), + }], + confidence: Math.min(0.35, Math.max(0.2, slot.confidence)), + })), + } +} + +async function instantiateBlueprint(input: { + config: BomCostingMultimodalProviderConfig + snapshot: MarketDetailSnapshot + factSnapshot: BomCostingSourceFactSnapshot + blueprint: BomCostingStructureBlueprintV1 + signal?: AbortSignal + analysisInstruction?: string + trace: BomCostingStructureTrace + onStage?: (progress: BomCostingStructureStageProgress) => Promise + checkpoint?: BomCostingStructureCheckpoint | null + onCheckpoint?: (checkpoint: BomCostingStructureCheckpoint) => Promise +}) { + const sourceById = new Map(input.snapshot.skus.map((sku) => [sku.externalSkuId, sku] as const)) + const sourceSkuIds = input.snapshot.skus.map((sku) => sku.externalSkuId) + const checkpointMatches = input.checkpoint && + input.checkpoint.sourceSkuIds.length === sourceSkuIds.length && + input.checkpoint.sourceSkuIds.every((skuId, index) => skuId === sourceSkuIds[index]) + const output: CompetitorAnalysisResultV2['skus'] = checkpointMatches + ? [...input.checkpoint!.skus] + : [] + const completedGroupKeys = new Set( + checkpointMatches ? input.checkpoint!.completedGroupKeys : [], + ) + for (const group of input.blueprint.structureGroups) { + if (completedGroupKeys.has(group.structureGroupKey)) continue + const groupSkus = group.skuIds.map((skuId) => sourceById.get(skuId)).filter(Boolean) as MarketSkuSnapshot[] + for (const skuBatch of splitBatch(groupSkus, MAX_SKUS_PER_INSTANTIATION_CALL)) { + await input.onStage?.({ + stage: 'INSTANTIATE', + structureGroupKey: group.structureGroupKey, + message: `正在按共享结构展开 ${skuBatch.length} 个真实 SKU`, + }) + let parsed: z.infer | null = null + let failure: unknown = null + try { + for (let attempt = 1; attempt <= MAX_BLUEPRINT_CONTRACT_ATTEMPTS; attempt += 1) { + const response = await callBomCostingMultimodalModel({ + config: input.config, + operation: 'SYNTHESIS', + systemPrompt: INSTANTIATION_SYSTEM_PROMPT, + userContent: JSON.stringify({ + task: '按结构蓝图为本批每个 SKU 独立生成 BOM。', + outputContractVersion: 'structure-instantiation/v1', + contractRetry: attempt > 1, + productTruth: { title: input.snapshot.title, shopName: input.snapshot.shopName }, + structureGroup: group, + skuTruth: skuBatch.map(serializeSku), + facts: groupFacts(input.factSnapshot, group.skuIds), + reviewInstruction: input.analysisInstruction?.trim() || undefined, + }), + maxTokens: Math.min(12_000, Math.max(5_000, skuBatch.length * 2_500)), + timeoutMs: getTimeoutMs(), + retrySynthesisTimeout: true, + signal: input.signal, + }) + recordResponse(input.trace, response) + input.trace.instantiationCallCount += 1 + try { + parsed = removeModelPricing( + parseSingle(response.content, instantiationPayloadSchema), + ) + assertInstantiation({ group, skuBatch, output: parsed }) + break + } catch (error) { + if (attempt >= MAX_BLUEPRINT_CONTRACT_ATTEMPTS) throw error + } + } + if (!parsed) throw new Error(`${group.label}没有返回有效 SKU BOM`) + } catch (error) { + failure = error + } + if (failure || !parsed) { + for (const sku of skuBatch) { + output.push(buildFallbackStructuredSku({ sku, group, failure })) + if (!input.trace.fallbackSkuIds.includes(sku.externalSkuId)) { + input.trace.fallbackSkuIds.push(sku.externalSkuId) + } + } + await input.onStage?.({ + stage: 'RECONCILE', + structureGroupKey: group.structureGroupKey, + message: `${group.label}展开未完成,已保留低可信必要部件并继续其他 SKU`, + }) + continue + } + for (const result of parsed.skus) { + const source = sourceById.get(result.externalSkuId) + if (!source) continue + output.push({ + externalSkuId: source.externalSkuId, + name: source.name, + structureGroupKey: group.structureGroupKey, + imageUrl: source.imageUrl ?? undefined, + attributes: { ...source.attributes }, + dimensions: result.dimensions, + weightKg: result.weightKg, + packaging: result.packaging, + bomLines: hydrateLineEvidence(result.bomLines, input.factSnapshot), + }) + } + } + completedGroupKeys.add(group.structureGroupKey) + await input.onCheckpoint?.({ + kind: BOM_COSTING_STRUCTURE_CHECKPOINT_KIND, + version: BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION, + sourceSkuIds, + blueprint: input.blueprint, + completedGroupKeys: Array.from(completedGroupKeys), + skus: output, + }) + } + const expected = input.snapshot.skus.map((sku) => sku.externalSkuId).sort() + const actual = output.map((sku) => sku.externalSkuId).sort() + if (expected.length !== actual.length || expected.some((id, index) => id !== actual[index])) { + throw new Error('结构实例化没有返回全部真实 SKU') + } + return output +} + +export async function buildStructuredBomCostingAnalysis(input: { + config: BomCostingMultimodalProviderConfig + sourceUrl: string + snapshot: MarketDetailSnapshot + factSnapshot: BomCostingSourceFactSnapshot + signal?: AbortSignal + analysisInstruction?: string + onStage?: (progress: BomCostingStructureStageProgress) => Promise + checkpoint?: BomCostingStructureCheckpoint | null + onCheckpoint?: (checkpoint: BomCostingStructureCheckpoint) => Promise +}) { + const trace: BomCostingStructureTrace = { + model: input.config.model, + callIds: [], + durationMs: 0, + promptTokens: null, + completionTokens: null, + blueprintFallback: false, + verificationRoundCount: 0, + instantiationCallCount: 0, + fallbackSkuIds: [], + } + const sourceSkuIds = input.snapshot.skus.map((sku) => sku.externalSkuId) + const checkpointMatches = input.checkpoint && + input.checkpoint.sourceSkuIds.length === sourceSkuIds.length && + input.checkpoint.sourceSkuIds.every((skuId, index) => skuId === sourceSkuIds[index]) + await input.onStage?.({ stage: 'BLUEPRINT', message: '正在建立产品级共享结构蓝图' }) + let blueprint: BomCostingStructureBlueprintV1 + if (checkpointMatches) { + blueprint = input.checkpoint!.blueprint + } else { + try { + blueprint = await generateBlueprint({ ...input, trace }) + } catch (error) { + console.warn('[BOM costing] structure blueprint fell back to deterministic priors', { + errorName: error instanceof Error ? error.name : 'UnknownError', + errorMessage: error instanceof Error ? error.message.slice(0, 240) : null, + }) + blueprint = buildDeterministicStructureBlueprint({ + snapshot: input.snapshot, + factSnapshot: input.factSnapshot, + }) + trace.blueprintFallback = true + } + await input.onCheckpoint?.({ + kind: BOM_COSTING_STRUCTURE_CHECKPOINT_KIND, + version: BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION, + sourceSkuIds, + blueprint, + completedGroupKeys: [], + skus: [], + }) + } + for (let groupIndex = 0; groupIndex < blueprint.structureGroups.length; groupIndex += 1) { + blueprint = await verifyBlueprintGroup({ + config: input.config, + blueprint, + groupIndex, + factSnapshot: input.factSnapshot, + signal: input.signal, + trace, + onStage: input.onStage, + onCheckpoint: input.onCheckpoint, + sourceSkuIds, + }) + } + let skus: CompetitorAnalysisResultV2['skus'] + try { + skus = await instantiateBlueprint({ + ...input, + blueprint, + trace, + checkpoint: checkpointMatches ? input.checkpoint : null, + }) + } catch (error) { + // The old fact-only structure is deliberately not used here: treating a + // sentence or image as a physical BOM row is the defect this pipeline + // replaces. Keep the failure scoped and explicit so the worker can resume + // this structure stage without discarding durable image facts. + throw new Error( + `结构蓝图已保存,但 SKU 展开未完成(${classifyStructureFailure(error)}):${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + const imageUrls = Array.from(new Set([ + ...input.snapshot.mainImages, + ...input.snapshot.detailImages, + ...input.snapshot.skuImages, + ])).slice(0, 30) + const result = competitorAnalysisResultV2Schema.parse({ + schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION, + sourceUrl: input.sourceUrl, + product: { + title: input.snapshot.title, + brand: input.snapshot.shopName ?? undefined, + category: blueprint.productFamily.label, + imageUrls, + }, + structureBlueprint: blueprint, + skus, + analyzedAt: new Date().toISOString(), + }) + return { result, trace } +} 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 new file mode 100644 index 00000000..78ba530b --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.test.ts @@ -0,0 +1,149 @@ +/** @jest-environment node */ + +import { BOM_COSTING_FACT_SCHEMA_VERSION } from '@/lib/bom-costing/fact-contracts' +import type { MarketDetailSnapshot } from './market-detail-client' +import { + BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION, + buildDeterministicStructureBlueprint, +} from './bom-costing-structure-knowledge' + +function buildSnapshot( + skus: MarketDetailSnapshot['skus'], + title = '现代简约推拉门衣柜', +): MarketDetailSnapshot { + return { + itemId: 'item-1', + channel: 'taobao', + title, + shopName: '测试店铺', + detailUrl: 'https://item.taobao.com/item.htm?id=1', + mainImages: [], + detailImages: [], + skuImages: [], + requestId: 'request-1', + skus, + } +} + +function sku(externalSkuId: string, name: string) { + return { + externalSkuId, + name, + size: name, + imageUrl: null, + attributes: { 颜色分类: name }, + priceRaw: null, + originalPriceRaw: null, + quantity: null, + } +} + +describe('BOM 通用结构知识与确定性蓝图', () => { + it('尺寸、颜色和材质变化不制造新结构组,真实 SKU 数量保持不变', () => { + const snapshot = buildSnapshot(Array.from({ length: 11 }, (_, index) => sku( + `sku-${index + 1}`, + `${index % 2 === 0 ? '橡木原木色' : '橡胶木胡桃色'} ${140 + index * 10}cm 推拉门`, + ))) + const result = buildDeterministicStructureBlueprint({ + snapshot, + factSnapshot: { + schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, + imageFacts: [], + failedImages: [], + extractedAt: new Date().toISOString(), + }, + }) + + expect(result.knowledgeVersion).toBe(BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION) + expect(result.structureGroups).toHaveLength(1) + expect(result.structureGroups[0]?.skuIds).toEqual( + 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: 'labor-assembly', required: true }), + expect.objectContaining({ componentKey: 'packaging-set', required: true }), + ])) + }) + + it('真正改变部件拓扑的抽屉差异才拆成独立结构组', () => { + const snapshot = buildSnapshot([ + sku('plain', '1.6米推拉门衣柜 不带抽屉'), + sku('drawer', '1.6米推拉门衣柜 带抽屉'), + ]) + const result = buildDeterministicStructureBlueprint({ + snapshot, + factSnapshot: { + schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, + imageFacts: [], + failedImages: [], + extractedAt: new Date().toISOString(), + }, + }) + + expect(result.structureGroups).toHaveLength(2) + expect(result.structureGroups.flatMap((group) => group.skuIds).sort()).toEqual([ + 'drawer', + 'plain', + ]) + }) + + it('SKU 专属明确板厚不会流入另一个结构组,且优先于目测证据', () => { + const snapshot = buildSnapshot([ + sku('plain', '1.6米推拉门衣柜 不带抽屉'), + sku('drawer', '1.6米推拉门衣柜 带抽屉'), + ]) + const result = buildDeterministicStructureBlueprint({ + snapshot, + factSnapshot: { + schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, + imageFacts: [{ + imageId: 'sku-plain-image', + sourceImageUrl: 'https://img.example.com/plain.jpg', + kind: 'SKU', + label: '无抽屉 SKU 图', + externalSkuIds: ['plain'], + observedTexts: [], + facts: [ + { + factId: 'plain-side-explicit', + kind: 'DIMENSION', + subject: '柜体侧板厚度', + value: '19.95 mm', + numericValue: 19.95, + unit: 'mm', + relatedSkuIds: ['plain'], + basis: 'IMAGE', + confidence: 0.95, + }, + { + factId: 'plain-side-visual', + kind: 'DIMENSION', + subject: '柜体侧板厚度', + value: '目测约 18 mm', + numericValue: 18, + unit: 'mm', + relatedSkuIds: ['plain'], + basis: 'VISUAL_ESTIMATE', + confidence: 0.45, + }, + ], + }], + failedImages: [], + extractedAt: new Date().toISOString(), + }, + }) + const plainGroup = result.structureGroups.find((group) => group.skuIds.includes('plain')) + const drawerGroup = result.structureGroups.find((group) => group.skuIds.includes('drawer')) + const plainSide = plainGroup?.componentSlots.find((slot) => slot.componentKey === 'enclosure-side') + const drawerSide = drawerGroup?.componentSlots.find((slot) => slot.componentKey === 'enclosure-side') + + expect(plainSide?.evidenceFactIds.slice(0, 2)).toEqual([ + 'plain-side-explicit', + 'plain-side-visual', + ]) + expect(drawerSide?.evidenceFactIds).toEqual([]) + expect(drawerSide?.confidence).toBeLessThan(0.55) + }) +}) 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 new file mode 100644 index 00000000..0386cbf3 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-knowledge.ts @@ -0,0 +1,444 @@ +import 'server-only' + +import { + BOM_COSTING_STRUCTURE_BLUEPRINT_SCHEMA_VERSION, + bomCostingStructureBlueprintV1Schema, + type BomCostingStructureBlueprintV1, +} from '@/lib/bom-costing/contracts' +import type { BomCostingSourceFactSnapshot } from '@/lib/bom-costing/fact-contracts' +import type { MarketDetailSnapshot, MarketSkuSnapshot } from './market-detail-client' +import { buildBomCostingInstallationKnowledge } from './bom-costing-installation-knowledge' + +export const BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION = + 'structure-priors/2026-08-10-v1' as const + +export interface BomCostingStructureSlotPrior { + componentKey: string + role: + | 'ENCLOSURE' + | 'SUPPORT' + | 'PARTITION' + | 'COVER' + | 'MOTION' + | 'HARDWARE' + | 'SURFACE' + | 'PACKAGING' + | 'PROCESSING' + | 'LABOR' + | 'OTHER' + category: 'BOARD' | 'STEEL' | 'ACCESSORY' | 'PACKAGING' | 'PROCESSING' | 'LABOR' + partName: string + required: boolean + quantityRule: string + dimensionRule: string + materialScope: string + keywords: string[] +} + +export interface BomCostingStructureFamilyPrior { + code: string + label: string + componentSlots: BomCostingStructureSlotPrior[] + relationRules: string[] + validationRules: string[] +} + +const COST_SLOTS: BomCostingStructureSlotPrior[] = [ + { + componentKey: 'processing-main', + role: 'PROCESSING', + category: 'PROCESSING', + partName: '主体加工', + required: true, + quantityRule: '每套产品 1 套;按已确认材料和结构选择必要加工。', + dimensionRule: '不使用板件外形尺寸冒充加工规格。', + materialScope: '', + keywords: ['加工', '打磨', '开料', '封边', '喷涂', '焊接', '开孔'], + }, + { + componentKey: 'labor-assembly', + role: 'LABOR', + category: 'LABOR', + partName: '生产装配人工', + required: true, + quantityRule: '每套产品 1 套;复杂度由部件数量和机构数量决定。', + dimensionRule: '不填写虚构尺寸。', + materialScope: '', + keywords: ['安装', '装配', '人工'], + }, + { + componentKey: 'packaging-set', + role: 'PACKAGING', + category: 'PACKAGING', + partName: '整套运输包材', + required: true, + quantityRule: '每套产品 1 套;按外形、重量和易损部位拆分实际包材。', + dimensionRule: '有包装尺寸时直接使用;否则按整机外形低可信推导。', + materialScope: '纸箱、泡沫、珍珠棉、护角、打包带等运输保护材料。', + keywords: ['包装', '纸箱', '泡沫', '珍珠棉', '护角', '打包带'], + }, +] + +const CONNECTOR_SLOT: BomCostingStructureSlotPrior = { + componentKey: 'hardware-connectors', + role: 'HARDWARE', + category: 'ACCESSORY', + partName: '主体连接五金', + required: true, + quantityRule: '按已确认部件连接界面估算最小必要数量。', + dimensionRule: '没有直接证据时不得指定品牌和精确型号。', + materialScope: '螺钉、木榫、角码、三合一连接件或其他必要连接件。', + keywords: ['连接', '螺丝', '螺钉', '木榫', '角码', '三合一'], +} + +const FAMILY_PRIORS: Record = { + TABLE_DESK: { + code: 'TABLE_DESK', + label: '桌台类', + componentSlots: [ + { + componentKey: 'surface-main', role: 'SURFACE', category: 'BOARD', + partName: '主台面', required: true, + quantityRule: '通常为 1 块;拼接、折叠或延伸结构按直接证据拆分。', + dimensionRule: '长宽以 SKU 外形和明确台面标注为先;板厚必须单独取证。', + materialScope: '当前 SKU 明确材质。', keywords: ['台面', '桌面', '面板'], + }, + { + componentKey: 'support-main', role: 'SUPPORT', category: 'BOARD', + partName: '主体支撑', required: true, + quantityRule: '按桌腿、侧板、底座或框架的实际结构计数。', + dimensionRule: '由整机高度和连接关系推导;不得把整机尺寸直接复制到每个支撑。', + materialScope: '与当前支撑结构一致的木材、板材或金属。', keywords: ['桌腿', '支撑', '侧板', '框架', '底座'], + }, + CONNECTOR_SLOT, + ...COST_SLOTS, + ], + relationRules: ['主台面必须由主体支撑承托。', '活动台面必须关联对应活动机构。'], + validationRules: ['台面数量与展开方式一致。', '外形尺寸变化必须反映到台面或支撑用量。'], + }, + CABINET_STORAGE: { + code: 'CABINET_STORAGE', + label: '柜体收纳类', + componentSlots: [ + { + componentKey: 'enclosure-side', role: 'ENCLOSURE', category: 'BOARD', + partName: '左右侧板', required: true, + quantityRule: '独立柜体通常 2 块;组合柜按柜体边界确认。', + dimensionRule: '高度接近柜体高度,深度接近柜体有效深度;扣除连接结构需说明。', + materialScope: '柜体侧板材质。', keywords: ['侧板', '柜体', '侧框'], + }, + { + componentKey: 'enclosure-top', role: 'ENCLOSURE', category: 'BOARD', + partName: '柜体顶板', required: true, + quantityRule: '每个连续柜体通常 1 块;被明确分段时拆分。', + dimensionRule: '宽度随 SKU 柜体宽度变化,深度随柜体深度;板厚独立取证。', + materialScope: '柜体顶板材质。', keywords: ['顶板', '上横板'], + }, + { + componentKey: 'enclosure-bottom', role: 'ENCLOSURE', category: 'BOARD', + partName: '柜体底板', required: true, + quantityRule: '每个连续柜体通常为一块横向连续底板;只有明确分仓结构才拆分。', + dimensionRule: '宽度随 SKU 柜体宽度变化,深度随柜体深度。', + materialScope: '柜体底板材质。', keywords: ['底板', '底座', '下横板'], + }, + { + componentKey: 'partition-vertical', role: 'PARTITION', category: 'BOARD', + partName: '竖向隔板', required: false, + quantityRule: '按图片中独立竖向分区计数,不把门缝当隔板。', + dimensionRule: '由柜内高度和深度推导,需与上下横板闭合。', + materialScope: '柜内隔板材质。', keywords: ['竖隔板', '中竖板', '分区'], + }, + { + componentKey: 'partition-horizontal', role: 'PARTITION', category: 'BOARD', + partName: '横向层板', required: false, + quantityRule: '按每个分区内可见或明确标注的固定/活动层板计数。', + dimensionRule: '宽度按所在分区净宽,深度按柜内有效深度。', + materialScope: '柜内层板材质。', keywords: ['层板', '横隔板', '隔板'], + }, + { + componentKey: 'cover-door', role: 'COVER', category: 'BOARD', + partName: '柜门或覆盖件', required: false, + quantityRule: '按明确门数和开启方式计数;推拉门需考虑覆盖重叠。', + dimensionRule: '按柜体开口范围推导,不能用门缝数量替代门板数量。', + materialScope: '门板材质和表面处理。', keywords: ['门板', '柜门', '推拉门', '移门'], + }, + { + componentKey: 'back-panel', role: 'COVER', category: 'BOARD', + partName: '背板', required: false, + quantityRule: '仅在图片、文字或结构闭合证据支持时计入。', + dimensionRule: '覆盖柜体背部开口;厚度和嵌槽方式不得从外观臆造。', + materialScope: '背板可能与柜体主体不同,必须单独确定。', keywords: ['背板', '后板'], + }, + CONNECTOR_SLOT, + ...COST_SLOTS, + ], + relationRules: ['侧板、顶板和连续底板围合主体。', '隔板必须位于主体内部并与对应边界闭合。', '门板覆盖柜体开口并关联开启机构。'], + validationRules: ['核心围合板件不得因 SKU 尺寸变化而消失。', '同组 SKU 宽度变化必须反映到横向板件面积。'], + }, + WARDROBE: { + code: 'WARDROBE', + label: '衣柜类', + componentSlots: [], + relationRules: [], + validationRules: [], + }, + RACK_SHELF: { + code: 'RACK_SHELF', + label: '架类', + componentSlots: [ + { + componentKey: 'support-upright', role: 'SUPPORT', category: 'BOARD', + partName: '立柱或侧架', required: true, + quantityRule: '按可见承重立柱或侧架计数。', + dimensionRule: '高度随整机高度,截面或板厚必须单独取证。', + materialScope: '木质或金属支撑。', keywords: ['立柱', '侧架', '支架'], + }, + { + componentKey: 'partition-shelf', role: 'PARTITION', category: 'BOARD', + partName: '层板', required: true, + quantityRule: '按实际层数计数。', + dimensionRule: '长宽按层架开口和整机深度推导。', + materialScope: '层板材质。', keywords: ['层板', '置物板', '搁板'], + }, + CONNECTOR_SLOT, + ...COST_SLOTS, + ], + relationRules: ['每块层板必须由立柱、侧架或横撑支承。'], + validationRules: ['层数变化必须反映层板数量。', '承重结构不得缺失。'], + }, + BED_FRAME: { + code: 'BED_FRAME', label: '床架类', + componentSlots: [ + { componentKey: 'enclosure-bed-rail', role: 'ENCLOSURE', category: 'BOARD', partName: '床围与床头床尾', required: true, quantityRule: '按床头、床尾和左右床围拆分。', dimensionRule: '依据床垫规格和整机尺寸推导。', materialScope: '床架主体材质。', keywords: ['床头', '床尾', '床围', '侧围'] }, + { componentKey: 'support-bed-base', role: 'SUPPORT', category: 'BOARD', partName: '排骨架与中撑', required: true, quantityRule: '按排骨条、承托条、中撑梁和中撑脚计数。', dimensionRule: '覆盖床垫承托范围。', materialScope: '承重木材或金属。', keywords: ['排骨架', '排骨条', '中撑', '支撑'] }, + CONNECTOR_SLOT, + ...COST_SLOTS, + ], + relationRules: ['承托结构必须连接两侧床围并覆盖睡眠区域。'], + validationRules: ['床宽变化必须改变排骨条长度或数量。'], + }, + SEATING: { + code: 'SEATING', label: '坐具类', + componentSlots: [ + { componentKey: 'surface-seat', role: 'SURFACE', category: 'BOARD', partName: '座面', required: true, quantityRule: '每个独立坐具通常 1 个座面。', dimensionRule: '按座面标注或整机比例推导。', materialScope: '座面主体材质。', keywords: ['座面', '坐垫', '凳面'] }, + { componentKey: 'support-seat', role: 'SUPPORT', category: 'BOARD', partName: '腿架与支撑', required: true, quantityRule: '按腿、框架和横撑结构计数。', dimensionRule: '由座高和支撑关系推导。', materialScope: '木材或金属支撑。', keywords: ['椅腿', '凳腿', '支撑', '框架'] }, + CONNECTOR_SLOT, + ...COST_SLOTS, + ], + relationRules: ['座面必须由腿架或底座承托。'], + validationRules: ['套装数量必须与 SKU 文案一致。'], + }, + UPHOLSTERED_SEATING: { + code: 'UPHOLSTERED_SEATING', label: '软体坐具类', componentSlots: [], relationRules: [], validationRules: [], + }, + GENERIC: { + code: 'GENERIC', label: '通用家具', + componentSlots: [ + { componentKey: 'material-main', role: 'OTHER', category: 'BOARD', partName: '主体材料', required: true, quantityRule: '按已确认主体结构拆分,不合并为未知整机。', dimensionRule: '优先使用明确部件尺寸,否则按整机结构低可信推导。', materialScope: '当前 SKU 明确主体材质。', keywords: ['主体', '板材', '框架'] }, + CONNECTOR_SLOT, + ...COST_SLOTS, + ], + relationRules: ['主体材料、支撑和连接方式必须形成可装配结构。'], + validationRules: ['不得只返回一个“整机”占位材料行。'], + }, +} + +FAMILY_PRIORS.WARDROBE = { + ...FAMILY_PRIORS.CABINET_STORAGE!, + code: 'WARDROBE', + label: '衣柜类', + relationRules: [ + ...FAMILY_PRIORS.CABINET_STORAGE!.relationRules, + '挂衣杆必须由侧板、竖隔板或专用托座支承。', + '推拉门必须关联上下轨道、限位和必要缓冲件。', + ], + validationRules: [ + ...FAMILY_PRIORS.CABINET_STORAGE!.validationRules, + '门数、分区数和挂衣区域必须与当前 SKU 证据一致。', + ], +} + +FAMILY_PRIORS.UPHOLSTERED_SEATING = { + ...FAMILY_PRIORS.SEATING!, + code: 'UPHOLSTERED_SEATING', + label: '软体坐具类', + componentSlots: [ + ...FAMILY_PRIORS.SEATING!.componentSlots, + { componentKey: 'upholstery-set', role: 'COVER', category: 'ACCESSORY', partName: '软包面料与填充', required: true, quantityRule: '按座面、靠背和扶手覆盖范围估算。', dimensionRule: '依据软包覆盖范围和厚度证据。', materialScope: '面料、皮革、海绵或其他填充。', keywords: ['软包', '面料', '海绵', '皮革', '坐垫'] }, + ], +} + +const MECHANISM_SLOT_PRIORS: Record = { + DRAWER: [{ componentKey: 'motion-drawer', role: 'MOTION', category: 'ACCESSORY', partName: '抽屉及导轨机构', required: true, quantityRule: '按抽屉数量配置成对导轨和必要连接件。', dimensionRule: '导轨长度不得超过抽屉和柜体有效深度。', materialScope: '抽屉板件、导轨和拉手。', keywords: ['抽屉', '导轨'] }], + HINGED_DOOR: [{ componentKey: 'motion-hinge', role: 'MOTION', category: 'ACCESSORY', partName: '平开门铰链机构', required: true, quantityRule: '按门板数量和高度估算必要铰链数量。', dimensionRule: '具体型号和杯孔规格无证据时待复核。', materialScope: '铰链、缓冲器和门板连接件。', keywords: ['铰链', '合页', '平开门'] }], + SLIDING_DOOR: [{ componentKey: 'motion-sliding-door', role: 'MOTION', category: 'ACCESSORY', partName: '推拉门轨道机构', required: true, quantityRule: '按门扇、上下轨道、限位和缓冲结构配置。', dimensionRule: '轨道长度随柜体开口宽度,门扇需覆盖开口并保留重叠。', materialScope: '轨道、滑轮、限位和缓冲件。', keywords: ['推拉门', '移门', '轨道', '滑轮'] }], + CASTER: [{ componentKey: 'motion-caster', role: 'MOTION', category: 'ACCESSORY', partName: '脚轮组件', required: true, quantityRule: '按可见支撑点计数,并区分带刹车脚轮。', dimensionRule: '轮径和安装高度无证据时待复核。', materialScope: '脚轮、安装板和紧固件。', keywords: ['脚轮', '万向轮', '刹车轮'] }], + ADJUSTABLE_SHELF: [{ componentKey: 'hardware-shelf-support', role: 'HARDWARE', category: 'ACCESSORY', partName: '活动层板托', required: true, quantityRule: '每块活动层板按支承点配置层板托。', dimensionRule: '孔距和规格无证据时待复核。', materialScope: '层板托和调节孔件。', keywords: ['活动层板', '层板托', '可调'] }], + ANTI_TIP: [{ componentKey: 'hardware-anti-tip', role: 'HARDWARE', category: 'ACCESSORY', partName: '防倾倒组件', required: true, quantityRule: '高柜或明确要求上墙时每套配置。', dimensionRule: '具体安装尺寸按现场和说明书确定。', materialScope: '防倾倒带、上墙件和紧固件。', keywords: ['防倾倒', '上墙', '固定件'] }], +} + +function normalize(value: string) { + return value.trim().toLocaleLowerCase('zh-CN').replace(/\s+/g, '') +} + +function skuTopologySignature(sku: MarketSkuSnapshot) { + const text = normalize([ + sku.name, + ...Object.entries(sku.attributes) + .filter(([key]) => !/(颜色|色号|尺寸|规格|宽|长|高|深)/.test(key)) + .flatMap(([key, value]) => [key, value]), + ].join(' ')) + const tokens = [ + /不带镜|无镜|带镜|含镜/, + /推拉门|移门|平开门|折叠门/, + /单门|双门|两门|三门|四门|五门|六门/, + /不带抽屉|无抽屉|带抽屉|含抽屉|抽屉/, + /脚轮|万向轮|固定脚/, + /桌椅套装|一桌\d椅|单桌|单椅/, + ].flatMap((pattern) => text.match(pattern)?.[0] ?? []) + return tokens.length > 0 ? Array.from(new Set(tokens)).sort().join('-') : 'base' +} + +function factText( + fact: BomCostingSourceFactSnapshot['imageFacts'][number]['facts'][number], +) { + return normalize(`${fact.subject} ${fact.value} ${fact.note ?? ''}`) +} + +function findSlotFacts( + slot: BomCostingStructureSlotPrior, + facts: BomCostingSourceFactSnapshot, + skuIds: string[], +) { + const skuIdSet = new Set(skuIds) + return facts.imageFacts + .flatMap((image) => image.facts.map((fact) => { + const scopedSkuIds = Array.from(new Set([ + ...image.externalSkuIds, + ...fact.relatedSkuIds, + ])) + const appliesToGroup = scopedSkuIds.length === 0 || scopedSkuIds.some((skuId) => skuIdSet.has(skuId)) + const directEvidence = fact.basis !== 'VISUAL_ESTIMATE' + const scopeRank = scopedSkuIds.length > 0 + ? directEvidence ? 4 : 1 + : directEvidence ? 3 : 0 + return { fact, appliesToGroup, scopeRank } + })) + .filter(({ fact, appliesToGroup }) => ( + appliesToGroup && + slot.keywords.some((keyword) => factText(fact).includes(normalize(keyword))) + )) + .sort((left, right) => ( + right.scopeRank - left.scopeRank || + right.fact.confidence - left.fact.confidence + )) + .map(({ fact }) => fact) + .slice(0, 20) +} + +export function buildBomCostingStructureKnowledge(snapshot: MarketDetailSnapshot) { + const installation = buildBomCostingInstallationKnowledge(snapshot) + const matchedFamily = installation.productFamilies[0] + const family = FAMILY_PRIORS[matchedFamily?.code ?? 'GENERIC'] ?? FAMILY_PRIORS.GENERIC! + const mechanismSlots = installation.mechanisms.flatMap((mechanism) => ( + MECHANISM_SLOT_PRIORS[mechanism.code] ?? [] + )) + const slots = [...family.componentSlots, ...mechanismSlots] + return { + version: BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION, + usagePolicy: installation.usagePolicy, + family: { + code: family.code, + label: family.label, + confidence: matchedFamily ? 0.6 : 0.25, + }, + mechanisms: installation.mechanisms.map((mechanism) => ({ + code: mechanism.code, + label: mechanism.label, + confidence: 0.55, + })), + componentSlots: Array.from( + new Map(slots.map((slot) => [slot.componentKey, slot] as const)).values(), + ), + relationRules: family.relationRules, + validationRules: family.validationRules, + } +} + +export function buildDeterministicStructureBlueprint(input: { + snapshot: MarketDetailSnapshot + factSnapshot: BomCostingSourceFactSnapshot +}): BomCostingStructureBlueprintV1 { + const knowledge = buildBomCostingStructureKnowledge(input.snapshot) + const groups = new Map() + for (const sku of input.snapshot.skus) { + const signature = skuTopologySignature(sku) + const members = groups.get(signature) ?? [] + members.push(sku) + groups.set(signature, members) + } + const structureGroups = Array.from(groups.entries()).map(([signature, skus], groupIndex) => { + const componentSlots = knowledge.componentSlots.map((slot) => { + const facts = findSlotFacts(slot, input.factSnapshot, skus.map((sku) => sku.externalSkuId)) + const confidence = facts.length > 0 + ? Math.min(0.8, Math.max(...facts.map((fact) => fact.confidence))) + : slot.required ? 0.4 : 0.3 + return { + componentKey: slot.componentKey, + role: slot.role, + category: slot.category, + partName: slot.partName, + required: slot.required, + quantityRule: slot.quantityRule, + dimensionRule: slot.dimensionRule, + materialScope: slot.materialScope, + evidenceFactIds: facts.map((fact) => fact.factId), + confidence, + reviewFlags: facts.length === 0 + ? [`${slot.partName}来自结构知识低可信预估,需结合当前商品图片复核`] + : [], + } + }) + const unresolvedQuestions = componentSlots + .filter((slot) => slot.required && slot.confidence < 0.55) + .map((slot) => ({ + questionId: `q-${groupIndex + 1}-${slot.componentKey}`.slice(0, 120), + kind: slot.category === 'BOARD' ? 'DIMENSION_SCOPE' as const : 'COMPONENT_COUNT' as const, + question: `请确认${slot.partName}的数量、适用范围和关键尺寸。`, + affectedComponentKeys: [slot.componentKey], + evidenceFactIds: slot.evidenceFactIds, + confidence: slot.confidence, + status: 'OPEN' as const, + })) + return { + structureGroupKey: `topology-${groupIndex + 1}-${signature}`.slice(0, 160), + label: signature === 'base' ? knowledge.family.label : `${knowledge.family.label}·${signature}`, + skuIds: skus.map((sku) => sku.externalSkuId), + componentSlots, + relations: [], + unresolvedQuestions, + } + }) + return bomCostingStructureBlueprintV1Schema.parse({ + schemaVersion: BOM_COSTING_STRUCTURE_BLUEPRINT_SCHEMA_VERSION, + knowledgeVersion: knowledge.version, + productFamily: knowledge.family, + mechanisms: knowledge.mechanisms.map((mechanism) => ({ + ...mechanism, + evidenceFactIds: [], + })), + structureGroups, + verificationRounds: [], + generatedAt: new Date().toISOString(), + }) +} + +export function getBomCostingStructureFamilyPriors() { + return Object.values(FAMILY_PRIORS).map((prior) => ({ + code: prior.code, + label: prior.label, + componentSlots: prior.componentSlots, + relationRules: prior.relationRules, + validationRules: prior.validationRules, + })) +} diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.ts index 6516e2bf..a0051610 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.ts @@ -13,6 +13,7 @@ import { nanoid } from 'nanoid' import { BOM_COSTING_RESULT_RULESET_VERSION, BOM_COSTING_TARGET_ERROR_PERCENT, + competitorAnalysisResultV2Schema, type RecalculateBomCostingCaseInput, type UpdateBomCostingLineInput, } from '@/lib/bom-costing/contracts' @@ -234,6 +235,31 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) { analysisRuns: record.analysisRuns, skus: record.skus, }) + const structureAnalysis = (() => { + for (const run of record.analysisRuns) { + if (run.status !== 'READY') continue + const parsed = competitorAnalysisResultV2Schema.safeParse(run.resultSnapshot) + if (!parsed.success) continue + const unresolvedQuestions = parsed.data.structureBlueprint.structureGroups + .flatMap((group) => group.unresolvedQuestions + .filter((question) => question.status !== 'RESOLVED') + .map((question) => ({ + ...question, + structureGroupKey: group.structureGroupKey, + structureGroupLabel: group.label, + }))) + return { + revision: run.revision, + model: run.model, + blueprint: parsed.data.structureBlueprint, + verificationRoundCount: + parsed.data.structureBlueprint.verificationRounds.length, + unresolvedCount: unresolvedQuestions.length, + unresolvedQuestions, + } + } + return null + })() return { id: record.id, sourceUrl: canonicalCaseSourceUrl(record), @@ -253,6 +279,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) { processingFinishedAt: record.processingFinishedAt?.toISOString() ?? null, createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), + structureAnalysis, sourceSnapshot: record.sourceSnapshot ? { id: record.sourceSnapshot.id,