Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-main-release-20260805
This commit is contained in:
commit
5ea48578ec
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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(),
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('多模态分析输出达到长度上限') ||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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' }])
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user