Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-main-release-20260805

This commit is contained in:
WuYongJie 2026-08-05 20:05:50 +08:00
commit 5ea48578ec
15 changed files with 878 additions and 93 deletions

View File

@ -1502,6 +1502,8 @@ describe('BomCostingWorkbenchClient', () => {
)
expect(await screen.findByText('橡胶木桌面板')).toBeInTheDocument()
expect(screen.getByText('SKU 结构已就绪')).toBeInTheDocument()
expect(screen.getAllByText('待补成本').length).toBeGreaterThan(0)
expect(screen.getByText('缺板厚')).toBeInTheDocument()
expect(screen.getByText('暂缺')).toBeInTheDocument()
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()

View File

@ -1204,6 +1204,9 @@ function ResultCanvas({
}
const requiresRecalculation = Boolean(detail.requiresRecalculation)
const hasPendingCost =
!requiresRecalculation && selectedSku.estimatedCost === null
const pendingCostLabel = requiresRecalculation ? '待重算' : '待补成本'
const missingCostGroups = new Set(
selectedSku.costSummary.missingCostGroups ?? [],
)
@ -1872,7 +1875,11 @@ function ResultCanvas({
<section className="mt-5 bg-[#2d3335] px-4 py-4 text-white">
<div className="flex items-center gap-1">
<p className="text-[10px] font-semibold text-white/70">
{requiresRecalculation ? '历史成本留档' : '预计出厂成本'}
{requiresRecalculation
? '历史成本留档'
: hasPendingCost
? 'SKU 结构已就绪'
: '预计出厂成本'}
</p>
<BomInfoHint
inverse
@ -1880,14 +1887,14 @@ function ResultCanvas({
>
{requiresRecalculation
? '当前留档由旧规则生成,不能用于价格判断;重新核算后会保留新的版本。'
: selectedSku.estimatedCost === null
? '当前旧结果未形成完整估价;按现有事实重新核算即可,无需重新分析图片。'
: hasPendingCost
? '图片事实与 SKU 结构已保存;材料裁切、加工、人工和包材规则将逐项补齐后形成完整成本。'
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。'}
</BomInfoHint>
</div>
<div className="mt-1 text-[30px] font-black leading-none tabular-nums">
{requiresRecalculation || selectedSku.estimatedCost === null
? '待重算'
{requiresRecalculation || hasPendingCost
? pendingCostLabel
: `约 ${formatMoney(selectedSku.estimatedCost)}`}
</div>
</section>
@ -1905,7 +1912,7 @@ function ResultCanvas({
)}
>
{row.missing
? '待重算'
? pendingCostLabel
: row.unresolved
? '暂缺'
: formatMoney(row.value)}
@ -1918,7 +1925,9 @@ function ResultCanvas({
align="end"
className="absolute right-0 top-0"
>
{incompleteCostMessage
{hasPendingCost
? 'SKU 结构已就绪;补齐对应成本资料后会形成完整成本。'
: incompleteCostMessage
? `${incompleteCostMessage},按现有事实重新核算可补齐。`
: approximateCostLineCount > 0
? `${approximateCostLineCount} 项采用资料库近似价或模型估价,可选复核以提高精度。`
@ -2233,7 +2242,11 @@ function ResultCanvas({
</span>
</span>
<span className="shrink-0 text-[10px] font-black">
{requiresRecalculation ? '待重算' : formatMoney(sku.estimatedCost)}
{requiresRecalculation
? '待重算'
: sku.estimatedCost === null
? '待补成本'
: formatMoney(sku.estimatedCost)}
</span>
</button>
)

View File

@ -1558,6 +1558,7 @@ export class BomCostingRepository {
completionTokens: number | null
durationMs: number
analysisSchemaVersion: string
completedMessage?: string
}) {
return prisma.$transaction(async (tx) => {
await tx.bomCostingSku.deleteMany({
@ -1631,7 +1632,7 @@ export class BomCostingRepository {
analysisSchemaVersion: input.analysisSchemaVersion,
status: BomCostingCaseStatus.READY,
progress: 100,
statusMessage: `核算完成,共 ${input.skus.length} 个 SKU`,
statusMessage: input.completedMessage ?? `核算完成,共 ${input.skus.length} 个 SKU`,
rawAnalysisSnapshot: input.analysis,
processingFinishedAt: new Date(),
taskEvents: {
@ -1639,7 +1640,7 @@ export class BomCostingRepository {
eventType: 'RESULT_SAVED',
status: BomCostingCaseStatus.READY,
progress: 100,
message: `核算完成,共 ${input.skus.length} 个 SKU`,
message: input.completedMessage ?? `核算完成,共 ${input.skus.length} 个 SKU`,
payload: input.eventPayload,
},
},

View File

@ -0,0 +1,134 @@
/** @jest-environment node */
import { BOM_COSTING_FACT_SCHEMA_VERSION } from '@/lib/bom-costing/fact-contracts'
import type { MarketDetailSnapshot } from './market-detail-client'
import { buildFactBasedCompetitorAnalysis } from './bom-costing-fact-based-structure'
const snapshot: MarketDetailSnapshot = {
itemId: 'item-1',
channel: 'taobao',
title: '测试移动衣帽架',
shopName: '测试店铺',
detailUrl: 'https://item.taobao.com/item.htm?id=item-1',
mainImages: ['https://img.example.com/main.jpg'],
detailImages: ['https://img.example.com/detail.jpg'],
skuImages: [
'https://img.example.com/solid.jpg',
'https://img.example.com/steel.jpg',
],
skus: [
{
externalSkuId: 'solid-87',
name: '实木衣帽架 87.5CM',
size: '87.5CM',
imageUrl: 'https://img.example.com/solid.jpg',
attributes: { 材质: '实木', 尺寸: '87.5CM' },
priceRaw: '617.10',
originalPriceRaw: '855.00',
quantity: 12,
},
{
externalSkuId: 'steel-87',
name: '不锈钢衣帽架 87.5CM',
size: '87.5CM',
imageUrl: 'https://img.example.com/steel.jpg',
attributes: { 材质: '不锈钢', 尺寸: '87.5CM' },
priceRaw: '699.00',
originalPriceRaw: null,
quantity: 8,
},
{
externalSkuId: 'unknown-64',
name: '移动衣帽架 64CM',
size: '64CM',
imageUrl: null,
attributes: { 尺寸: '64CM' },
priceRaw: '499.00',
originalPriceRaw: null,
quantity: null,
},
],
requestId: 'request-1',
}
describe('事实到 SKU 结构整理', () => {
it('严格保留外部接口的每个 SKU,不做组合,并且不伪造成本单价', () => {
const result = buildFactBasedCompetitorAnalysis({
sourceUrl: snapshot.detailUrl,
snapshot,
factSnapshot: {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
extractedAt: '2026-08-05T00:00:00.000Z',
imageFacts: [
{
imageId: 'main-1',
sourceImageUrl: 'https://img.example.com/main.jpg',
kind: 'MAIN',
label: '商品主图 1',
externalSkuIds: [],
observedTexts: [],
facts: [{
factId: 'frame-material',
kind: 'MATERIAL',
subject: '主体框架',
value: '橡胶木',
relatedSkuIds: ['solid-87'],
basis: 'IMAGE',
confidence: 0.88,
}],
},
{
imageId: 'sku-2',
sourceImageUrl: 'https://img.example.com/steel.jpg',
kind: 'SKU',
label: 'SKU 图:不锈钢 87.5CM',
externalSkuIds: ['steel-87'],
observedTexts: [],
facts: [{
factId: 'frame-steel',
kind: 'MATERIAL',
subject: '主体支架',
value: '不锈钢',
relatedSkuIds: ['steel-87'],
basis: 'IMAGE',
confidence: 0.91,
}, {
factId: 'height',
kind: 'DIMENSION',
subject: '整机高度',
value: '175.5cm',
numericValue: 175.5,
unit: 'cm',
relatedSkuIds: ['steel-87'],
basis: 'IMAGE',
confidence: 0.9,
}],
},
],
},
})
expect(result.skus.map((sku) => sku.externalSkuId)).toEqual([
'solid-87',
'steel-87',
'unknown-64',
])
expect(new Set(result.skus.map((sku) => sku.structureGroupKey)).size).toBe(3)
expect(result.skus[0]?.bomLines).toEqual(expect.arrayContaining([
expect.objectContaining({ category: 'BOARD', materialName: '橡胶木' }),
]))
expect(result.skus[1]?.bomLines).toEqual(expect.arrayContaining([
expect.objectContaining({ category: 'STEEL', materialName: '不锈钢' }),
]))
expect(result.skus[1]?.dimensions.heightMm).toBe(1755)
expect(result.skus[2]?.bomLines).toEqual([
expect.objectContaining({
category: 'ACCESSORY',
partName: '主体结构待确认',
}),
])
expect(result.skus.every((sku) => (
sku.bomLines.every((line) => line.estimatedUnitPrice === undefined)
))).toBe(true)
})
})

View File

@ -0,0 +1,358 @@
import 'server-only'
import {
BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
competitorAnalysisResultV1Schema,
type CompetitorBomLineV1,
type CompetitorSkuV1,
} from '@/lib/bom-costing/contracts'
import type {
BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import type {
MarketDetailSnapshot,
MarketSkuSnapshot,
} from './market-detail-client'
const MAX_STRUCTURE_LINES_PER_SKU = 100
const STRUCTURE_FACT_KINDS = new Set([
'MATERIAL',
'PART',
'STRUCTURE',
'MECHANISM',
'HARDWARE',
'PACKAGING',
'PROCESS',
])
const SOURCE_MATERIAL_MARKERS: Array<{
materialName: string
pattern: RegExp
}> = [
{ materialName: '不锈钢', pattern: /不锈钢/i },
{ materialName: '碳钢', pattern: /碳钢/i },
{ materialName: '铝合金', pattern: /铝合金/i },
{ materialName: '橡胶木', pattern: /橡胶木/i },
{ materialName: '橡木', pattern: /橡木/i },
{ materialName: '胡桃木', pattern: /胡桃木/i },
{ materialName: '松木', pattern: /松木/i },
{ materialName: '桦木', pattern: /桦木/i },
{ materialName: '密度板', pattern: /密度板/i },
{ materialName: '刨花板', pattern: /刨花板/i },
{ materialName: '多层板', pattern: /多层板/i },
{ materialName: '实木', pattern: /实木/i },
]
type FactEntry = {
image: BomCostingSourceFactSnapshot['imageFacts'][number]
fact: BomCostingSourceFactSnapshot['imageFacts'][number]['facts'][number]
}
function compactText(value: string, maximum: number) {
const normalized = value.trim().replace(/\s+/g, ' ')
if (normalized.length <= maximum) return normalized
return `${normalized.slice(0, Math.max(0, maximum - 1))}…`
}
function normalizeText(value: string) {
return value.trim().replace(/\s+/g, ' ').toLowerCase()
}
function readSkuSourceText(sku: MarketSkuSnapshot) {
return [
sku.name,
sku.size ?? '',
...Object.entries(sku.attributes).flatMap(([name, value]) => [name, value]),
].filter(Boolean).join(' ')
}
function inferMaterialCategory(value: string): CompetitorBomLineV1['category'] {
if (/不锈钢|碳钢|钢材|钢管|铁|铝|合金|金属|方管|圆管/i.test(value)) {
return 'STEEL'
}
if (/木|板材|密度板|刨花板|胶合板|多层板|纤维板|竹/i.test(value)) {
return 'BOARD'
}
return 'ACCESSORY'
}
function factAppliesToSku(entry: FactEntry, externalSkuId: string) {
const explicitlyBoundSkuIds = new Set([
...entry.image.externalSkuIds,
...entry.fact.relatedSkuIds,
])
return (
explicitlyBoundSkuIds.size === 0 ||
explicitlyBoundSkuIds.has(externalSkuId)
)
}
function buildEvidence(input: {
sourceUrl: string
entry: FactEntry
}) {
const note = compactText(
input.entry.fact.note ||
`${input.entry.fact.subject}:${input.entry.fact.value}`,
1_000,
)
return {
sourceUrl: input.sourceUrl,
imageUrl: input.entry.image.sourceImageUrl,
basis: input.entry.fact.basis,
note,
confidence: input.entry.fact.confidence,
} as const
}
function buildFactLine(input: {
sourceUrl: string
entry: FactEntry
index: number
}): CompetitorBomLineV1 {
const { fact } = input.entry
const partName = compactText(
fact.kind === 'PART' || fact.kind === 'HARDWARE'
? fact.value
: fact.subject,
200,
)
const common = {
externalLineId: `fact-${input.index + 1}`,
partName: partName || '已识别结构项',
quantity: 1,
unit: '件',
evidence: [buildEvidence(input)],
confidence: fact.confidence,
}
if (fact.kind === 'MATERIAL') {
return {
...common,
category: inferMaterialCategory(`${fact.subject} ${fact.value}`),
materialName: compactText(fact.value, 200),
}
}
if (fact.kind === 'PACKAGING') {
return {
...common,
category: 'PACKAGING',
packagingKind: 'OTHER',
materialSpec: compactText(fact.value, 200),
}
}
if (fact.kind === 'PROCESS') {
return {
...common,
category: 'PROCESSING',
processName: compactText(fact.value, 200),
unit: '项',
}
}
return {
...common,
category: inferMaterialCategory(`${fact.subject} ${fact.value}`),
materialSpec: compactText(fact.value, 200),
}
}
function detectSourceMaterial(sku: MarketSkuSnapshot) {
const sourceText = readSkuSourceText(sku)
return SOURCE_MATERIAL_MARKERS.find((marker) => marker.pattern.test(sourceText))
?.materialName ?? null
}
function buildSourceMaterialLine(input: {
sourceUrl: string
sku: MarketSkuSnapshot
index: number
}): CompetitorBomLineV1 | null {
const materialName = detectSourceMaterial(input.sku)
if (!materialName) return null
return {
externalLineId: `source-material-${input.index + 1}`,
category: inferMaterialCategory(materialName),
partName: 'SKU 标注材质',
materialName,
quantity: 1,
unit: '件',
evidence: [{
sourceUrl: input.sourceUrl,
basis: 'SKU_METADATA',
note: compactText(`平台 SKU 标注:${readSkuSourceText(input.sku)}`, 1_000),
confidence: 0.7,
}],
confidence: 0.7,
}
}
function buildFallbackLine(input: {
sourceUrl: string
sku: MarketSkuSnapshot
index: number
}): CompetitorBomLineV1 {
return {
externalLineId: `source-structure-${input.index + 1}`,
category: 'ACCESSORY',
partName: '主体结构待确认',
materialSpec: compactText(readSkuSourceText(input.sku), 200),
quantity: 1,
unit: '件',
evidence: [{
sourceUrl: input.sourceUrl,
basis: 'SKU_METADATA',
note: compactText(
`图片事实暂未覆盖该 SKU 的具体部件;已保留平台 SKU 信息,待补充分析图片后匹配。${readSkuSourceText(input.sku)}`,
1_000,
),
confidence: 0.35,
}],
confidence: 0.35,
}
}
function readMillimeters(value: number | undefined, unit: string | undefined) {
if (!value || !Number.isFinite(value) || value <= 0) return undefined
const normalizedUnit = unit?.trim().toLowerCase()
if (['cm', '厘米', '公分'].includes(normalizedUnit ?? '')) return value * 10
if (['m', '米'].includes(normalizedUnit ?? '')) return value * 1_000
if (['mm', '毫米'].includes(normalizedUnit ?? '')) return value
return undefined
}
function readKilograms(value: number | undefined, unit: string | undefined) {
if (!value || !Number.isFinite(value) || value <= 0) return undefined
const normalizedUnit = unit?.trim().toLowerCase()
if (['kg', '千克', '公斤'].includes(normalizedUnit ?? '')) return value
if (['g', '克'].includes(normalizedUnit ?? '')) return value / 1_000
return undefined
}
function buildSkuDimensions(entries: FactEntry[]) {
const dimensions: NonNullable<CompetitorSkuV1['dimensions']> = {}
for (const entry of entries) {
if (entry.fact.kind !== 'DIMENSION') continue
const value = readMillimeters(entry.fact.numericValue, entry.fact.unit)
if (!value) continue
const subject = `${entry.fact.subject} ${entry.fact.value}`.toLowerCase()
if (!dimensions.lengthMm && /长|长度|length/.test(subject)) {
dimensions.lengthMm = value
} else if (!dimensions.widthMm && /宽|宽度|深|深度|width|depth/.test(subject)) {
dimensions.widthMm = value
} else if (!dimensions.heightMm && /高|高度|height/.test(subject)) {
dimensions.heightMm = value
}
}
return dimensions
}
function buildSkuWeight(entries: FactEntry[]) {
for (const entry of entries) {
if (entry.fact.kind !== 'WEIGHT') continue
const value = readKilograms(entry.fact.numericValue, entry.fact.unit)
if (value) return value
}
return undefined
}
function buildSkuStructure(input: {
sourceUrl: string
sku: MarketSkuSnapshot
skuIndex: number
factSnapshot: BomCostingSourceFactSnapshot
}): CompetitorSkuV1 {
const entries = input.factSnapshot.imageFacts.flatMap((image) => image.facts
.map((fact) => ({ image, fact }))
.filter((entry) => factAppliesToSku(entry, input.sku.externalSkuId)))
const seen = new Set<string>()
const lines = entries
.filter((entry) => STRUCTURE_FACT_KINDS.has(entry.fact.kind))
.filter((entry) => {
const key = [
entry.fact.kind,
normalizeText(entry.fact.subject),
normalizeText(entry.fact.value),
].join('\u001f')
if (seen.has(key)) return false
seen.add(key)
return true
})
.slice(0, MAX_STRUCTURE_LINES_PER_SKU)
.map((entry, index) => buildFactLine({
sourceUrl: input.sourceUrl,
entry,
index,
}))
if (!lines.some((line) => ['BOARD', 'STEEL', 'ACCESSORY'].includes(line.category))) {
const sourceMaterialLine = buildSourceMaterialLine({
sourceUrl: input.sourceUrl,
sku: input.sku,
index: lines.length,
})
if (sourceMaterialLine) lines.unshift(sourceMaterialLine)
}
if (lines.length === 0) {
lines.push(buildFallbackLine({
sourceUrl: input.sourceUrl,
sku: input.sku,
index: input.skuIndex,
}))
}
return {
externalSkuId: input.sku.externalSkuId,
name: input.sku.name,
// Each purchasable platform SKU is kept independent in this first phase.
// We intentionally do not fabricate a shared structure group before there
// is enough part-level evidence to prove that the BOM is identical.
structureGroupKey: `source-sku-${input.skuIndex + 1}`,
imageUrl: input.sku.imageUrl ?? undefined,
attributes: {
...input.sku.attributes,
marketPriceRaw: input.sku.priceRaw,
marketOriginalPriceRaw: input.sku.originalPriceRaw,
marketQuantity: input.sku.quantity,
},
dimensions: buildSkuDimensions(entries),
weightKg: buildSkuWeight(entries),
bomLines: lines,
}
}
/**
* Converts durable image facts into a truthful first-stage BOM structure.
*
* This deliberately does not estimate missing board cuts, processing, labour,
* or packaging. Those are later cost layers and must not be invented merely
* to produce a total. The source SKU list is the only SKU authority here.
*/
export function buildFactBasedCompetitorAnalysis(input: {
sourceUrl: string
snapshot: MarketDetailSnapshot
factSnapshot: BomCostingSourceFactSnapshot
}) {
const imageUrls = Array.from(new Set([
...input.snapshot.mainImages,
...input.snapshot.detailImages,
...input.snapshot.skuImages,
])).slice(0, 30)
return competitorAnalysisResultV1Schema.parse({
schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
sourceUrl: input.sourceUrl,
product: {
title: input.snapshot.title,
brand: input.snapshot.shopName ?? undefined,
imageUrls,
},
skus: input.snapshot.skus.map((sku, skuIndex) => buildSkuStructure({
sourceUrl: input.sourceUrl,
sku,
skuIndex,
factSnapshot: input.factSnapshot,
})),
analyzedAt: new Date().toISOString(),
})
}

View File

@ -226,9 +226,18 @@ describe('BOM 多模态分析器', () => {
const originalSynthesisBatchMaxDuration =
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
beforeEach(() => {
process.env.BOM_COSTING_AI_FACT_BATCH_SIZE = '6'
// Most tests below exercise response contracts, checkpoints, or fallback
// behavior rather than the production GPT-5 batching default. Keep their
// historical one-batch fixture explicit; the dedicated test below covers
// the real default of one platform SKU per GPT-5 synthesis request.
process.env.BOM_COSTING_SYNTHESIS_SKU_BATCH_SIZE = '8'
// 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'
delete process.env.BOM_COSTING_AI_TIMEOUT_MS
jest.spyOn(providerConfigService, 'getActive').mockResolvedValue({
id: 'config-1',
@ -276,9 +285,36 @@ describe('BOM 多模态分析器', () => {
} else {
process.env.BOM_COSTING_MAX_SKUS_PER_CASE = originalMaxSkusPerCase
}
if (originalDetailedSynthesis === undefined) {
delete process.env.BOM_COSTING_DETAILED_SYNTHESIS
} else {
process.env.BOM_COSTING_DETAILED_SYNTHESIS = originalDetailedSynthesis
}
jest.restoreAllMocks()
})
it('默认把逐图事实直接整理为真实 SKU,不再发送最终大汇总模型请求', async () => {
delete process.env.BOM_COSTING_DETAILED_SYNTHESIS
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
createStreamingResponse(JSON.stringify(createFactBatchPayload(snapshot))),
)
const output = await new BomCostingMultimodalAnalyzer().analyzeWithTrace({
sourceUrl: snapshot.detailUrl,
snapshot,
})
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(output.trace.pipeline).toBe('FACT_BASED_STRUCTURE')
expect(output.trace.synthesisCallIds).toEqual([])
expect(output.result.skus.map((sku) => sku.externalSkuId)).toEqual(
snapshot.skus.map((sku) => sku.externalSkuId),
)
expect(output.result.skus.every((sku) => (
sku.bomLines.every((line) => line.estimatedUnitPrice === undefined)
))).toBe(true)
})
it('分析全部去重后的主图、SKU 图和详情图,不受旧 8 张限制影响', () => {
process.env.BOM_COSTING_AI_MAX_IMAGES = '20'
const largeSnapshot: MarketDetailSnapshot = {
@ -915,7 +951,7 @@ describe('BOM 多模态分析器', () => {
).toBe(false)
expect(requestBody).toMatchObject({
reasoning_effort: 'minimal',
max_tokens: 12_000,
max_tokens: 6_000,
})
expect(output.trace).toMatchObject({
imageCount: 4,
@ -925,6 +961,60 @@ describe('BOM 多模态分析器', () => {
})
})
it('GPT-5 默认按每个真实 SKU 独立汇总,且拆分批次不会混入整件 SKU 数量', async () => {
delete process.env.BOM_COSTING_SYNTHESIS_SKU_BATCH_SIZE
delete process.env.BOM_COSTING_SYNTHESIS_BATCH_CONCURRENCY
const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(
async (_input, init) => {
const request = JSON.parse(String((init as RequestInit).body)) as {
messages?: Array<{ content?: string }>
}
const input = JSON.parse(request.messages?.[1]?.content ?? '{}') as {
skuTruth?: Array<{ externalSkuId: string }>
}
const externalSkuId = input.skuTruth?.[0]?.externalSkuId
return createStreamingResponse(JSON.stringify({
product: { category: '书桌' },
structureGroups: [analyzedStructureGroup()],
skus: [analyzedSku(externalSkuId)],
}))
},
)
const output = await new BomCostingMultimodalAnalyzer().analyzeWithTrace({
sourceUrl: snapshot.detailUrl,
snapshot,
existingFactSnapshot: createCompleteFactSnapshot(snapshot),
})
expect(output.result.skus.map((sku) => sku.externalSkuId)).toEqual([
'sku-white',
'sku-black',
])
expect(fetchSpy).toHaveBeenCalledTimes(2)
for (const [, init] of fetchSpy.mock.calls) {
const request = JSON.parse(String((init as RequestInit).body)) as {
max_tokens?: number
messages?: Array<{ content?: string }>
}
const input = JSON.parse(request.messages?.[1]?.content ?? '{}') as {
productTruth?: { sourceSkuCount?: number; skuCount?: number }
skuTruth?: unknown[]
}
expect(request.max_tokens).toBe(4_000)
expect(input.productTruth).toEqual({
itemId: snapshot.itemId,
channel: snapshot.channel,
title: snapshot.title,
shopName: snapshot.shopName,
detailUrl: snapshot.detailUrl,
sourceSkuCount: 2,
skuCount: 1,
})
expect(input.skuTruth).toHaveLength(1)
}
})
it('明确提高安全上限后才将高规格商品拆成多个汇总批次', async () => {
process.env.BOM_COSTING_SYNTHESIS_SKU_BATCH_SIZE = '8'
process.env.BOM_COSTING_SYNTHESIS_BATCH_CONCURRENCY = '3'
@ -1282,7 +1372,7 @@ describe('BOM 多模态分析器', () => {
existingFactSnapshot: createCompleteFactSnapshot(snapshot),
}),
).rejects.toThrow(
'第 1/1 批规格结构在 8 分钟内未完成,任务已停止以避免长时间占用',
'多模态 BOM 合并超时:第 1/1 批规格结构在 8 分钟内未完成',
)
expect(fetchSpy).toHaveBeenCalledTimes(1)

View File

@ -35,6 +35,7 @@ import {
type BomCostingFactBatchProgress,
type BomCostingFactExtractionTrace,
} from './bom-costing-image-fact-extractor'
import { buildFactBasedCompetitorAnalysis } from './bom-costing-fact-based-structure'
import {
callBomCostingMultimodalModel,
getBomCostingMultimodalProviderConfig,
@ -237,12 +238,19 @@ const DEFAULT_AUTOMATIC_SKU_SAFETY_LIMIT = 120
// minutes of work. Keep a lower default safety gate; a deliberately configured
// high-volume workflow may raise it up to the hard contract ceiling.
const DEFAULT_SYNTHESIS_SKU_BATCH_SIZE = 8
// GPT-5-compatible gateways often spend their response window reasoning
// before emitting the strict JSON BOM. One real platform SKU per synthesis
// request keeps that response bounded and, importantly, prevents the model
// from treating distinct purchasable specifications as combinations.
const DEFAULT_GPT5_SYNTHESIS_SKU_BATCH_SIZE = 1
const DEFAULT_SYNTHESIS_BATCH_CONCURRENCY = 3
const DEFAULT_GPT5_SYNTHESIS_BATCH_CONCURRENCY = 2
// A synthesis batch is allowed to split a malformed or slow model response,
// but it must still finish in a short, deterministic window. Without a wall
// clock budget, one 4-SKU batch can recursively split and spend many minutes
// retrying while the worker remains occupied.
const DEFAULT_SYNTHESIS_BATCH_MAX_DURATION_MS = 8 * 60_000
const DEFAULT_GPT5_SYNTHESIS_BATCH_MAX_DURATION_MS = 3 * 60_000
const MAX_EXPLICIT_FACT_SKU_IDS = 20
const MAX_SYNTHESIS_CONTRACT_ATTEMPTS = 2
const SYNTHESIS_IMAGE_REFERENCE_PREFIX = 'bom-image:'
@ -270,6 +278,10 @@ function getAutomaticSkuSafetyLimit() {
)
}
function isDetailedSynthesisEnabled() {
return process.env.BOM_COSTING_DETAILED_SYNTHESIS?.trim().toLowerCase() === 'true'
}
function describeHighCardinalitySkuCatalog(
skus: MarketSkuSnapshot[],
safetyLimit: number,
@ -305,6 +317,7 @@ function describeHighCardinalitySkuCatalog(
}
export interface BomCostingMultimodalTrace {
pipeline?: 'FACT_BASED_STRUCTURE' | 'DETAILED_SYNTHESIS'
model: string
callId: string | null
imageCount: number
@ -595,8 +608,12 @@ function buildUserText(
title: snapshot.title,
shopName: snapshot.shopName,
detailUrl: snapshot.detailUrl,
skuCount: snapshot.skus.length,
batchSkuCount: skus.length,
// sourceSkuCount is context only. skuTruth below is the complete and
// exclusive output scope for this call. Keeping the two counts
// separate is essential after a stalled batch is split into individual
// real SKUs: the model must not return the original full catalog again.
sourceSkuCount: snapshot.skus.length,
skuCount: skus.length,
},
skuTruth: skus.map(serializeSkuForPrompt),
installationKnowledge: buildSynthesisInstallationKnowledge(snapshot),
@ -604,10 +621,16 @@ function buildUserText(
})
}
function splitSkuBatches(skus: MarketSkuSnapshot[]) {
function isGpt5CompatibleModel(model: string) {
return /^gpt-5(?:[.-]|$)/i.test(model)
}
function splitSkuBatches(skus: MarketSkuSnapshot[], model: string) {
const batchSize = readBomCostingBoundedInteger(
process.env.BOM_COSTING_SYNTHESIS_SKU_BATCH_SIZE,
DEFAULT_SYNTHESIS_SKU_BATCH_SIZE,
isGpt5CompatibleModel(model)
? DEFAULT_GPT5_SYNTHESIS_SKU_BATCH_SIZE
: DEFAULT_SYNTHESIS_SKU_BATCH_SIZE,
1,
120,
)
@ -617,19 +640,23 @@ function splitSkuBatches(skus: MarketSkuSnapshot[]) {
)
}
function getSynthesisBatchConcurrency() {
function getSynthesisBatchConcurrency(model: string) {
return readBomCostingBoundedInteger(
process.env.BOM_COSTING_SYNTHESIS_BATCH_CONCURRENCY,
DEFAULT_SYNTHESIS_BATCH_CONCURRENCY,
isGpt5CompatibleModel(model)
? DEFAULT_GPT5_SYNTHESIS_BATCH_CONCURRENCY
: DEFAULT_SYNTHESIS_BATCH_CONCURRENCY,
1,
3,
)
}
function getSynthesisBatchMaxDurationMs() {
function getSynthesisBatchMaxDurationMs(model: string) {
return readBomCostingBoundedInteger(
process.env.BOM_COSTING_SYNTHESIS_BATCH_MAX_DURATION_MS,
DEFAULT_SYNTHESIS_BATCH_MAX_DURATION_MS,
isGpt5CompatibleModel(model)
? DEFAULT_GPT5_SYNTHESIS_BATCH_MAX_DURATION_MS
: DEFAULT_SYNTHESIS_BATCH_MAX_DURATION_MS,
60_000,
10 * 60_000,
)
@ -643,7 +670,7 @@ function createSynthesisBatchDeadlineError(input: {
const durationMinutes = Math.ceil(input.maxDurationMs / 60_000)
return new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
`第 ${input.batchIndex}/${input.totalBatches} 批规格结构在 ${durationMinutes} 分钟内未完成,任务已停止以避免长时间占用;已保存的图片事实可在稍后继续使用`,
`多模态 BOM 合并超时:第 ${input.batchIndex}/${input.totalBatches} 批规格结构在 ${durationMinutes} 分钟内未完成;已保存的图片事实和已完成规格会自动续跑`,
)
}
@ -966,13 +993,63 @@ export class BomCostingMultimodalAnalyzer {
})
await input.onFactExtractionCompleted?.(factOutput)
// The first delivery phase ends at durable image facts. Do not turn those
// completed facts into a failed case by demanding a second, much larger
// "complete BOM + every cost group" model response. Each source SKU is
// instead assembled locally from the facts already obtained.
if (!isDetailedSynthesisEnabled()) {
const structureStartedAt = Date.now()
await input.onSynthesisBatchStarted?.({
batchIndex: 1,
totalBatches: 1,
batchSkuCount: input.snapshot.skus.length,
completedSkuCount: 0,
})
const result = buildFactBasedCompetitorAnalysis({
sourceUrl: input.sourceUrl,
snapshot: input.snapshot,
factSnapshot: factOutput.snapshot,
})
const structureDurationMs = Date.now() - structureStartedAt
await input.onSynthesisBatchCompleted?.({
batchIndex: 1,
totalBatches: 1,
batchSkuCount: input.snapshot.skus.length,
completedSkuCount: input.snapshot.skus.length,
model: factOutput.trace.model,
callId: null,
durationMs: structureDurationMs,
promptTokens: null,
completionTokens: null,
synthesisCheckpoint: null,
})
return {
result,
trace: {
pipeline: 'FACT_BASED_STRUCTURE',
model: factOutput.trace.model,
callId: factOutput.trace.callIds[0] ?? null,
imageCount: factOutput.snapshot.imageFacts.length,
inputSkuCount: input.snapshot.skus.length,
factBatchCount: factOutput.trace.batchCount,
synthesisBatchCount: 0,
synthesisCallIds: [],
reusedImageCount: factOutput.trace.reusedImageCount,
analyzedImageCount: factOutput.trace.analyzedImageCount,
durationMs: factOutput.trace.durationMs + structureDurationMs,
promptTokens: factOutput.trace.promptTokens,
completionTokens: factOutput.trace.completionTokens,
},
}
}
const configuredTimeoutMs = readBomCostingBoundedInteger(
process.env.BOM_COSTING_AI_TIMEOUT_MS,
110_000,
30_000,
300_000,
)
const timeoutMs = /^gpt-5(?:[.-]|$)/i.test(config.model)
const timeoutMs = isGpt5CompatibleModel(config.model)
? Math.min(configuredTimeoutMs, 110_000)
: configuredTimeoutMs
const configuredMaxTokens = readBomCostingBoundedInteger(
@ -985,11 +1062,19 @@ export class BomCostingMultimodalAnalyzer {
// A structured BOM for one product should fit in this bounded response;
// allowing a larger hidden reasoning/output budget only makes a stalled
// synthesis block the whole task until the upstream gateway terminates it.
const maxTokens = /^gpt-5(?:[.-]|$)/i.test(config.model)
? Math.min(configuredMaxTokens, 12_000)
: configuredMaxTokens
const skuBatches = splitSkuBatches(input.snapshot.skus)
const synthesisBatchConcurrency = getSynthesisBatchConcurrency()
const getSynthesisMaxTokens = (skuCount: number) => {
if (!isGpt5CompatibleModel(config.model)) return configuredMaxTokens
// A single SKU still has to contain a complete materials/process/labor/
// packaging BOM, but a 12k response allowance invites verbose JSON that
// regularly keeps the stream open past the gateway window. Scale the
// allowance only when an operator explicitly opts into multi-SKU batches.
return Math.min(
configuredMaxTokens,
Math.max(4_000, Math.min(8_000, skuCount * 3_000)),
)
}
const skuBatches = splitSkuBatches(input.snapshot.skus, config.model)
const synthesisBatchConcurrency = getSynthesisBatchConcurrency(config.model)
const analyzedSkus: z.infer<typeof competitorSkuSchema>[] = []
let product: z.infer<typeof aiProductSchema> | null = null
let model = config.model
@ -1038,7 +1123,9 @@ export class BomCostingMultimodalAnalyzer {
skuBatch: MarketSkuSnapshot[],
) => {
const synthesisBatchStartedAt = Date.now()
const synthesisBatchMaxDurationMs = getSynthesisBatchMaxDurationMs()
const synthesisBatchMaxDurationMs = getSynthesisBatchMaxDurationMs(
config.model,
)
const getRemainingSynthesisBatchDurationMs = () => {
const remainingMs =
synthesisBatchMaxDurationMs - (Date.now() - synthesisBatchStartedAt)
@ -1131,7 +1218,7 @@ export class BomCostingMultimodalAnalyzer {
skuSegment,
{ contractRetry: contractAttempt > 1 },
),
maxTokens,
maxTokens: getSynthesisMaxTokens(skuSegment.length),
timeoutMs: Math.min(
timeoutMs,
remainingSynthesisBatchDurationMs,
@ -1183,6 +1270,13 @@ export class BomCostingMultimodalAnalyzer {
finishReason: responsePayload.finishReason,
issues: describeAiOutputIssues(error),
errorName: error instanceof Error ? error.name : 'UnknownError',
// Safe diagnostic metadata only: never log model content or a
// prompt, but retain the local validation reason so an empty
// schema-issue list no longer conceals a coverage/reference bug.
errorMessage: error instanceof Error
? error.message.slice(0, 240)
: null,
responseContentBytes: Buffer.byteLength(responsePayload.content),
})
if (contractAttempt >= MAX_SYNTHESIS_CONTRACT_ATTEMPTS) {
if (skuSegment.length > 1) {
@ -1338,6 +1432,7 @@ export class BomCostingMultimodalAnalyzer {
return {
result,
trace: {
pipeline: 'DETAILED_SYNTHESIS',
model,
callId,
imageCount: factOutput.snapshot.imageFacts.length,

View File

@ -71,6 +71,13 @@ describe('BOM 多模态网关', () => {
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
it('规格汇总达到自身时限时属于可自动续跑的临时故障', () => {
expect(isBomCostingMultimodalTransientError(new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'多模态 BOM 合并超时:第 1/4 批规格结构在 3 分钟内未完成;已保存的图片事实和已完成规格会自动续跑',
))).toBe(true)
})
it('把短暂的模型连接故障标为可自动续跑,保留配置或结构错误的失败提示', () => {
expect(isBomCostingMultimodalTransientError(new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,

View File

@ -382,6 +382,7 @@ export function isBomCostingMultimodalTransientError(error: unknown) {
error.code === ErrorCode.BOM_COSTING_INVALID_PROVIDER &&
(
error.message.startsWith('多模态 BOM 分析超时') ||
error.message.startsWith('多模态 BOM 合并超时') ||
error.message.startsWith('多模态分析服务处理超时') ||
error.message.startsWith('多模态分析服务暂时无法连接') ||
error.message.startsWith('多模态分析输出达到长度上限') ||

View File

@ -626,6 +626,49 @@ describe('BomCostingProcessingService', () => {
})
})
it('事实结构阶段允许成本待补充,并把已完成的 SKU 结构保存为可继续核算', async () => {
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(
BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
) as never)
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(
BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
) as never)
mockRunAnalysis.mockResolvedValue({
result: {
...analysisResult,
skus: [{
...analysisResult.skus[0],
bomLines: [analysisResult.skus[0]!.bomLines[0]!],
}],
},
trace: {
pipeline: 'FACT_BASED_STRUCTURE',
model: 'grok-4.5',
callId: 'fact-call-1',
imageCount: 2,
inputSkuCount: 1,
durationMs: 12_000,
promptTokens: 50,
completionTokens: 80,
},
})
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
expect.objectContaining({
completedMessage: 'SKU 结构已就绪,共 1 个 SKU;成本项待补充',
skus: [expect.objectContaining({ estimatedCost: null })],
}),
)
})
it('预估成本高于竞品售价时拒绝把结果写成已完成', async () => {
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(

View File

@ -957,7 +957,7 @@ export class BomCostingProcessingService {
status: BomCostingCaseStatus.ANALYZING,
progress: 77,
message: failedImages.length > 0
? `已就绪 ${result.snapshot.imageFacts.length}/${analysisInputStats.imageCount} 张图片事实;${failedImages.length} 张待重试,继续汇总成本`
? `已就绪 ${result.snapshot.imageFacts.length}/${analysisInputStats.imageCount} 张图片事实;${failedImages.length} 张待重试,继续整理 SKU 结构`
: `全部 ${result.snapshot.imageFacts.length} 张图片事实已就绪`,
payload: {
factAnalysisId,
@ -977,8 +977,8 @@ export class BomCostingProcessingService {
status: BomCostingCaseStatus.ANALYZING,
progress: 78,
message: failedImages.length > 0
? `正在基于已就绪图片事实汇总各 SKU 的结构化 BOM;${failedImages.length} 张图片待重试`
: '正在合并逐图事实并生成各 SKU 的结构化 BOM',
? `正在基于已就绪图片事实整理各 SKU 的结构化 BOM;${failedImages.length} 张图片待重试`
: '正在按逐图事实整理各 SKU 的结构化 BOM',
payload: {
runId: analysisRunId,
factAnalysisId,
@ -999,7 +999,7 @@ export class BomCostingProcessingService {
status: BomCostingCaseStatus.ANALYZING,
progress: caseProgress,
message:
`正在并行汇总第 ${progress.batchIndex}/${progress.totalBatches} 批规格结构` +
`正在整理第 ${progress.batchIndex}/${progress.totalBatches} 批规格结构` +
`(${progress.batchSkuCount} 个 SKU)`,
payload: {
batchIndex: progress.batchIndex,
@ -1029,7 +1029,7 @@ export class BomCostingProcessingService {
status: BomCostingCaseStatus.ANALYZING,
progress: caseProgress,
message:
`已完成 ${progress.completedSkuCount}/${analysisInputStats.inputSkuCount} 个 SKU 的结构汇总`,
`已完成 ${progress.completedSkuCount}/${analysisInputStats.inputSkuCount} 个 SKU 的结构整理`,
payload: {
batchIndex: progress.batchIndex,
totalBatches: progress.totalBatches,
@ -1047,6 +1047,8 @@ export class BomCostingProcessingService {
const persistedResult = archivedSource
? replaceAnalysisImageUrlsWithStableArchive(output.result, archivedSource)
: output.result
const isFactBasedStructure =
output.trace.pipeline === 'FACT_BASED_STRUCTURE'
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_ANALYSIS_COMPLETED',
status: BomCostingCaseStatus.ANALYZING,
@ -1065,7 +1067,9 @@ export class BomCostingProcessingService {
eventType: 'PRICE_MATCH_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: 86,
message: '正在匹配基础资料库价格并计算成本',
message: isFactBasedStructure
? '正在匹配可用的基础资料库价格;其余成本项将保留待补充'
: '正在匹配基础资料库价格并计算成本',
})
const candidates = await bomCostingRepository.listPriceCandidates()
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
@ -1073,6 +1077,7 @@ export class BomCostingProcessingService {
analysis: persistedResult,
sourceSnapshot: marketSnapshot,
preparedSkus,
allowIncompleteCosting: isFactBasedStructure,
})
const stats = buildResultStats(preparedSkus)
const completedFailedFactImages = buildFailedFactImagePayload(
@ -1083,7 +1088,9 @@ export class BomCostingProcessingService {
eventType: 'PRICE_MATCH_COMPLETED',
status: BomCostingCaseStatus.ANALYZING,
progress: 95,
message: `已完成 ${stats.bomLineCount} 条 BOM 的价格匹配`,
message: isFactBasedStructure
? `已整理 ${stats.bomLineCount} 条可继续核算的 BOM 结构`
: `已完成 ${stats.bomLineCount} 条 BOM 的价格匹配`,
payload: stats,
})
await bomCostingRepository.completeCase({
@ -1114,6 +1121,9 @@ export class BomCostingProcessingService {
completionTokens: output.trace.completionTokens,
durationMs: output.trace.durationMs,
analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,
completedMessage: isFactBasedStructure
? `SKU 结构已就绪,共 ${preparedSkus.length} 个 SKU;成本项待补充`
: undefined,
})
} catch (error) {
const message = readErrorMessage(error)

View File

@ -140,7 +140,7 @@ describe('BOM 核算结果准备', () => {
})
})
it('资料库价格缺少计价所需尺寸时回退到可计算的模型备用估价', () => {
it('板材缺少自身裁切尺寸时不按整板或备用估价计算', () => {
const candidates = emptyCandidates()
candidates.BOARD = [{
libraryType: 'SUBSTRATE',
@ -173,17 +173,17 @@ describe('BOM 核算结果准备', () => {
const [sku] = prepareBomCostingAnalysis(missingBoardDimensions, candidates)
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
expect(sku?.estimatedCost).toBeGreaterThan(0)
expect(sku?.estimatedCost).toBeNull()
expect(board).toMatchObject({
matchStatus: BomCostingMatchStatus.NEEDS_CONFIRMATION,
matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE',
resolvedUnitPrice: 80,
matchStatus: BomCostingMatchStatus.UNMATCHED,
matchedLibraryType: '',
resolvedUnitPrice: null,
calculatedQuantity: 1,
subtotal: 80,
subtotal: null,
})
})
it('单件桌面板缺少自身长宽时,使用已识别的 SKU 桌面尺寸按面积核算', () => {
it('单件板材缺少自身长宽时,不拿 SKU 整体尺寸冒充裁切尺寸', () => {
const missingBoardDimensions: CompetitorAnalysisResultV1 = {
...analysis,
skus: analysis.skus.map((sku) => ({
@ -212,13 +212,14 @@ describe('BOM 核算结果准备', () => {
)
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
expect(sku?.estimatedCost).toBeGreaterThan(0)
expect(sku?.estimatedCost).toBeNull()
expect(board).toMatchObject({
matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE',
lengthMm: 1200,
widthMm: 600,
calculatedQuantity: 0.72,
subtotal: 57.6,
matchStatus: BomCostingMatchStatus.UNMATCHED,
matchedLibraryType: '',
lengthMm: null,
widthMm: null,
calculatedQuantity: 1,
subtotal: null,
})
})

View File

@ -6,7 +6,6 @@ import {
import type {
CompetitorAnalysisResultV1,
CompetitorBomLineV1,
CompetitorSkuV1,
} from '@/lib/bom-costing/contracts'
import {
calculateBomCostingLine,
@ -84,32 +83,13 @@ function createExternalEstimateCandidate(
}
}
function isSquareMeterUnit(unit: string) {
return ['㎡', 'm²', 'm2', '平方米'].includes(unit.trim().toLowerCase())
}
function resolvePricingDimensions(
line: CompetitorBomLineV1,
skuDimensions: CompetitorSkuV1['dimensions'],
) {
const canUseSkuSurfaceDimensions =
line.category === 'BOARD' &&
line.quantity === 1 &&
isSquareMeterUnit(line.unit) &&
Boolean(skuDimensions.lengthMm) &&
Boolean(skuDimensions.widthMm)
if (!canUseSkuSurfaceDimensions) {
return {
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
}
}
function resolvePricingDimensions(line: CompetitorBomLineV1) {
// A SKU's overall dimensions are never a board-cut size. Reusing them here
// silently prices a whole product as one panel (and can also charge an
// entire stock board). Board costing must wait for its own cut dimensions.
return {
lengthMm: line.lengthMm ?? skuDimensions.lengthMm,
widthMm: line.widthMm ?? skuDimensions.widthMm,
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
}
}
@ -127,22 +107,29 @@ function prepareLine(
line: CompetitorBomLineV1,
candidates: Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>,
sortOrder: number,
skuDimensions: CompetitorSkuV1['dimensions'],
): BomCostingPreparedLine {
const category = line.category as BomCostingLineCategory
const lossFactor = resolveLossFactor(line)
const pricingDimensions = resolvePricingDimensions(line, skuDimensions)
const externalEstimate = createExternalEstimateCandidate(line)
const libraryEstimate = resolveBomCostingLibraryEstimate(candidates[category], {
code: line.materialCode,
name: line.materialName || line.partName,
spec: line.materialSpec,
thicknessMm: line.category === 'BOARD' ? line.heightMm : null,
packagingKind:
line.category === 'PACKAGING'
? normalizePackagingKind(line.packagingKind)
: null,
})
const pricingDimensions = resolvePricingDimensions(line)
const hasBoardCutDimensions =
line.category !== 'BOARD' ||
(Boolean(line.lengthMm) && Boolean(line.widthMm))
const externalEstimate = hasBoardCutDimensions
? createExternalEstimateCandidate(line)
: null
const libraryEstimate = resolveBomCostingLibraryEstimate(
hasBoardCutDimensions ? candidates[category] : [],
{
code: line.materialCode,
name: line.materialName || line.partName,
spec: line.materialSpec,
thicknessMm: line.category === 'BOARD' ? line.heightMm : null,
packagingKind:
line.category === 'PACKAGING'
? normalizePackagingKind(line.packagingKind)
: null,
},
)
const calculateWithCandidate = (
candidate: BomCostingLibraryCandidate | null,
) => calculateBomCostingLine({
@ -219,7 +206,7 @@ export function prepareBomCostingAnalysis(
) {
return analysis.skus.map<BomCostingPreparedSku>((sku, skuIndex) => {
const bomLines = sku.bomLines.map((line, lineIndex) =>
prepareLine(line, candidates, lineIndex, sku.dimensions),
prepareLine(line, candidates, lineIndex),
)
const summary = summarizeBomCosting(
bomLines.map((line) => ({

View File

@ -57,7 +57,7 @@ function buildAnalysis(
function buildPreparedSku(
externalSkuId: string,
estimatedCost = 120,
estimatedCost: number | null = 120,
attributes: Record<string, unknown> = {},
): BomCostingPreparedSku {
return {
@ -75,7 +75,7 @@ function buildPreparedSku(
packageHeightMm: null,
packageWeightKg: null,
estimatedCost,
costCalculatedAt: new Date(),
costCalculatedAt: estimatedCost === null ? null : new Date(),
sortOrder: 0,
bomLines: [],
}
@ -145,6 +145,40 @@ describe('BOM 核算结果质量保护', () => {
})).not.toThrow()
})
it('第一阶段仅整理事实结构时允许成本待补充,不把已完成的图片事实判成失败', () => {
const sourceSnapshot = buildSourceSnapshot([
{
externalSkuId: 'solid-64-no-mirror',
name: '实木移动衣帽架【胡桃色 64CM】不带镜子',
size: '64CM',
imageUrl: null,
attributes: { 颜色分类: '实木 胡桃色 64CM 不带镜子' },
priceRaw: '617.10',
originalPriceRaw: null,
quantity: null,
},
{
externalSkuId: 'fas-84-with-mirror',
name: 'FAS级橡木移动衣帽架【原木色 84CM】带镜',
size: '84CM',
imageUrl: null,
attributes: { 颜色分类: 'FAS级橡木 原木色 84CM 带镜' },
priceRaw: '855.00',
originalPriceRaw: null,
quantity: null,
},
])
expect(() => assertBomCostingResultQuality({
analysis: buildAnalysis(sourceSnapshot.skus),
sourceSnapshot,
preparedSkus: sourceSnapshot.skus.map((sku) =>
buildPreparedSku(sku.externalSkuId, null),
),
allowIncompleteCosting: true,
})).not.toThrow()
})
it('能识别带货币符号和“起”字的售价,阻止高于售价的成本', () => {
const analysis = buildAnalysis([{ externalSkuId: 'sku-1', name: '测试 SKU' }])

View File

@ -247,9 +247,18 @@ export function assertBomCostingResultQuality(input: {
analysis: CompetitorAnalysisResultV1
sourceSnapshot: MarketDetailSnapshot | null
preparedSkus: BomCostingPreparedSku[]
/**
* Fact-based structure assembly is a deliberate first costing stage. It
* preserves usable SKU/image facts while panel cuts, processing, labour and
* packaging rules are not yet available, so it must not be rejected merely
* because a final total is still absent.
*/
allowIncompleteCosting?: boolean
}) {
assertPreparedCostRange(input.preparedSkus)
if (input.sourceSnapshot) {
if (!input.allowIncompleteCosting) {
assertPreparedCostRange(input.preparedSkus)
}
if (input.sourceSnapshot && !input.allowIncompleteCosting) {
assertDistinctSourceSkuStructures(input.analysis, input.sourceSnapshot)
}
}