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(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.getByText('暂缺')).toBeInTheDocument()
|
expect(screen.getByText('暂缺')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
|
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
|
||||||
|
|||||||
@ -1204,6 +1204,9 @@ function ResultCanvas({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requiresRecalculation = Boolean(detail.requiresRecalculation)
|
const requiresRecalculation = Boolean(detail.requiresRecalculation)
|
||||||
|
const hasPendingCost =
|
||||||
|
!requiresRecalculation && selectedSku.estimatedCost === null
|
||||||
|
const pendingCostLabel = requiresRecalculation ? '待重算' : '待补成本'
|
||||||
const missingCostGroups = new Set(
|
const missingCostGroups = new Set(
|
||||||
selectedSku.costSummary.missingCostGroups ?? [],
|
selectedSku.costSummary.missingCostGroups ?? [],
|
||||||
)
|
)
|
||||||
@ -1872,7 +1875,11 @@ function ResultCanvas({
|
|||||||
<section className="mt-5 bg-[#2d3335] px-4 py-4 text-white">
|
<section className="mt-5 bg-[#2d3335] px-4 py-4 text-white">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<p className="text-[10px] font-semibold text-white/70">
|
<p className="text-[10px] font-semibold text-white/70">
|
||||||
{requiresRecalculation ? '历史成本留档' : '预计出厂成本'}
|
{requiresRecalculation
|
||||||
|
? '历史成本留档'
|
||||||
|
: hasPendingCost
|
||||||
|
? 'SKU 结构已就绪'
|
||||||
|
: '预计出厂成本'}
|
||||||
</p>
|
</p>
|
||||||
<BomInfoHint
|
<BomInfoHint
|
||||||
inverse
|
inverse
|
||||||
@ -1880,14 +1887,14 @@ function ResultCanvas({
|
|||||||
>
|
>
|
||||||
{requiresRecalculation
|
{requiresRecalculation
|
||||||
? '当前留档由旧规则生成,不能用于价格判断;重新核算后会保留新的版本。'
|
? '当前留档由旧规则生成,不能用于价格判断;重新核算后会保留新的版本。'
|
||||||
: selectedSku.estimatedCost === null
|
: hasPendingCost
|
||||||
? '当前旧结果未形成完整估价;按现有事实重新核算即可,无需重新分析图片。'
|
? '图片事实与 SKU 结构已保存;材料裁切、加工、人工和包材规则将逐项补齐后形成完整成本。'
|
||||||
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。'}
|
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。'}
|
||||||
</BomInfoHint>
|
</BomInfoHint>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[30px] font-black leading-none tabular-nums">
|
<div className="mt-1 text-[30px] font-black leading-none tabular-nums">
|
||||||
{requiresRecalculation || selectedSku.estimatedCost === null
|
{requiresRecalculation || hasPendingCost
|
||||||
? '待重算'
|
? pendingCostLabel
|
||||||
: `约 ${formatMoney(selectedSku.estimatedCost)}`}
|
: `约 ${formatMoney(selectedSku.estimatedCost)}`}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -1905,7 +1912,7 @@ function ResultCanvas({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.missing
|
{row.missing
|
||||||
? '待重算'
|
? pendingCostLabel
|
||||||
: row.unresolved
|
: row.unresolved
|
||||||
? '暂缺'
|
? '暂缺'
|
||||||
: formatMoney(row.value)}
|
: formatMoney(row.value)}
|
||||||
@ -1918,7 +1925,9 @@ function ResultCanvas({
|
|||||||
align="end"
|
align="end"
|
||||||
className="absolute right-0 top-0"
|
className="absolute right-0 top-0"
|
||||||
>
|
>
|
||||||
{incompleteCostMessage
|
{hasPendingCost
|
||||||
|
? 'SKU 结构已就绪;补齐对应成本资料后会形成完整成本。'
|
||||||
|
: incompleteCostMessage
|
||||||
? `${incompleteCostMessage},按现有事实重新核算可补齐。`
|
? `${incompleteCostMessage},按现有事实重新核算可补齐。`
|
||||||
: approximateCostLineCount > 0
|
: approximateCostLineCount > 0
|
||||||
? `${approximateCostLineCount} 项采用资料库近似价或模型估价,可选复核以提高精度。`
|
? `${approximateCostLineCount} 项采用资料库近似价或模型估价,可选复核以提高精度。`
|
||||||
@ -2233,7 +2242,11 @@ function ResultCanvas({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-[10px] font-black">
|
<span className="shrink-0 text-[10px] font-black">
|
||||||
{requiresRecalculation ? '待重算' : formatMoney(sku.estimatedCost)}
|
{requiresRecalculation
|
||||||
|
? '待重算'
|
||||||
|
: sku.estimatedCost === null
|
||||||
|
? '待补成本'
|
||||||
|
: formatMoney(sku.estimatedCost)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1558,6 +1558,7 @@ export class BomCostingRepository {
|
|||||||
completionTokens: number | null
|
completionTokens: number | null
|
||||||
durationMs: number
|
durationMs: number
|
||||||
analysisSchemaVersion: string
|
analysisSchemaVersion: string
|
||||||
|
completedMessage?: string
|
||||||
}) {
|
}) {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
await tx.bomCostingSku.deleteMany({
|
await tx.bomCostingSku.deleteMany({
|
||||||
@ -1631,7 +1632,7 @@ export class BomCostingRepository {
|
|||||||
analysisSchemaVersion: input.analysisSchemaVersion,
|
analysisSchemaVersion: input.analysisSchemaVersion,
|
||||||
status: BomCostingCaseStatus.READY,
|
status: BomCostingCaseStatus.READY,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
statusMessage: `核算完成,共 ${input.skus.length} 个 SKU`,
|
statusMessage: input.completedMessage ?? `核算完成,共 ${input.skus.length} 个 SKU`,
|
||||||
rawAnalysisSnapshot: input.analysis,
|
rawAnalysisSnapshot: input.analysis,
|
||||||
processingFinishedAt: new Date(),
|
processingFinishedAt: new Date(),
|
||||||
taskEvents: {
|
taskEvents: {
|
||||||
@ -1639,7 +1640,7 @@ export class BomCostingRepository {
|
|||||||
eventType: 'RESULT_SAVED',
|
eventType: 'RESULT_SAVED',
|
||||||
status: BomCostingCaseStatus.READY,
|
status: BomCostingCaseStatus.READY,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
message: `核算完成,共 ${input.skus.length} 个 SKU`,
|
message: input.completedMessage ?? `核算完成,共 ${input.skus.length} 个 SKU`,
|
||||||
payload: input.eventPayload,
|
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 =
|
const originalSynthesisBatchMaxDuration =
|
||||||
process.env.BOM_COSTING_SYNTHESIS_BATCH_MAX_DURATION_MS
|
process.env.BOM_COSTING_SYNTHESIS_BATCH_MAX_DURATION_MS
|
||||||
const originalMaxSkusPerCase = process.env.BOM_COSTING_MAX_SKUS_PER_CASE
|
const originalMaxSkusPerCase = process.env.BOM_COSTING_MAX_SKUS_PER_CASE
|
||||||
|
const originalDetailedSynthesis = process.env.BOM_COSTING_DETAILED_SYNTHESIS
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.BOM_COSTING_AI_FACT_BATCH_SIZE = '6'
|
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
|
delete process.env.BOM_COSTING_AI_TIMEOUT_MS
|
||||||
jest.spyOn(providerConfigService, 'getActive').mockResolvedValue({
|
jest.spyOn(providerConfigService, 'getActive').mockResolvedValue({
|
||||||
id: 'config-1',
|
id: 'config-1',
|
||||||
@ -276,9 +285,36 @@ describe('BOM 多模态分析器', () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.BOM_COSTING_MAX_SKUS_PER_CASE = originalMaxSkusPerCase
|
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()
|
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 张限制影响', () => {
|
it('分析全部去重后的主图、SKU 图和详情图,不受旧 8 张限制影响', () => {
|
||||||
process.env.BOM_COSTING_AI_MAX_IMAGES = '20'
|
process.env.BOM_COSTING_AI_MAX_IMAGES = '20'
|
||||||
const largeSnapshot: MarketDetailSnapshot = {
|
const largeSnapshot: MarketDetailSnapshot = {
|
||||||
@ -915,7 +951,7 @@ describe('BOM 多模态分析器', () => {
|
|||||||
).toBe(false)
|
).toBe(false)
|
||||||
expect(requestBody).toMatchObject({
|
expect(requestBody).toMatchObject({
|
||||||
reasoning_effort: 'minimal',
|
reasoning_effort: 'minimal',
|
||||||
max_tokens: 12_000,
|
max_tokens: 6_000,
|
||||||
})
|
})
|
||||||
expect(output.trace).toMatchObject({
|
expect(output.trace).toMatchObject({
|
||||||
imageCount: 4,
|
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 () => {
|
it('明确提高安全上限后才将高规格商品拆成多个汇总批次', async () => {
|
||||||
process.env.BOM_COSTING_SYNTHESIS_SKU_BATCH_SIZE = '8'
|
process.env.BOM_COSTING_SYNTHESIS_SKU_BATCH_SIZE = '8'
|
||||||
process.env.BOM_COSTING_SYNTHESIS_BATCH_CONCURRENCY = '3'
|
process.env.BOM_COSTING_SYNTHESIS_BATCH_CONCURRENCY = '3'
|
||||||
@ -1282,7 +1372,7 @@ describe('BOM 多模态分析器', () => {
|
|||||||
existingFactSnapshot: createCompleteFactSnapshot(snapshot),
|
existingFactSnapshot: createCompleteFactSnapshot(snapshot),
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
'第 1/1 批规格结构在 8 分钟内未完成,任务已停止以避免长时间占用',
|
'多模态 BOM 合并超时:第 1/1 批规格结构在 8 分钟内未完成',
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import {
|
|||||||
type BomCostingFactBatchProgress,
|
type BomCostingFactBatchProgress,
|
||||||
type BomCostingFactExtractionTrace,
|
type BomCostingFactExtractionTrace,
|
||||||
} from './bom-costing-image-fact-extractor'
|
} from './bom-costing-image-fact-extractor'
|
||||||
|
import { buildFactBasedCompetitorAnalysis } from './bom-costing-fact-based-structure'
|
||||||
import {
|
import {
|
||||||
callBomCostingMultimodalModel,
|
callBomCostingMultimodalModel,
|
||||||
getBomCostingMultimodalProviderConfig,
|
getBomCostingMultimodalProviderConfig,
|
||||||
@ -237,12 +238,19 @@ const DEFAULT_AUTOMATIC_SKU_SAFETY_LIMIT = 120
|
|||||||
// minutes of work. Keep a lower default safety gate; a deliberately configured
|
// minutes of work. Keep a lower default safety gate; a deliberately configured
|
||||||
// high-volume workflow may raise it up to the hard contract ceiling.
|
// high-volume workflow may raise it up to the hard contract ceiling.
|
||||||
const DEFAULT_SYNTHESIS_SKU_BATCH_SIZE = 8
|
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_SYNTHESIS_BATCH_CONCURRENCY = 3
|
||||||
|
const DEFAULT_GPT5_SYNTHESIS_BATCH_CONCURRENCY = 2
|
||||||
// A synthesis batch is allowed to split a malformed or slow model response,
|
// 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
|
// 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
|
// clock budget, one 4-SKU batch can recursively split and spend many minutes
|
||||||
// retrying while the worker remains occupied.
|
// retrying while the worker remains occupied.
|
||||||
const DEFAULT_SYNTHESIS_BATCH_MAX_DURATION_MS = 8 * 60_000
|
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_EXPLICIT_FACT_SKU_IDS = 20
|
||||||
const MAX_SYNTHESIS_CONTRACT_ATTEMPTS = 2
|
const MAX_SYNTHESIS_CONTRACT_ATTEMPTS = 2
|
||||||
const SYNTHESIS_IMAGE_REFERENCE_PREFIX = 'bom-image:'
|
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(
|
function describeHighCardinalitySkuCatalog(
|
||||||
skus: MarketSkuSnapshot[],
|
skus: MarketSkuSnapshot[],
|
||||||
safetyLimit: number,
|
safetyLimit: number,
|
||||||
@ -305,6 +317,7 @@ function describeHighCardinalitySkuCatalog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BomCostingMultimodalTrace {
|
export interface BomCostingMultimodalTrace {
|
||||||
|
pipeline?: 'FACT_BASED_STRUCTURE' | 'DETAILED_SYNTHESIS'
|
||||||
model: string
|
model: string
|
||||||
callId: string | null
|
callId: string | null
|
||||||
imageCount: number
|
imageCount: number
|
||||||
@ -595,8 +608,12 @@ function buildUserText(
|
|||||||
title: snapshot.title,
|
title: snapshot.title,
|
||||||
shopName: snapshot.shopName,
|
shopName: snapshot.shopName,
|
||||||
detailUrl: snapshot.detailUrl,
|
detailUrl: snapshot.detailUrl,
|
||||||
skuCount: snapshot.skus.length,
|
// sourceSkuCount is context only. skuTruth below is the complete and
|
||||||
batchSkuCount: skus.length,
|
// 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),
|
skuTruth: skus.map(serializeSkuForPrompt),
|
||||||
installationKnowledge: buildSynthesisInstallationKnowledge(snapshot),
|
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(
|
const batchSize = readBomCostingBoundedInteger(
|
||||||
process.env.BOM_COSTING_SYNTHESIS_SKU_BATCH_SIZE,
|
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,
|
1,
|
||||||
120,
|
120,
|
||||||
)
|
)
|
||||||
@ -617,19 +640,23 @@ function splitSkuBatches(skus: MarketSkuSnapshot[]) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSynthesisBatchConcurrency() {
|
function getSynthesisBatchConcurrency(model: string) {
|
||||||
return readBomCostingBoundedInteger(
|
return readBomCostingBoundedInteger(
|
||||||
process.env.BOM_COSTING_SYNTHESIS_BATCH_CONCURRENCY,
|
process.env.BOM_COSTING_SYNTHESIS_BATCH_CONCURRENCY,
|
||||||
DEFAULT_SYNTHESIS_BATCH_CONCURRENCY,
|
isGpt5CompatibleModel(model)
|
||||||
|
? DEFAULT_GPT5_SYNTHESIS_BATCH_CONCURRENCY
|
||||||
|
: DEFAULT_SYNTHESIS_BATCH_CONCURRENCY,
|
||||||
1,
|
1,
|
||||||
3,
|
3,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSynthesisBatchMaxDurationMs() {
|
function getSynthesisBatchMaxDurationMs(model: string) {
|
||||||
return readBomCostingBoundedInteger(
|
return readBomCostingBoundedInteger(
|
||||||
process.env.BOM_COSTING_SYNTHESIS_BATCH_MAX_DURATION_MS,
|
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,
|
60_000,
|
||||||
10 * 60_000,
|
10 * 60_000,
|
||||||
)
|
)
|
||||||
@ -643,7 +670,7 @@ function createSynthesisBatchDeadlineError(input: {
|
|||||||
const durationMinutes = Math.ceil(input.maxDurationMs / 60_000)
|
const durationMinutes = Math.ceil(input.maxDurationMs / 60_000)
|
||||||
return new AppError(
|
return new AppError(
|
||||||
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
|
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)
|
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(
|
const configuredTimeoutMs = readBomCostingBoundedInteger(
|
||||||
process.env.BOM_COSTING_AI_TIMEOUT_MS,
|
process.env.BOM_COSTING_AI_TIMEOUT_MS,
|
||||||
110_000,
|
110_000,
|
||||||
30_000,
|
30_000,
|
||||||
300_000,
|
300_000,
|
||||||
)
|
)
|
||||||
const timeoutMs = /^gpt-5(?:[.-]|$)/i.test(config.model)
|
const timeoutMs = isGpt5CompatibleModel(config.model)
|
||||||
? Math.min(configuredTimeoutMs, 110_000)
|
? Math.min(configuredTimeoutMs, 110_000)
|
||||||
: configuredTimeoutMs
|
: configuredTimeoutMs
|
||||||
const configuredMaxTokens = readBomCostingBoundedInteger(
|
const configuredMaxTokens = readBomCostingBoundedInteger(
|
||||||
@ -985,11 +1062,19 @@ export class BomCostingMultimodalAnalyzer {
|
|||||||
// A structured BOM for one product should fit in this bounded response;
|
// A structured BOM for one product should fit in this bounded response;
|
||||||
// allowing a larger hidden reasoning/output budget only makes a stalled
|
// allowing a larger hidden reasoning/output budget only makes a stalled
|
||||||
// synthesis block the whole task until the upstream gateway terminates it.
|
// synthesis block the whole task until the upstream gateway terminates it.
|
||||||
const maxTokens = /^gpt-5(?:[.-]|$)/i.test(config.model)
|
const getSynthesisMaxTokens = (skuCount: number) => {
|
||||||
? Math.min(configuredMaxTokens, 12_000)
|
if (!isGpt5CompatibleModel(config.model)) return configuredMaxTokens
|
||||||
: configuredMaxTokens
|
// A single SKU still has to contain a complete materials/process/labor/
|
||||||
const skuBatches = splitSkuBatches(input.snapshot.skus)
|
// packaging BOM, but a 12k response allowance invites verbose JSON that
|
||||||
const synthesisBatchConcurrency = getSynthesisBatchConcurrency()
|
// 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>[] = []
|
const analyzedSkus: z.infer<typeof competitorSkuSchema>[] = []
|
||||||
let product: z.infer<typeof aiProductSchema> | null = null
|
let product: z.infer<typeof aiProductSchema> | null = null
|
||||||
let model = config.model
|
let model = config.model
|
||||||
@ -1038,7 +1123,9 @@ export class BomCostingMultimodalAnalyzer {
|
|||||||
skuBatch: MarketSkuSnapshot[],
|
skuBatch: MarketSkuSnapshot[],
|
||||||
) => {
|
) => {
|
||||||
const synthesisBatchStartedAt = Date.now()
|
const synthesisBatchStartedAt = Date.now()
|
||||||
const synthesisBatchMaxDurationMs = getSynthesisBatchMaxDurationMs()
|
const synthesisBatchMaxDurationMs = getSynthesisBatchMaxDurationMs(
|
||||||
|
config.model,
|
||||||
|
)
|
||||||
const getRemainingSynthesisBatchDurationMs = () => {
|
const getRemainingSynthesisBatchDurationMs = () => {
|
||||||
const remainingMs =
|
const remainingMs =
|
||||||
synthesisBatchMaxDurationMs - (Date.now() - synthesisBatchStartedAt)
|
synthesisBatchMaxDurationMs - (Date.now() - synthesisBatchStartedAt)
|
||||||
@ -1131,7 +1218,7 @@ export class BomCostingMultimodalAnalyzer {
|
|||||||
skuSegment,
|
skuSegment,
|
||||||
{ contractRetry: contractAttempt > 1 },
|
{ contractRetry: contractAttempt > 1 },
|
||||||
),
|
),
|
||||||
maxTokens,
|
maxTokens: getSynthesisMaxTokens(skuSegment.length),
|
||||||
timeoutMs: Math.min(
|
timeoutMs: Math.min(
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
remainingSynthesisBatchDurationMs,
|
remainingSynthesisBatchDurationMs,
|
||||||
@ -1183,6 +1270,13 @@ export class BomCostingMultimodalAnalyzer {
|
|||||||
finishReason: responsePayload.finishReason,
|
finishReason: responsePayload.finishReason,
|
||||||
issues: describeAiOutputIssues(error),
|
issues: describeAiOutputIssues(error),
|
||||||
errorName: error instanceof Error ? error.name : 'UnknownError',
|
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 (contractAttempt >= MAX_SYNTHESIS_CONTRACT_ATTEMPTS) {
|
||||||
if (skuSegment.length > 1) {
|
if (skuSegment.length > 1) {
|
||||||
@ -1338,6 +1432,7 @@ export class BomCostingMultimodalAnalyzer {
|
|||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
trace: {
|
trace: {
|
||||||
|
pipeline: 'DETAILED_SYNTHESIS',
|
||||||
model,
|
model,
|
||||||
callId,
|
callId,
|
||||||
imageCount: factOutput.snapshot.imageFacts.length,
|
imageCount: factOutput.snapshot.imageFacts.length,
|
||||||
|
|||||||
@ -71,6 +71,13 @@ describe('BOM 多模态网关', () => {
|
|||||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('规格汇总达到自身时限时属于可自动续跑的临时故障', () => {
|
||||||
|
expect(isBomCostingMultimodalTransientError(new AppError(
|
||||||
|
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
|
||||||
|
'多模态 BOM 合并超时:第 1/4 批规格结构在 3 分钟内未完成;已保存的图片事实和已完成规格会自动续跑',
|
||||||
|
))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('把短暂的模型连接故障标为可自动续跑,保留配置或结构错误的失败提示', () => {
|
it('把短暂的模型连接故障标为可自动续跑,保留配置或结构错误的失败提示', () => {
|
||||||
expect(isBomCostingMultimodalTransientError(new AppError(
|
expect(isBomCostingMultimodalTransientError(new AppError(
|
||||||
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
|
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
|
||||||
|
|||||||
@ -382,6 +382,7 @@ export function isBomCostingMultimodalTransientError(error: unknown) {
|
|||||||
error.code === ErrorCode.BOM_COSTING_INVALID_PROVIDER &&
|
error.code === ErrorCode.BOM_COSTING_INVALID_PROVIDER &&
|
||||||
(
|
(
|
||||||
error.message.startsWith('多模态 BOM 分析超时') ||
|
error.message.startsWith('多模态 BOM 分析超时') ||
|
||||||
|
error.message.startsWith('多模态 BOM 合并超时') ||
|
||||||
error.message.startsWith('多模态分析服务处理超时') ||
|
error.message.startsWith('多模态分析服务处理超时') ||
|
||||||
error.message.startsWith('多模态分析服务暂时无法连接') ||
|
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 () => {
|
it('预估成本高于竞品售价时拒绝把结果写成已完成', async () => {
|
||||||
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
|
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
|
||||||
.mockResolvedValue(factAnalysisRecord(
|
.mockResolvedValue(factAnalysisRecord(
|
||||||
|
|||||||
@ -957,7 +957,7 @@ export class BomCostingProcessingService {
|
|||||||
status: BomCostingCaseStatus.ANALYZING,
|
status: BomCostingCaseStatus.ANALYZING,
|
||||||
progress: 77,
|
progress: 77,
|
||||||
message: failedImages.length > 0
|
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} 张图片事实已就绪`,
|
: `全部 ${result.snapshot.imageFacts.length} 张图片事实已就绪`,
|
||||||
payload: {
|
payload: {
|
||||||
factAnalysisId,
|
factAnalysisId,
|
||||||
@ -977,8 +977,8 @@ export class BomCostingProcessingService {
|
|||||||
status: BomCostingCaseStatus.ANALYZING,
|
status: BomCostingCaseStatus.ANALYZING,
|
||||||
progress: 78,
|
progress: 78,
|
||||||
message: failedImages.length > 0
|
message: failedImages.length > 0
|
||||||
? `正在基于已就绪图片事实汇总各 SKU 的结构化 BOM;${failedImages.length} 张图片待重试`
|
? `正在基于已就绪图片事实整理各 SKU 的结构化 BOM;${failedImages.length} 张图片待重试`
|
||||||
: '正在合并逐图事实并生成各 SKU 的结构化 BOM',
|
: '正在按逐图事实整理各 SKU 的结构化 BOM',
|
||||||
payload: {
|
payload: {
|
||||||
runId: analysisRunId,
|
runId: analysisRunId,
|
||||||
factAnalysisId,
|
factAnalysisId,
|
||||||
@ -999,7 +999,7 @@ export class BomCostingProcessingService {
|
|||||||
status: BomCostingCaseStatus.ANALYZING,
|
status: BomCostingCaseStatus.ANALYZING,
|
||||||
progress: caseProgress,
|
progress: caseProgress,
|
||||||
message:
|
message:
|
||||||
`正在并行汇总第 ${progress.batchIndex}/${progress.totalBatches} 批规格结构` +
|
`正在整理第 ${progress.batchIndex}/${progress.totalBatches} 批规格结构` +
|
||||||
`(${progress.batchSkuCount} 个 SKU)`,
|
`(${progress.batchSkuCount} 个 SKU)`,
|
||||||
payload: {
|
payload: {
|
||||||
batchIndex: progress.batchIndex,
|
batchIndex: progress.batchIndex,
|
||||||
@ -1029,7 +1029,7 @@ export class BomCostingProcessingService {
|
|||||||
status: BomCostingCaseStatus.ANALYZING,
|
status: BomCostingCaseStatus.ANALYZING,
|
||||||
progress: caseProgress,
|
progress: caseProgress,
|
||||||
message:
|
message:
|
||||||
`已完成 ${progress.completedSkuCount}/${analysisInputStats.inputSkuCount} 个 SKU 的结构汇总`,
|
`已完成 ${progress.completedSkuCount}/${analysisInputStats.inputSkuCount} 个 SKU 的结构整理`,
|
||||||
payload: {
|
payload: {
|
||||||
batchIndex: progress.batchIndex,
|
batchIndex: progress.batchIndex,
|
||||||
totalBatches: progress.totalBatches,
|
totalBatches: progress.totalBatches,
|
||||||
@ -1047,6 +1047,8 @@ export class BomCostingProcessingService {
|
|||||||
const persistedResult = archivedSource
|
const persistedResult = archivedSource
|
||||||
? replaceAnalysisImageUrlsWithStableArchive(output.result, archivedSource)
|
? replaceAnalysisImageUrlsWithStableArchive(output.result, archivedSource)
|
||||||
: output.result
|
: output.result
|
||||||
|
const isFactBasedStructure =
|
||||||
|
output.trace.pipeline === 'FACT_BASED_STRUCTURE'
|
||||||
await bomCostingRepository.updateCaseStage(caseId, {
|
await bomCostingRepository.updateCaseStage(caseId, {
|
||||||
eventType: 'MODEL_ANALYSIS_COMPLETED',
|
eventType: 'MODEL_ANALYSIS_COMPLETED',
|
||||||
status: BomCostingCaseStatus.ANALYZING,
|
status: BomCostingCaseStatus.ANALYZING,
|
||||||
@ -1065,7 +1067,9 @@ export class BomCostingProcessingService {
|
|||||||
eventType: 'PRICE_MATCH_STARTED',
|
eventType: 'PRICE_MATCH_STARTED',
|
||||||
status: BomCostingCaseStatus.ANALYZING,
|
status: BomCostingCaseStatus.ANALYZING,
|
||||||
progress: 86,
|
progress: 86,
|
||||||
message: '正在匹配基础资料库价格并计算成本',
|
message: isFactBasedStructure
|
||||||
|
? '正在匹配可用的基础资料库价格;其余成本项将保留待补充'
|
||||||
|
: '正在匹配基础资料库价格并计算成本',
|
||||||
})
|
})
|
||||||
const candidates = await bomCostingRepository.listPriceCandidates()
|
const candidates = await bomCostingRepository.listPriceCandidates()
|
||||||
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
|
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
|
||||||
@ -1073,6 +1077,7 @@ export class BomCostingProcessingService {
|
|||||||
analysis: persistedResult,
|
analysis: persistedResult,
|
||||||
sourceSnapshot: marketSnapshot,
|
sourceSnapshot: marketSnapshot,
|
||||||
preparedSkus,
|
preparedSkus,
|
||||||
|
allowIncompleteCosting: isFactBasedStructure,
|
||||||
})
|
})
|
||||||
const stats = buildResultStats(preparedSkus)
|
const stats = buildResultStats(preparedSkus)
|
||||||
const completedFailedFactImages = buildFailedFactImagePayload(
|
const completedFailedFactImages = buildFailedFactImagePayload(
|
||||||
@ -1083,7 +1088,9 @@ export class BomCostingProcessingService {
|
|||||||
eventType: 'PRICE_MATCH_COMPLETED',
|
eventType: 'PRICE_MATCH_COMPLETED',
|
||||||
status: BomCostingCaseStatus.ANALYZING,
|
status: BomCostingCaseStatus.ANALYZING,
|
||||||
progress: 95,
|
progress: 95,
|
||||||
message: `已完成 ${stats.bomLineCount} 条 BOM 的价格匹配`,
|
message: isFactBasedStructure
|
||||||
|
? `已整理 ${stats.bomLineCount} 条可继续核算的 BOM 结构`
|
||||||
|
: `已完成 ${stats.bomLineCount} 条 BOM 的价格匹配`,
|
||||||
payload: stats,
|
payload: stats,
|
||||||
})
|
})
|
||||||
await bomCostingRepository.completeCase({
|
await bomCostingRepository.completeCase({
|
||||||
@ -1114,6 +1121,9 @@ export class BomCostingProcessingService {
|
|||||||
completionTokens: output.trace.completionTokens,
|
completionTokens: output.trace.completionTokens,
|
||||||
durationMs: output.trace.durationMs,
|
durationMs: output.trace.durationMs,
|
||||||
analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,
|
analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,
|
||||||
|
completedMessage: isFactBasedStructure
|
||||||
|
? `SKU 结构已就绪,共 ${preparedSkus.length} 个 SKU;成本项待补充`
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = readErrorMessage(error)
|
const message = readErrorMessage(error)
|
||||||
|
|||||||
@ -140,7 +140,7 @@ describe('BOM 核算结果准备', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('资料库价格缺少计价所需尺寸时回退到可计算的模型备用估价', () => {
|
it('板材缺少自身裁切尺寸时不按整板或备用估价计算', () => {
|
||||||
const candidates = emptyCandidates()
|
const candidates = emptyCandidates()
|
||||||
candidates.BOARD = [{
|
candidates.BOARD = [{
|
||||||
libraryType: 'SUBSTRATE',
|
libraryType: 'SUBSTRATE',
|
||||||
@ -173,17 +173,17 @@ describe('BOM 核算结果准备', () => {
|
|||||||
const [sku] = prepareBomCostingAnalysis(missingBoardDimensions, candidates)
|
const [sku] = prepareBomCostingAnalysis(missingBoardDimensions, candidates)
|
||||||
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
||||||
|
|
||||||
expect(sku?.estimatedCost).toBeGreaterThan(0)
|
expect(sku?.estimatedCost).toBeNull()
|
||||||
expect(board).toMatchObject({
|
expect(board).toMatchObject({
|
||||||
matchStatus: BomCostingMatchStatus.NEEDS_CONFIRMATION,
|
matchStatus: BomCostingMatchStatus.UNMATCHED,
|
||||||
matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE',
|
matchedLibraryType: '',
|
||||||
resolvedUnitPrice: 80,
|
resolvedUnitPrice: null,
|
||||||
calculatedQuantity: 1,
|
calculatedQuantity: 1,
|
||||||
subtotal: 80,
|
subtotal: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('单件桌面板缺少自身长宽时,使用已识别的 SKU 桌面尺寸按面积核算', () => {
|
it('单件板材缺少自身长宽时,不拿 SKU 整体尺寸冒充裁切尺寸', () => {
|
||||||
const missingBoardDimensions: CompetitorAnalysisResultV1 = {
|
const missingBoardDimensions: CompetitorAnalysisResultV1 = {
|
||||||
...analysis,
|
...analysis,
|
||||||
skus: analysis.skus.map((sku) => ({
|
skus: analysis.skus.map((sku) => ({
|
||||||
@ -212,13 +212,14 @@ describe('BOM 核算结果准备', () => {
|
|||||||
)
|
)
|
||||||
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
||||||
|
|
||||||
expect(sku?.estimatedCost).toBeGreaterThan(0)
|
expect(sku?.estimatedCost).toBeNull()
|
||||||
expect(board).toMatchObject({
|
expect(board).toMatchObject({
|
||||||
matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE',
|
matchStatus: BomCostingMatchStatus.UNMATCHED,
|
||||||
lengthMm: 1200,
|
matchedLibraryType: '',
|
||||||
widthMm: 600,
|
lengthMm: null,
|
||||||
calculatedQuantity: 0.72,
|
widthMm: null,
|
||||||
subtotal: 57.6,
|
calculatedQuantity: 1,
|
||||||
|
subtotal: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CompetitorAnalysisResultV1,
|
CompetitorAnalysisResultV1,
|
||||||
CompetitorBomLineV1,
|
CompetitorBomLineV1,
|
||||||
CompetitorSkuV1,
|
|
||||||
} from '@/lib/bom-costing/contracts'
|
} from '@/lib/bom-costing/contracts'
|
||||||
import {
|
import {
|
||||||
calculateBomCostingLine,
|
calculateBomCostingLine,
|
||||||
@ -84,32 +83,13 @@ function createExternalEstimateCandidate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSquareMeterUnit(unit: string) {
|
function resolvePricingDimensions(line: CompetitorBomLineV1) {
|
||||||
return ['㎡', 'm²', 'm2', '平方米'].includes(unit.trim().toLowerCase())
|
// 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.
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lengthMm: line.lengthMm ?? skuDimensions.lengthMm,
|
lengthMm: line.lengthMm,
|
||||||
widthMm: line.widthMm ?? skuDimensions.widthMm,
|
widthMm: line.widthMm,
|
||||||
heightMm: line.heightMm,
|
heightMm: line.heightMm,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,22 +107,29 @@ function prepareLine(
|
|||||||
line: CompetitorBomLineV1,
|
line: CompetitorBomLineV1,
|
||||||
candidates: Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>,
|
candidates: Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>,
|
||||||
sortOrder: number,
|
sortOrder: number,
|
||||||
skuDimensions: CompetitorSkuV1['dimensions'],
|
|
||||||
): BomCostingPreparedLine {
|
): BomCostingPreparedLine {
|
||||||
const category = line.category as BomCostingLineCategory
|
const category = line.category as BomCostingLineCategory
|
||||||
const lossFactor = resolveLossFactor(line)
|
const lossFactor = resolveLossFactor(line)
|
||||||
const pricingDimensions = resolvePricingDimensions(line, skuDimensions)
|
const pricingDimensions = resolvePricingDimensions(line)
|
||||||
const externalEstimate = createExternalEstimateCandidate(line)
|
const hasBoardCutDimensions =
|
||||||
const libraryEstimate = resolveBomCostingLibraryEstimate(candidates[category], {
|
line.category !== 'BOARD' ||
|
||||||
code: line.materialCode,
|
(Boolean(line.lengthMm) && Boolean(line.widthMm))
|
||||||
name: line.materialName || line.partName,
|
const externalEstimate = hasBoardCutDimensions
|
||||||
spec: line.materialSpec,
|
? createExternalEstimateCandidate(line)
|
||||||
thicknessMm: line.category === 'BOARD' ? line.heightMm : null,
|
: null
|
||||||
packagingKind:
|
const libraryEstimate = resolveBomCostingLibraryEstimate(
|
||||||
line.category === 'PACKAGING'
|
hasBoardCutDimensions ? candidates[category] : [],
|
||||||
? normalizePackagingKind(line.packagingKind)
|
{
|
||||||
: null,
|
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 = (
|
const calculateWithCandidate = (
|
||||||
candidate: BomCostingLibraryCandidate | null,
|
candidate: BomCostingLibraryCandidate | null,
|
||||||
) => calculateBomCostingLine({
|
) => calculateBomCostingLine({
|
||||||
@ -219,7 +206,7 @@ export function prepareBomCostingAnalysis(
|
|||||||
) {
|
) {
|
||||||
return analysis.skus.map<BomCostingPreparedSku>((sku, skuIndex) => {
|
return analysis.skus.map<BomCostingPreparedSku>((sku, skuIndex) => {
|
||||||
const bomLines = sku.bomLines.map((line, lineIndex) =>
|
const bomLines = sku.bomLines.map((line, lineIndex) =>
|
||||||
prepareLine(line, candidates, lineIndex, sku.dimensions),
|
prepareLine(line, candidates, lineIndex),
|
||||||
)
|
)
|
||||||
const summary = summarizeBomCosting(
|
const summary = summarizeBomCosting(
|
||||||
bomLines.map((line) => ({
|
bomLines.map((line) => ({
|
||||||
|
|||||||
@ -57,7 +57,7 @@ function buildAnalysis(
|
|||||||
|
|
||||||
function buildPreparedSku(
|
function buildPreparedSku(
|
||||||
externalSkuId: string,
|
externalSkuId: string,
|
||||||
estimatedCost = 120,
|
estimatedCost: number | null = 120,
|
||||||
attributes: Record<string, unknown> = {},
|
attributes: Record<string, unknown> = {},
|
||||||
): BomCostingPreparedSku {
|
): BomCostingPreparedSku {
|
||||||
return {
|
return {
|
||||||
@ -75,7 +75,7 @@ function buildPreparedSku(
|
|||||||
packageHeightMm: null,
|
packageHeightMm: null,
|
||||||
packageWeightKg: null,
|
packageWeightKg: null,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
costCalculatedAt: new Date(),
|
costCalculatedAt: estimatedCost === null ? null : new Date(),
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
bomLines: [],
|
bomLines: [],
|
||||||
}
|
}
|
||||||
@ -145,6 +145,40 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
})).not.toThrow()
|
})).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('能识别带货币符号和“起”字的售价,阻止高于售价的成本', () => {
|
it('能识别带货币符号和“起”字的售价,阻止高于售价的成本', () => {
|
||||||
const analysis = buildAnalysis([{ externalSkuId: 'sku-1', name: '测试 SKU' }])
|
const analysis = buildAnalysis([{ externalSkuId: 'sku-1', name: '测试 SKU' }])
|
||||||
|
|
||||||
|
|||||||
@ -247,9 +247,18 @@ export function assertBomCostingResultQuality(input: {
|
|||||||
analysis: CompetitorAnalysisResultV1
|
analysis: CompetitorAnalysisResultV1
|
||||||
sourceSnapshot: MarketDetailSnapshot | null
|
sourceSnapshot: MarketDetailSnapshot | null
|
||||||
preparedSkus: BomCostingPreparedSku[]
|
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.allowIncompleteCosting) {
|
||||||
if (input.sourceSnapshot) {
|
assertPreparedCostRange(input.preparedSkus)
|
||||||
|
}
|
||||||
|
if (input.sourceSnapshot && !input.allowIncompleteCosting) {
|
||||||
assertDistinctSourceSkuStructures(input.analysis, input.sourceSnapshot)
|
assertDistinctSourceSkuStructures(input.analysis, input.sourceSnapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user