merge: BOM structure blueprint v6
This commit is contained in:
commit
e4554d86cf
@ -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)
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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('安全上限'),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@ -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: [{
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 })
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 +
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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%')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user