merge: BOM structure blueprint v6

This commit is contained in:
WuYongJie 2026-08-10 22:15:08 +08:00
commit e4554d86cf
25 changed files with 3243 additions and 116 deletions

View File

@ -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)

View File

@ -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 ? (
<div className="mt-3 border border-dashed border-black/[0.12] px-3 py-3 text-[10px] leading-4 text-[#7b8486]">
这条记录还没有 v5 预估结果。重新核算后会自动生成同材质基准;需要比较时再选择替换材质。
这条记录还没有 v6 预估结果。重新核算后会自动生成同材质基准;需要比较时再选择替换材质。
</div>
) : (
<>
@ -1232,7 +1279,7 @@ function DynamicCostingPanel({
: missingScenarioLabel(selectedScenario)}
</span>
<BomInfoHint label="查看预估区间与依据">
{scenarioEvidenceText(selectedScenario)}
{scenarioEvidenceText(selectedScenario, structureAnalysis)}
</BomInfoHint>
</div>
{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<RecalculationMode>('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({
<DynamicCostingPanel
sku={selectedSku}
costing={dynamicCosting}
structureAnalysis={detail.structureAnalysis}
scenarioKind={dynamicScenarioKind}
targetMaterialId={dynamicTargetMaterialId}
calculating={dynamicCalculating}
@ -2556,7 +2634,7 @@ function ResultCanvas({
label="查看预计出厂成本说明"
>
{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({
</div>
</section>
{detail.structureAnalysis && selectedStructureGroup ? (
<details
className="border-b border-black/[0.07] py-4"
onToggle={(event) => setStructureEvidenceOpen(event.currentTarget.open)}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-[11px] font-bold outline-none focus-visible:ring-2 focus-visible:ring-[#40585d]">
<span>结构与推导依据</span>
<span className="flex items-center gap-2 text-[9px] font-semibold text-[#7a8385]">
{selectedStructureItems.length} 个部件
{lowConfidenceStructureCount > 0 ? ` · ${lowConfidenceStructureCount} 项待复核` : ''}
<BomGsapRotate active={structureEvidenceOpen}>
<ChevronRight size={13} />
</BomGsapRotate>
</span>
</summary>
<div className="mt-3 space-y-3">
<div className="border-l-2 border-[#dce7e4] pl-2 text-[9px] leading-4 text-[#677275]">
<div className="font-bold text-[#46575a]">
{detail.structureAnalysis.blueprint.productFamily.label} · {selectedStructureGroup.label}
</div>
<div>
结构初判 1 轮 · 定向复核 {detail.structureAnalysis.verificationRoundCount} 轮 · 规则 {detail.structureAnalysis.blueprint.schemaVersion}
</div>
</div>
{selectedStructureGroup.unresolvedQuestions.length > 0 ? (
<div className="bg-[#fbf4e8] px-2.5 py-2 text-[9px] leading-4 text-[#735938]">
<div className="font-bold">仍需确认</div>
<ul className="mt-1 space-y-0.5">
{selectedStructureGroup.unresolvedQuestions.slice(0, 4).map((question) => (
<li key={question.questionId}>· {question.question}</li>
))}
</ul>
</div>
) : null}
<div className="divide-y divide-black/[0.06] border-y border-black/[0.06]">
{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 (
<div key={slot.componentKey} className="py-2.5 text-[9px] leading-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-bold text-[#3f4d50]">{line?.partName || slot.partName}</span>
<span className="text-[#889092]">{line?.categoryLabel || slot.category}</span>
{needsReview ? (
<span className="font-semibold text-[#80613a]">待复核</span>
) : (
<span className="font-semibold text-[#4d6b63]">已形成依据</span>
)}
</div>
<div className="mt-0.5 text-[#667174]">
{line
? `${[line.lengthMm, line.widthMm, line.heightMm].map((value) => value ?? '--').join(' × ')} mm · ${line.quantity} ${line.unit}`
: `${slot.dimensionRule} · ${slot.quantityRule}`}
</div>
<div className="mt-0.5 text-[#7d8789]">
{line?.materialName || line?.materialSpec || slot.materialScope || '材质待确认'}
{' · '}
可信度 {Math.round(confidence * 100)}%
</div>
{evidenceBasis.length > 0 ? (
<div className="mt-0.5 text-[#7d8789]">
来源:{evidenceBasis.map((basis) => STRUCTURE_EVIDENCE_BASIS_LABELS[basis]).join('、')}
</div>
) : null}
{derivations.length > 0 ? (
<div className="mt-0.5 text-[#58686b]">推导:{derivations.slice(0, 2).join(';')}</div>
) : null}
{reviewReasons.length > 0 ? (
<div className="mt-0.5 text-[#80613a]">原因:{reviewReasons.slice(0, 2).join(';')}</div>
) : null}
</div>
{line && needsReview ? (
<button
type="button"
onClick={() => {
openManualReviewEditor(line)
window.requestAnimationFrame(scrollToBom)
}}
className="inline-flex h-9 shrink-0 items-center gap-1 rounded-[4px] px-2 font-bold text-[#40585d] outline-none transition hover:bg-[#edf1f1] focus-visible:ring-2 focus-visible:ring-[#40585d]"
>
<Pencil size={11} aria-hidden="true" />
复核
</button>
) : null}
</div>
</div>
)
})}
</div>
</div>
</details>
) : null}
<details
className="border-b border-black/[0.07] py-4"
onToggle={(event) => setReviewArchiveOpen(event.currentTarget.open)}

View File

@ -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('安全上限'),
]))
})
})

View File

@ -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)),
}
}

