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,