From d41ee6a61a43119cbb5edc83a1b6c0928f9c0e04 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 5 Aug 2026 19:26:10 +0800 Subject: [PATCH 1/2] fix(bom): bound gpt synthesis by real sku --- .../bom-costing-multimodal-analyzer.test.ts | 63 +++++++++++++++- .../bom-costing-multimodal-analyzer.ts | 72 ++++++++++++++----- .../bom-costing-multimodal-gateway.test.ts | 7 ++ .../bom-costing-multimodal-gateway.ts | 1 + 4 files changed, 124 insertions(+), 19 deletions(-) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts index 1f91a998..41041558 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts @@ -229,6 +229,11 @@ describe('BOM 多模态分析器', () => { 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' delete process.env.BOM_COSTING_AI_TIMEOUT_MS jest.spyOn(providerConfigService, 'getActive').mockResolvedValue({ id: 'config-1', @@ -915,7 +920,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 +930,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 +1341,7 @@ describe('BOM 多模态分析器', () => { existingFactSnapshot: createCompleteFactSnapshot(snapshot), }), ).rejects.toThrow( - '第 1/1 批规格结构在 8 分钟内未完成,任务已停止以避免长时间占用', + '多模态 BOM 合并超时:第 1/1 批规格结构在 8 分钟内未完成', ) expect(fetchSpy).toHaveBeenCalledTimes(1) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts index 4cb03407..a78524fe 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts @@ -237,12 +237,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:' @@ -595,8 +602,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 +615,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 +634,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 +664,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} 分钟内未完成;已保存的图片事实和已完成规格会自动续跑`, ) } @@ -972,7 +993,7 @@ export class BomCostingMultimodalAnalyzer { 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 +1006,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[] = [] let product: z.infer | null = null let model = config.model @@ -1038,7 +1067,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 +1162,7 @@ export class BomCostingMultimodalAnalyzer { skuSegment, { contractRetry: contractAttempt > 1 }, ), - maxTokens, + maxTokens: getSynthesisMaxTokens(skuSegment.length), timeoutMs: Math.min( timeoutMs, remainingSynthesisBatchDurationMs, @@ -1183,6 +1214,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) { diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts index 9ef36e02..a40acb27 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts @@ -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, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts index 1fbbdfc9..24389ffd 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts @@ -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('多模态分析输出达到长度上限') || From 87a7e75d2191efbdcb3a0bd72dcda7745234cbc7 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 5 Aug 2026 19:56:34 +0800 Subject: [PATCH 2/2] fix(bom): assemble exact sku structures from image facts --- .../BomCostingWorkbenchClient.test.tsx | 2 + .../bom-costing/BomCostingWorkbenchClient.tsx | 29 +- .../repositories/bom-costing.repository.ts | 5 +- .../bom-costing-fact-based-structure.test.ts | 134 +++++++ .../bom-costing-fact-based-structure.ts | 358 ++++++++++++++++++ .../bom-costing-multimodal-analyzer.test.ts | 31 ++ .../bom-costing-multimodal-analyzer.ts | 57 +++ .../bom-costing-processing.service.test.ts | 43 +++ .../bom-costing-processing.service.ts | 24 +- .../bom-costing-result-preparer.test.ts | 27 +- .../bom-costing-result-preparer.ts | 67 ++-- .../bom-costing-result-quality.test.ts | 38 +- .../bom-costing/bom-costing-result-quality.ts | 13 +- 13 files changed, 754 insertions(+), 74 deletions(-) create mode 100644 furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.test.ts create mode 100644 furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.ts diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx index d2959dd5..9262ba3b 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -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() diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index 6773101e..873f39be 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -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({

- {requiresRecalculation ? '历史成本留档' : '预计出厂成本'} + {requiresRecalculation + ? '历史成本留档' + : hasPendingCost + ? 'SKU 结构已就绪' + : '预计出厂成本'}

{requiresRecalculation ? '当前留档由旧规则生成,不能用于价格判断;重新核算后会保留新的版本。' - : selectedSku.estimatedCost === null - ? '当前旧结果未形成完整估价;按现有事实重新核算即可,无需重新分析图片。' + : hasPendingCost + ? '图片事实与 SKU 结构已保存;材料裁切、加工、人工和包材规则将逐项补齐后形成完整成本。' : '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。'}
- {requiresRecalculation || selectedSku.estimatedCost === null - ? '待重算' + {requiresRecalculation || hasPendingCost + ? pendingCostLabel : `约 ${formatMoney(selectedSku.estimatedCost)}`}
@@ -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({ - {requiresRecalculation ? '待重算' : formatMoney(sku.estimatedCost)} + {requiresRecalculation + ? '待重算' + : sku.estimatedCost === null + ? '待补成本' + : formatMoney(sku.estimatedCost)} ) diff --git a/furniture-3d-system/src/server/repositories/bom-costing.repository.ts b/furniture-3d-system/src/server/repositories/bom-costing.repository.ts index dd2f7682..45a3e464 100644 --- a/furniture-3d-system/src/server/repositories/bom-costing.repository.ts +++ b/furniture-3d-system/src/server/repositories/bom-costing.repository.ts @@ -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, }, }, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.test.ts new file mode 100644 index 00000000..55cef0e6 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.test.ts @@ -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) + }) +}) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.ts new file mode 100644 index 00000000..7f675a99 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-fact-based-structure.ts @@ -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 = {} + 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() + 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(), + }) +} diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts index 41041558..cc5b707f 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts @@ -226,6 +226,7 @@ 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' @@ -234,6 +235,9 @@ describe('BOM 多模态分析器', () => { // 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', @@ -281,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 = { diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts index a78524fe..1ab6b7da 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts @@ -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, @@ -277,6 +278,10 @@ function getAutomaticSkuSafetyLimit() { ) } +function isDetailedSynthesisEnabled() { + return process.env.BOM_COSTING_DETAILED_SYNTHESIS?.trim().toLowerCase() === 'true' +} + function describeHighCardinalitySkuCatalog( skus: MarketSkuSnapshot[], safetyLimit: number, @@ -312,6 +317,7 @@ function describeHighCardinalitySkuCatalog( } export interface BomCostingMultimodalTrace { + pipeline?: 'FACT_BASED_STRUCTURE' | 'DETAILED_SYNTHESIS' model: string callId: string | null imageCount: number @@ -987,6 +993,56 @@ 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, @@ -1376,6 +1432,7 @@ export class BomCostingMultimodalAnalyzer { return { result, trace: { + pipeline: 'DETAILED_SYNTHESIS', model, callId, imageCount: factOutput.snapshot.imageFacts.length, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts index e7d3dac9..a4d7f9ee 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts @@ -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( diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts index 829ef0b6..690862aa 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts @@ -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) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.test.ts index 21ffae83..064b35c4 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.test.ts @@ -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, }) }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts index 0b94b76e..3337f99a 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts @@ -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, 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((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) => ({ diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts index bed23f09..08f3aea2 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts @@ -57,7 +57,7 @@ function buildAnalysis( function buildPreparedSku( externalSkuId: string, - estimatedCost = 120, + estimatedCost: number | null = 120, attributes: Record = {}, ): 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' }]) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts index 2223d201..3477115c 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts @@ -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) } }