View File

@ -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[]
}

View File

@ -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: [{

View File

@ -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<typeof competitorAnalysisResult
export type CompetitorSkuV1 = z.infer<typeof competitorSkuSchema>
export type CompetitorBomLineV1 = z.infer<typeof competitorBomLineSchema>
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),

View File

@ -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'

View File

@ -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 })

View File

@ -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,

View File

@ -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()
})

View File

@ -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<void>
onStructureStage?: (
progress: BomCostingStructureStageProgress,
) => Promise<void>
onStructureCheckpoint?: (
checkpoint: BomCostingStructureCheckpoint,
) => Promise<void>
onSynthesisBatchStarted?: (
progress: Pick<
BomCostingSynthesisBatchProgress,
@ -1487,7 +1519,7 @@ export interface BomCostingMultimodalAnalyzeInput {
export class BomCostingMultimodalAnalyzer {
async analyze(
input: BomCostingMultimodalAnalyzeInput,
): Promise<CompetitorAnalysisResultV1> {
): Promise<CompetitorAnalysisResult> {
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,

View File

@ -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 +

View File

@ -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<void>
onStructureStage?: (
progress: BomCostingStructureStageProgress,
) => Promise<void>
onStructureCheckpoint?: (
checkpoint: BomCostingStructureCheckpoint,
) => Promise<void>
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<void>
onStructureStage?: (
progress: BomCostingStructureStageProgress,
) => Promise<void>
onStructureCheckpoint?: (
checkpoint: BomCostingStructureCheckpoint,
) => Promise<void>
onSynthesisBatchStarted?: (
progress: Pick<
BomCostingSynthesisBatchProgress,

View File

@ -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<BomCostingLineCategory, BomCostingLibraryCandidate[]>,
) {
return analysis.skus.map<BomCostingPreparedSku>((sku, skuIndex) => {

View File

@ -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%')
})
})

View File

@ -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)
}

View File

@ -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',
})
})

View File

@ -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<string, DynamicBomPriceQuote> = {}
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<string, {
sourceLineId: string | null
materialName: string
rulesetVersion: string
selectedBatchSize: number
batchSheetCount: number
perUnitSheetCost: number
utilization: number
singleUnitSheetCount: number
singleUnitSheetCost: number
}>()
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<string, unknown>).calculation
if (typeof calculation !== 'object' || calculation === null || Array.isArray(calculation)) continue
const audit = (calculation as Record<string, unknown>).priceSourceAudit
if (typeof audit !== 'object' || audit === null || Array.isArray(audit)) continue
const source = audit as Record<string, unknown>
const singleUnit = typeof source.singleUnit === 'object' && source.singleUnit !== null && !Array.isArray(source.singleUnit)
? source.singleUnit as Record<string, unknown>
: 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,

View File

@ -541,12 +541,35 @@ export class BomCostingSourceArchiveService {
})
const stitchPlans = [...explicitPlans, ...pixelBoundaryPlans]
.sort((left, right) => left.sourceIndexes[0]! - right.sourceIndexes[0]!)
const remainingStitchUses = new Map<string, number>()
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 &&

View File

@ -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')
})
})

View File

@ -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<typeof instantiationPayloadSchema>,
): z.infer<typeof instantiationPayloadSchema> {
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<T>(content: string, schema: z.ZodType<T>) {
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<void>
onCheckpoint?: (checkpoint: BomCostingStructureCheckpoint) => Promise<void>
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<typeof verificationPayloadSchema>
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<typeof competitorBomLineSchema>[],
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<typeof instantiationPayloadSchema>
}) {
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<T>(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<void>
checkpoint?: BomCostingStructureCheckpoint | null
onCheckpoint?: (checkpoint: BomCostingStructureCheckpoint) => Promise<void>
}) {
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<typeof instantiationPayloadSchema> | 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<void>
checkpoint?: BomCostingStructureCheckpoint | null
onCheckpoint?: (checkpoint: BomCostingStructureCheckpoint) => Promise<void>
}) {
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 }
}

View File

@ -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)
})
})

View File

@ -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<string, BomCostingStructureFamilyPrior> = {
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<string, BomCostingStructureSlotPrior[]> = {
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<string, MarketSkuSnapshot[]>()
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,
}))
}

View File

@ -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,