From 57b502e1c3f4f1c164b16cbc4b7873980f8c60ed Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 15 Aug 2026 17:06:46 +0800 Subject: [PATCH 1/8] feat(bom-costing): prioritize realtime 1688 procurement estimates --- furniture-3d-system/.env.example | 15 + .../migration.sql | 5 + furniture-3d-system/prisma/schema.prisma | 1 + .../BomCostingWorkbenchClient.test.tsx | 40 ++ .../bom-costing/BomCostingWorkbenchClient.tsx | 74 +++- .../src/lib/bom-costing/client-types.ts | 39 ++ .../bom-costing/dynamic-bom-cost-domain.ts | 45 ++ .../bom-costing-scenario.repository.ts | 4 + .../bom-costing-1688-image-search.test.ts | 115 ++++++ .../bom-costing-1688-image-search.ts | 385 ++++++++++++++++++ .../bom-costing-ai-cost-estimator.ts | 13 + .../bom-costing-scenario.service.test.ts | 72 ++++ .../bom-costing-scenario.service.ts | 75 ++++ 13 files changed, 874 insertions(+), 9 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260815170000_add_bom_costing_realtime_market_quote/migration.sql create mode 100644 furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts create mode 100644 furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts diff --git a/furniture-3d-system/.env.example b/furniture-3d-system/.env.example index e01a9f63..6bf61fe6 100644 --- a/furniture-3d-system/.env.example +++ b/furniture-3d-system/.env.example @@ -135,6 +135,21 @@ BOM_COSTING_AI_MAX_TOKENS=12000 BOM_COSTING_AI_REASONING_EFFORT=low BOM_COSTING_MODELING_PROVIDER= +# BOM 核算实时同款采购预估(1688 图搜图)。 +# 只配置在服务器环境变量中:实时同款价 × 采购系数作为整件采购预估,历史 BOM 仅保留为结构参考。 +# ENDPOINT 可直接填写完整接口;或只填写 BASE_URL,由系统追加 /api/find_product/1.0.0。 +BOM_COSTING_1688_IMAGE_SEARCH_ENDPOINT= +BOM_COSTING_1688_IMAGE_SEARCH_BASE_URL= +BOM_COSTING_1688_IMAGE_SEARCH_API_KEY=replace-with-1688-image-search-api-key +# 供应商若不使用 X-API-KEY,可在此指定实际请求头名称;密钥本身不得提交。 +BOM_COSTING_1688_IMAGE_SEARCH_API_KEY_HEADER=X-API-KEY +BOM_COSTING_1688_IMAGE_SEARCH_TIMEOUT_MS=45000 +BOM_COSTING_1688_IMAGE_SEARCH_MIN_SIMILARITY=0.78 +BOM_COSTING_1688_PROCUREMENT_RATE=0.90 +BOM_COSTING_1688_IMAGE_SEARCH_PAGE_SIZE=10 +BOM_COSTING_1688_IMAGE_SEARCH_PURCHASE_AMOUNT=1 +BOM_COSTING_1688_IMAGE_SEARCH_TAGS=4306497 + # 素材库白底图回传 MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL=https://example.com diff --git a/furniture-3d-system/prisma/migrations/20260815170000_add_bom_costing_realtime_market_quote/migration.sql b/furniture-3d-system/prisma/migrations/20260815170000_add_bom_costing_realtime_market_quote/migration.sql new file mode 100644 index 00000000..8d32d0f5 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260815170000_add_bom_costing_realtime_market_quote/migration.sql @@ -0,0 +1,5 @@ +-- Retain the auditable realtime 1688 search result with every scenario +-- revision. It is a whole-product procurement signal, not a line-level BOM +-- price, therefore it stays as a single JSON snapshot on the scenario. +ALTER TABLE "BomCostingScenario" +ADD COLUMN "realtimeMarketQuote" JSONB; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 4a7ef4e5..b682cfa0 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1379,6 +1379,7 @@ model BomCostingScenario { lossCost Decimal? @db.Decimal(18, 2) competitorPrice Decimal? @db.Decimal(18, 2) priceDelta Decimal? @db.Decimal(18, 2) + realtimeMarketQuote Json? confidence Decimal? @db.Decimal(5, 4) warning String @default("") @db.Text unresolvedCount Int @default(0) 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 0dc95c38..a69d1532 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -2002,6 +2002,24 @@ describe('BomCostingWorkbenchClient', () => { secondarySubtotal?: number aiSubtotal?: number rulesetVersion?: string + realtimeMarketQuote?: { + schemaVersion: 'bom-costing-1688-realtime/v1' + source: '1688_IMAGE_SEARCH' + status: 'AVAILABLE' + sourceImageUrl: string + searchedAt: string + procurementRate: number + marketPrice: number + procurementCost: number + estimateRange: { min: number; max: number } + confidence: number + comparableOfferCount: number + returnedOfferCount: number + warnings: string[] + offers: [] + structuralCost: number + structuralEstimateRange: { min: number; max: number } + } }) => ({ id: input.id, skuId: 'sku-dynamic', @@ -2034,6 +2052,7 @@ describe('BomCostingWorkbenchClient', () => { syncStatus: 'SUCCEEDED', syncCompletedAt: now, rulesetVersion: input.rulesetVersion ?? 'dynamic-bom-cost/v1', + realtimeMarketQuote: input.realtimeMarketQuote ?? null, createdAt: now, boardCutting: [{ sourceLineId: 'line-board', @@ -2207,6 +2226,24 @@ describe('BomCostingWorkbenchClient', () => { subtotal: 800, secondarySubtotal: 550, rulesetVersion: 'dynamic-bom-cost/v6', + realtimeMarketQuote: { + schemaVersion: 'bom-costing-1688-realtime/v1', + source: '1688_IMAGE_SEARCH', + status: 'AVAILABLE', + sourceImageUrl: 'https://images.example.com/desk.jpg', + searchedAt: now, + procurementRate: 0.9, + marketPrice: 1_500, + procurementCost: 1_350, + estimateRange: { min: 1_215, max: 1_485 }, + confidence: 0.84, + comparableOfferCount: 3, + returnedOfferCount: 3, + warnings: [], + offers: [], + structuralCost: 1_350, + structuralEstimateRange: { min: 1_080, max: 1_620 }, + }, }), scenario({ id: 'scenario-substitution-updated', @@ -2521,6 +2558,9 @@ describe('BomCostingWorkbenchClient', () => { expect(screen.queryByText('较同材质')).not.toBeInTheDocument() expect(screen.queryByText('替换后预计')).not.toBeInTheDocument() expect(screen.queryByText('节省 ¥50.00')).not.toBeInTheDocument() + expect(screen.getAllByText('实时采购预估 · 1688 同款').length).toBeGreaterThan(0) + expect(screen.getAllByText(/1688 同款价 ¥1,500\.00 × 90%/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/结构拆解参考 ¥1,350\.00/).length).toBeGreaterThan(0) expect(screen.getAllByText('历史价').length).toBeGreaterThan(0) } finally { global.fetch = originalFetch diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index 2a0d595c..ff0c589d 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -346,6 +346,7 @@ const ESTIMATE_BASIS_LABELS = { SIMILAR_HISTORY: '相似历史 BOM', CATEGORY_COHORT: '同分类历史分布', AI_REFERENCE_ESTIMATE: '多模态模型参考预估', + REALTIME_1688_MARKET: '1688 实时同款采购预估', } as const const STRUCTURE_EVIDENCE_BASIS_LABELS = { @@ -359,6 +360,10 @@ function scenarioEvidenceText( scenario: BomCostingScenarioDetail, structureAnalysis?: BomCostingCaseDetail['structureAnalysis'], ) { + const realtimeQuote = scenario.realtimeMarketQuote?.status === 'AVAILABLE' && + scenario.realtimeMarketQuote.procurementCost !== null + ? scenario.realtimeMarketQuote + : null const basisLabels = Array.from(new Set(scenario.lines.flatMap((line) => ( line.estimateBasis ? [ESTIMATE_BASIS_LABELS[line.estimateBasis]] : [] )))) @@ -394,6 +399,9 @@ function scenarioEvidenceText( 0, ) return [ + realtimeQuote + ? `1688 同款实时价 ${formatMoney(realtimeQuote.marketPrice)} × ${(realtimeQuote.procurementRate * 100).toFixed(0)}%` + : '', `估算区间 ${formatEstimateRange(scenario.estimateRange)}`, `可信度 ${estimateConfidenceLabel(scenario.confidence)}${scenario.confidence !== null ? `(${Math.round(scenario.confidence * 100)}%)` : ''}`, sampleCount > 0 @@ -422,6 +430,13 @@ function scenarioEvidenceText( ].filter(Boolean).join(';') } +function realtimeMarketQuoteForScenario(scenario: BomCostingScenarioDetail | null | undefined) { + const quote = scenario?.realtimeMarketQuote + return quote?.status === 'AVAILABLE' && quote.procurementCost !== null + ? quote + : null +} + function missingScenarioLabel(scenario: BomCostingScenarioDetail | null) { if ( !scenario || @@ -1252,6 +1267,9 @@ function DynamicCostingPanel({ substitutionScenario && targetMaterialId !== savedTargetMaterialId, ) const savedMaterialName = substitutionScenario?.targetMaterialName || materialName + const realtimeMarketQuote = scenarioKind === 'SAME_MATERIAL' + ? realtimeMarketQuoteForScenario(selectedScenario) + : null const latestFailedAfterSuccess = Boolean( costing?.sync.lastFailedAt && (!costing.sync.lastSuccessfulAt || Date.parse(costing.sync.lastFailedAt) > Date.parse(costing.sync.lastSuccessfulAt)), @@ -1265,7 +1283,8 @@ function DynamicCostingPanel({ ['损耗', selectedScenario.lossCost, 'LOSS'], ] : [] - const contributionBase = selectedScenario?.totalCost ?? selectedScenario?.knownCost ?? 0 + const contributionBase = realtimeMarketQuote?.structuralCost ?? + selectedScenario?.totalCost ?? selectedScenario?.knownCost ?? 0 const contributionLines = (selectedScenario?.lines ?? []) .map((line, index) => ({ line, index })) .sort((left, right) => ( @@ -1407,7 +1426,11 @@ function DynamicCostingPanel({
- {scenarioKind === 'SAME_MATERIAL' ? '预计出厂成本 · 同材质基准' : `预计出厂成本 · 材质替换:${selectedScenario.targetMaterialName || materialName}`} + {realtimeMarketQuote + ? '实时采购预估 · 1688 同款' + : scenarioKind === 'SAME_MATERIAL' + ? '预计出厂成本 · 同材质基准' + : `预计出厂成本 · 材质替换:${selectedScenario.targetMaterialName || materialName}`} {dynamicStatusLabel(selectedScenario.status)} @@ -1430,6 +1453,16 @@ function DynamicCostingPanel({ 已计部分 {formatMoney(selectedScenario.knownCost)}(不是整件成本)

) : null} + {realtimeMarketQuote ? ( +

+ 1688 同款实时价 {formatMoney(realtimeMarketQuote.marketPrice)} × {(realtimeMarketQuote.procurementRate * 100).toFixed(0)}% + {realtimeMarketQuote.comparableOfferCount > 0 ? ` · ${realtimeMarketQuote.comparableOfferCount} 条可比同款` : ''} + {realtimeMarketQuote.searchedAt ? ` · 查询于 ${formatActivityTime(realtimeMarketQuote.searchedAt)}` : ''}。 + 结构拆解 {realtimeMarketQuote.structuralCost !== null && realtimeMarketQuote.structuralCost !== undefined + ? `参考 ${formatMoney(realtimeMarketQuote.structuralCost)}` + : '仍在补全'},不会因单项待补价否定本次整件预估。 +

+ ) : null}
{selectedScenario.competitorPrice !== null ? (
@@ -1456,13 +1489,18 @@ function DynamicCostingPanel({
) : null} + {realtimeMarketQuote ? ( +
+ 上方为实时采购预估;以下是结构拆解参考。某个部件暂缺价格时,只标记为待补,不会把整件预估改成失败。 +
+ ) : null}
{categoryRows.map(([label, value, rangeKey]) => (
{label} - {value === null || selectedScenario.totalCost === null ? '核算未完成' : formatEstimatedMoney(value)} - {value !== null && selectedScenario.totalCost !== null ? ( + {value === null ? realtimeMarketQuote ? '待补结构价' : '核算未完成' : formatEstimatedMoney(value)} + {value !== null ? ( 估算区间 {formatEstimateRange(selectedScenario.categoryEstimateRanges?.[rangeKey])} @@ -1827,6 +1865,9 @@ function ResultCanvas({ const selectedDynamicScenario = dynamicScenarioKind === 'SAME_MATERIAL' ? sameMaterialScenario : substitutionScenario + const selectedRealtimeMarketQuote = dynamicScenarioKind === 'SAME_MATERIAL' + ? realtimeMarketQuoteForScenario(selectedDynamicScenario) + : null const dynamicScenarioDifference = sameMaterialScenario?.totalCost !== null && sameMaterialScenario?.totalCost !== undefined && substitutionScenario?.totalCost !== null && substitutionScenario?.totalCost !== undefined ? substitutionScenario.totalCost - sameMaterialScenario.totalCost @@ -2966,9 +3007,11 @@ function ResultCanvas({ {currentCostingIsProcessing ? '新一轮成本处理中' : selectedDynamicScenario - ? dynamicScenarioKind === 'SAME_MATERIAL' - ? '预计出厂成本 · 同材质基准' - : `预计出厂成本 · ${selectedDynamicScenario.targetMaterialName || '材质替换'}` + ? selectedRealtimeMarketQuote + ? '实时采购预估 · 1688 同款' + : dynamicScenarioKind === 'SAME_MATERIAL' + ? '预计出厂成本 · 同材质基准' + : `预计出厂成本 · ${selectedDynamicScenario.targetMaterialName || '材质替换'}` : categorySupportStatus === 'AMBIGUOUS' ? '品类待确认' : categorySupportStatus === 'UNSUPPORTED' @@ -3033,7 +3076,18 @@ function ResultCanvas({ {selectedDynamicScenario ? (
{dynamicStatusLabel(selectedDynamicScenario.status)} - 镜像完成于 {formatActivityTime(selectedDynamicScenario.dataAsOf)} + {selectedRealtimeMarketQuote?.searchedAt + ? `1688 查询于 ${formatActivityTime(selectedRealtimeMarketQuote.searchedAt)}` + : `镜像完成于 ${formatActivityTime(selectedDynamicScenario.dataAsOf)}`} +
+ ) : null} + {selectedRealtimeMarketQuote ? ( +
+ 1688 同款价 {formatMoney(selectedRealtimeMarketQuote.marketPrice)} × {(selectedRealtimeMarketQuote.procurementRate * 100).toFixed(0)}% + {selectedRealtimeMarketQuote.comparableOfferCount > 0 ? ` · ${selectedRealtimeMarketQuote.comparableOfferCount} 条可比同款` : ''}。 + {selectedRealtimeMarketQuote.structuralCost !== null && selectedRealtimeMarketQuote.structuralCost !== undefined + ? ` 结构拆解参考 ${formatMoney(selectedRealtimeMarketQuote.structuralCost)}。` + : ' 结构拆解仍在补全。'}
) : null} {selectedDynamicScenario && selectedDynamicScenario.competitorPrice !== null ? ( @@ -3104,7 +3158,9 @@ function ResultCanvas({ {currentCostingIsProcessing ? '新一轮核算完成前,分层成本只标记为上一轮历史留档,不参与当前判断。' : selectedDynamicScenario - ? selectedDynamicScenario.unresolvedCount > 0 + ? selectedRealtimeMarketQuote + ? `1688 实时采购预估已形成;${selectedDynamicScenario.unresolvedCount > 0 ? `${selectedDynamicScenario.unresolvedCount} 条结构明细待补价,` : ''}下方分类仅作结构拆解参考,不影响本次整件预估。` + : selectedDynamicScenario.unresolvedCount > 0 ? `${selectedDynamicScenario.unresolvedCount} 条明细没有形成预估;本次核算未完成。` : `${scenarioEvidenceText(selectedDynamicScenario, detail.structureAnalysis)};规则 ${selectedDynamicScenario.rulesetVersion}。板材按整板裁切利用率计算,竞品售价只做合理性检查。` : categoryCostBlocked diff --git a/furniture-3d-system/src/lib/bom-costing/client-types.ts b/furniture-3d-system/src/lib/bom-costing/client-types.ts index af30a6a9..b6ac5f85 100644 --- a/furniture-3d-system/src/lib/bom-costing/client-types.ts +++ b/furniture-3d-system/src/lib/bom-costing/client-types.ts @@ -286,12 +286,49 @@ export type BomCostingEstimateBasisValue = | 'SIMILAR_HISTORY' | 'CATEGORY_COHORT' | 'AI_REFERENCE_ESTIMATE' + | 'REALTIME_1688_MARKET' export interface BomCostingEstimateRangeValue { min: number max: number } +export interface BomCostingRealtimeMarketOfferValue { + itemId: string + skuId: string | null + title: string + skuTitle: string | null + detailUrl: string | null + imageUrl: string | null + supplier: string | null + currentPrice: number + similarityScore: number + qualityIndex: number | null + quantityBegin: number | null + unit: string | null + soldCount: number | null + stockAmount: number | null +} + +export interface BomCostingRealtimeMarketQuoteValue { + schemaVersion: 'bom-costing-1688-realtime/v1' + source: '1688_IMAGE_SEARCH' + status: 'AVAILABLE' | 'NO_COMPARABLE_OFFER' | 'NOT_CONFIGURED' | 'IMAGE_UNAVAILABLE' | 'FAILED' + sourceImageUrl: string | null + searchedAt: string | null + procurementRate: number + marketPrice: number | null + procurementCost: number | null + estimateRange: BomCostingEstimateRangeValue | null + confidence: number | null + comparableOfferCount: number + returnedOfferCount: number + warnings: string[] + offers: BomCostingRealtimeMarketOfferValue[] + structuralCost?: number | null + structuralEstimateRange?: BomCostingEstimateRangeValue | null +} + export interface BomCostingScenarioLineDetail { id: string sourceLineId: string | null @@ -362,6 +399,8 @@ export interface BomCostingScenarioDetail { syncStatus: string | null syncCompletedAt: string | null rulesetVersion: string + /** Optional while older persisted scenario records are being upgraded. */ + realtimeMarketQuote?: BomCostingRealtimeMarketQuoteValue | null createdAt: string boardCutting: Array<{ sourceLineId: string | null diff --git a/furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts b/furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts index 6d4e6f6c..2deb5b0a 100644 --- a/furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts +++ b/furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts @@ -57,12 +57,55 @@ export type DynamicBomEstimateBasis = | 'SIMILAR_HISTORY' | 'CATEGORY_COHORT' | 'AI_REFERENCE_ESTIMATE' + | 'REALTIME_1688_MARKET' export interface DynamicBomEstimateRange { min: number max: number } +/** + * A whole-product realtime market signal. It is deliberately separate from + * line-level BOM prices: an image-search offer can anchor procurement cost for + * the finished SKU, but cannot truthfully become a board or hardware quote. + */ +export interface DynamicBomRealtimeMarketOffer { + itemId: string + skuId: string | null + title: string + skuTitle: string | null + detailUrl: string | null + imageUrl: string | null + supplier: string | null + currentPrice: number + similarityScore: number + qualityIndex: number | null + quantityBegin: number | null + unit: string | null + soldCount: number | null + stockAmount: number | null +} + +export interface DynamicBomRealtimeMarketQuote { + schemaVersion: 'bom-costing-1688-realtime/v1' + source: '1688_IMAGE_SEARCH' + status: 'AVAILABLE' | 'NO_COMPARABLE_OFFER' | 'NOT_CONFIGURED' | 'IMAGE_UNAVAILABLE' | 'FAILED' + sourceImageUrl: string | null + searchedAt: string | null + procurementRate: number + marketPrice: number | null + procurementCost: number | null + estimateRange: DynamicBomEstimateRange | null + confidence: number | null + comparableOfferCount: number + returnedOfferCount: number + warnings: string[] + offers: DynamicBomRealtimeMarketOffer[] + /** Structural BOM estimate before the current market anchor is selected. */ + structuralCost?: number | null + structuralEstimateRange?: DynamicBomEstimateRange | null +} + export interface DynamicBomSourceRow { [key: string]: unknown Id?: unknown @@ -335,6 +378,8 @@ export interface DynamicBomScenarioResult { unresolvedCount: number warning: string dataAsOf: string | null + /** Attached by the service after a realtime 1688 image search. */ + realtimeMarketQuote?: DynamicBomRealtimeMarketQuote | null } export interface DynamicBomScenarioCalculationInput { diff --git a/furniture-3d-system/src/server/repositories/bom-costing-scenario.repository.ts b/furniture-3d-system/src/server/repositories/bom-costing-scenario.repository.ts index a608d4fe..0bbb4539 100644 --- a/furniture-3d-system/src/server/repositories/bom-costing-scenario.repository.ts +++ b/furniture-3d-system/src/server/repositories/bom-costing-scenario.repository.ts @@ -222,6 +222,7 @@ type ScenarioCostResultInput = { categoryTotals: Record unresolvedCount: number warning: string + realtimeMarketQuote?: unknown lines: Array> } @@ -269,6 +270,9 @@ async function createScenarioRecord( lossCost: input.result.lossCost, competitorPrice: input.competitorPrice, priceDelta, + realtimeMarketQuote: input.result.realtimeMarketQuote === null || input.result.realtimeMarketQuote === undefined + ? undefined + : input.result.realtimeMarketQuote as Prisma.InputJsonValue, warning: input.result.warning, unresolvedCount: input.result.unresolvedCount, confidence: input.result.confidence, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts new file mode 100644 index 00000000..71021be4 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts @@ -0,0 +1,115 @@ +import { + BomCosting1688ImageSearchService, + buildBomCosting1688RealtimeMarketQuote, + type BomCosting1688ImageSearchConfig, +} from './bom-costing-1688-image-search' + +const config: BomCosting1688ImageSearchConfig = { + endpoint: new URL('https://1688.example.com/api/find_product/1.0.0'), + apiKey: 'test-key', + apiKeyHeader: 'X-API-KEY', + timeoutMs: 10_000, + maxImageBytes: 5 * 1024 * 1024, + minSimilarityScore: 0.78, + procurementRate: 0.9, + pageSize: 10, + purchaseAmount: 1, + tags: '4306497', +} + +describe('buildBomCosting1688RealtimeMarketQuote', () => { + it('uses the robust realtime price centre and the configured procurement rate', () => { + const quote = buildBomCosting1688RealtimeMarketQuote({ + sourceImageUrl: 'https://images.example.com/sku.jpg', + searchedAt: new Date('2026-08-15T10:00:00.000Z'), + procurementRate: 0.9, + minSimilarityScore: 0.78, + returnedOffers: [ + { itemId: 'one', title: '同款 A', score: 0.99, currentPrice: 100 }, + { itemId: 'two', title: '同款 B', score: 0.95, currentPrice: 110 }, + { itemId: 'three', title: '同款 C', score: 0.9, currentPrice: 120 }, + { itemId: 'ignored', title: '低相关', score: 0.6, currentPrice: 1 }, + ], + }) + + expect(quote.status).toBe('AVAILABLE') + expect(quote.marketPrice).toBe(110) + expect(quote.procurementCost).toBe(99) + expect(quote.estimateRange).toEqual({ min: 93.6, max: 104.4 }) + expect(quote.comparableOfferCount).toBe(3) + expect(quote.offers).toHaveLength(3) + }) + + it('does not fabricate a current price from weak or invalid offers', () => { + const quote = buildBomCosting1688RealtimeMarketQuote({ + sourceImageUrl: 'https://images.example.com/sku.jpg', + searchedAt: new Date('2026-08-15T10:00:00.000Z'), + procurementRate: 0.9, + minSimilarityScore: 0.9, + returnedOffers: [ + { itemId: 'weak', title: '弱相似', score: 0.89, currentPrice: 100 }, + { itemId: 'invalid', title: '无价', score: 0.99, currentPrice: null }, + ], + }) + + expect(quote.status).toBe('NO_COMPARABLE_OFFER') + expect(quote.procurementCost).toBeNull() + }) +}) + +describe('BomCosting1688ImageSearchService', () => { + it('sends the SKU image to the configured API and returns the realtime procurement estimate', async () => { + const imageResponse = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'image/jpeg' }), + arrayBuffer: async () => Buffer.from('image-bytes'), + } as unknown as Response + const apiResponse = { + ok: true, + status: 200, + text: async () => JSON.stringify({ + data: { + count: 1, + data: [{ itemId: 1, title: '同款', score: 0.99, currentPrice: 200 }], + }, + }), + } as unknown as Response + const fetch = jest.fn() + .mockResolvedValueOnce(imageResponse) + .mockResolvedValueOnce(apiResponse) + const service = new BomCosting1688ImageSearchService({ + getConfig: () => config, + fetch, + now: () => new Date('2026-08-15T10:00:00.000Z'), + }) + + const quote = await service.findRealtimeQuote({ + imageUrl: 'https://images.example.com/sku.jpg', + }) + + expect(quote.procurementCost).toBe(180) + expect(fetch).toHaveBeenCalledTimes(2) + const request = fetch.mock.calls[1]?.[1] + expect(request?.headers).toEqual(expect.objectContaining({ 'X-API-KEY': 'test-key' })) + expect(JSON.parse(request?.body ?? '{}')).toEqual(expect.objectContaining({ + imageUrl: 'https://images.example.com/sku.jpg', + purchaseAmount: 1, + tags: '4306497', + })) + }) + + it('degrades without a hidden browser fallback when the service is not configured', async () => { + const fetch = jest.fn() + const service = new BomCosting1688ImageSearchService({ + getConfig: () => null, + fetch, + now: () => new Date('2026-08-15T10:00:00.000Z'), + }) + + await expect(service.findRealtimeQuote({ + imageUrl: 'https://images.example.com/sku.jpg', + })).resolves.toMatchObject({ status: 'NOT_CONFIGURED', procurementCost: null }) + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts new file mode 100644 index 00000000..49d79a75 --- /dev/null +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts @@ -0,0 +1,385 @@ +import 'server-only' + +import { z } from 'zod' +import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety' +import type { + DynamicBomRealtimeMarketOffer, + DynamicBomRealtimeMarketQuote, +} from '@/lib/bom-costing/dynamic-bom-cost-domain' + +const IMAGE_SEARCH_PATH = '/api/find_product/1.0.0' +const DEFAULT_IMAGE_MAX_BYTES = 5 * 1024 * 1024 +const DEFAULT_TIMEOUT_MS = 45_000 +const DEFAULT_MINIMUM_SCORE = 0.78 +const DEFAULT_PROCUREMENT_RATE = 0.9 + +const finiteNumber = z.union([z.number(), z.string()]).transform((value, context) => { + const parsed = typeof value === 'number' ? value : Number(value.trim()) + if (!Number.isFinite(parsed)) { + context.addIssue({ code: 'custom', message: '必须是有限数字' }) + return z.NEVER + } + return parsed +}) + +const nullableFiniteNumber = finiteNumber.nullish().transform((value) => value ?? null) + +const imageSearchItemSchema = z.object({ + itemId: z.union([z.string(), z.number()]).transform(String), + title: z.string().trim().max(500).optional().default(''), + imageUrl: z.string().url().optional().nullable().default(null), + detailUrl: z.string().url().optional().nullable().default(null), + score: nullableFiniteNumber, + currentPrice: nullableFiniteNumber, + skuId: z.union([z.string(), z.number()]).optional().nullable().transform((value) => ( + value === null || value === undefined ? null : String(value) + )), + skuTitle: z.string().trim().max(500).optional().nullable().default(null), + yxIndex: nullableFiniteNumber, + quantityBegin: nullableFiniteNumber, + unit: z.string().trim().max(80).optional().nullable().default(null), + company: z.string().trim().max(300).optional().nullable().default(null), + soldOut: nullableFiniteNumber, + stockAmount: nullableFiniteNumber, +}).passthrough() + +const imageSearchResponseSchema = z.object({ + data: z.object({ + data: z.array(imageSearchItemSchema).optional().default([]), + count: z.number().int().nonnegative().optional().default(0), + }).passthrough(), +}).passthrough() + +export interface BomCosting1688ImageSearchConfig { + endpoint: URL + apiKey: string + apiKeyHeader: string + timeoutMs: number + maxImageBytes: number + minSimilarityScore: number + procurementRate: number + pageSize: number + purchaseAmount: number + tags: string +} + +export type BomCosting1688ComparableOffer = DynamicBomRealtimeMarketOffer +export type BomCosting1688RealtimeMarketQuote = DynamicBomRealtimeMarketQuote + +export interface BomCosting1688ImageSearchGateway { + getConfig(): BomCosting1688ImageSearchConfig | null + fetch(input: RequestInfo | URL, init?: RequestInit): Promise + now(): Date +} + +function boundedPositiveInteger( + value: string | undefined, + fallback: number, + minimum: number, + maximum: number, +) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum + ? parsed + : fallback +} + +function boundedDecimal( + value: string | undefined, + fallback: number, + minimum: number, + maximum: number, +) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum + ? parsed + : fallback +} + +export function getBomCosting1688ImageSearchConfig(): BomCosting1688ImageSearchConfig | null { + const explicitEndpoint = process.env.BOM_COSTING_1688_IMAGE_SEARCH_ENDPOINT?.trim() + const baseUrl = process.env.BOM_COSTING_1688_IMAGE_SEARCH_BASE_URL?.trim() + const apiKey = process.env.BOM_COSTING_1688_IMAGE_SEARCH_API_KEY?.trim() + if ((!explicitEndpoint && !baseUrl) || !apiKey) return null + + let endpoint: URL + try { + endpoint = explicitEndpoint + ? new URL(explicitEndpoint) + : new URL(IMAGE_SEARCH_PATH, baseUrl) + } catch { + return null + } + if (endpoint.protocol !== 'https:' && endpoint.protocol !== 'http:') return null + + return { + endpoint, + apiKey, + apiKeyHeader: process.env.BOM_COSTING_1688_IMAGE_SEARCH_API_KEY_HEADER?.trim() || 'X-API-KEY', + timeoutMs: boundedPositiveInteger( + process.env.BOM_COSTING_1688_IMAGE_SEARCH_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + 5_000, + 120_000, + ), + maxImageBytes: boundedPositiveInteger( + process.env.BOM_COSTING_1688_IMAGE_SEARCH_MAX_IMAGE_BYTES, + DEFAULT_IMAGE_MAX_BYTES, + 1_024, + DEFAULT_IMAGE_MAX_BYTES, + ), + minSimilarityScore: boundedDecimal( + process.env.BOM_COSTING_1688_IMAGE_SEARCH_MIN_SIMILARITY, + DEFAULT_MINIMUM_SCORE, + 0.5, + 0.9999, + ), + procurementRate: boundedDecimal( + process.env.BOM_COSTING_1688_PROCUREMENT_RATE, + DEFAULT_PROCUREMENT_RATE, + 0.5, + 1, + ), + pageSize: boundedPositiveInteger( + process.env.BOM_COSTING_1688_IMAGE_SEARCH_PAGE_SIZE, + 10, + 1, + 20, + ), + purchaseAmount: boundedPositiveInteger( + process.env.BOM_COSTING_1688_IMAGE_SEARCH_PURCHASE_AMOUNT, + 1, + 1, + 10_000, + ), + tags: process.env.BOM_COSTING_1688_IMAGE_SEARCH_TAGS?.trim() || '4306497', + } +} + +const defaultGateway: BomCosting1688ImageSearchGateway = { + getConfig: getBomCosting1688ImageSearchConfig, + fetch: (...args) => fetch(...args), + now: () => new Date(), +} + +function roundMoney(value: number) { + return Number(value.toFixed(2)) +} + +function percentile(values: readonly number[], ratio: number) { + if (values.length === 0) return null + const ordered = [...values].sort((left, right) => left - right) + const position = Math.max(0, Math.min(ordered.length - 1, (ordered.length - 1) * ratio)) + const lower = Math.floor(position) + const upper = Math.ceil(position) + const lowerValue = ordered[lower] ?? 0 + const upperValue = ordered[upper] ?? lowerValue + return lowerValue + (upperValue - lowerValue) * (position - lower) +} + +function readBoundedImage(response: Response, maxBytes: number) { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error('1688 同款检索图片超过 5MB 限制') + } + return response.arrayBuffer().then((buffer) => { + const bytes = Buffer.from(buffer) + if (bytes.length > maxBytes) throw new Error('1688 同款检索图片超过 5MB 限制') + return bytes + }) +} + +function emptyQuote(input: Pick< + BomCosting1688RealtimeMarketQuote, + 'status' | 'sourceImageUrl' | 'procurementRate' | 'warnings' +> & Partial>): BomCosting1688RealtimeMarketQuote { + return { + schemaVersion: 'bom-costing-1688-realtime/v1', + source: '1688_IMAGE_SEARCH', + status: input.status, + sourceImageUrl: input.sourceImageUrl, + searchedAt: input.searchedAt ?? null, + procurementRate: input.procurementRate, + marketPrice: null, + procurementCost: null, + estimateRange: null, + confidence: null, + comparableOfferCount: 0, + returnedOfferCount: input.returnedOfferCount ?? 0, + warnings: input.warnings, + offers: input.offers ?? [], + } +} + +export function buildBomCosting1688RealtimeMarketQuote(input: { + sourceImageUrl: string + searchedAt: Date + procurementRate: number + minSimilarityScore: number + returnedOffers: z.infer[] +}): BomCosting1688RealtimeMarketQuote { + const offers = input.returnedOffers.flatMap((item) => { + const price = item.currentPrice + const score = item.score + if ( + price === null || price <= 0 || + score === null || score < input.minSimilarityScore + ) return [] + return [{ + itemId: item.itemId, + skuId: item.skuId, + title: item.title, + skuTitle: item.skuTitle, + detailUrl: item.detailUrl, + imageUrl: item.imageUrl, + supplier: item.company, + currentPrice: roundMoney(price), + similarityScore: Number(score.toFixed(4)), + qualityIndex: item.yxIndex == null ? null : Number(item.yxIndex.toFixed(4)), + quantityBegin: item.quantityBegin == null ? null : Math.max(1, Math.floor(item.quantityBegin)), + unit: item.unit, + soldCount: item.soldOut == null ? null : Math.max(0, Math.floor(item.soldOut)), + stockAmount: item.stockAmount == null ? null : Math.max(0, Math.floor(item.stockAmount)), + } satisfies BomCosting1688ComparableOffer] + }).sort((left, right) => ( + right.similarityScore - left.similarityScore || + (right.qualityIndex ?? -1) - (left.qualityIndex ?? -1) || + left.currentPrice - right.currentPrice || + left.itemId.localeCompare(right.itemId) + )).slice(0, 8) + + if (offers.length === 0) { + return emptyQuote({ + status: 'NO_COMPARABLE_OFFER', + sourceImageUrl: input.sourceImageUrl, + searchedAt: input.searchedAt.toISOString(), + procurementRate: input.procurementRate, + returnedOfferCount: input.returnedOffers.length, + warnings: ['1688 未返回相似度足够且价格有效的同款,未覆盖结构拆解预估'], + }) + } + + const prices = offers.map((offer) => offer.currentPrice) + const marketPrice = percentile(prices, 0.5) ?? prices[0] ?? 0 + const lowerMarketPrice = percentile(prices, 0.2) ?? marketPrice + const upperMarketPrice = percentile(prices, 0.8) ?? marketPrice + const procurementCost = roundMoney(marketPrice * input.procurementRate) + const lower = roundMoney(Math.min(lowerMarketPrice, marketPrice) * input.procurementRate) + const upper = roundMoney(Math.max(upperMarketPrice, marketPrice) * input.procurementRate) + const topScore = offers[0]?.similarityScore ?? input.minSimilarityScore + const confidence = Math.min(0.9, Number(( + 0.35 + topScore * 0.42 + Math.min(offers.length, 4) * 0.045 + ).toFixed(4))) + const warnings = [ + `1688 实时同款价格中位数 ¥${roundMoney(marketPrice)},按采购系数 ${(input.procurementRate * 100).toFixed(0)}% 形成采购预估`, + offers.length < 3 ? '可比同款不足 3 条,区间已按有限样本保守处理' : '', + offers.some((offer) => (offer.quantityBegin ?? 1) > 1) + ? '部分同款存在起批量,已按本次采购数量返回的实时价格估算,需下单前复核阶梯价' : '', + ].filter(Boolean) + return { + schemaVersion: 'bom-costing-1688-realtime/v1', + source: '1688_IMAGE_SEARCH', + status: 'AVAILABLE', + sourceImageUrl: input.sourceImageUrl, + searchedAt: input.searchedAt.toISOString(), + procurementRate: input.procurementRate, + marketPrice: roundMoney(marketPrice), + procurementCost, + estimateRange: { min: lower, max: upper }, + confidence, + comparableOfferCount: offers.length, + returnedOfferCount: input.returnedOffers.length, + warnings, + offers, + } +} + +export class BomCosting1688ImageSearchService { + constructor(private readonly gateway: BomCosting1688ImageSearchGateway = defaultGateway) {} + + async findRealtimeQuote(input: { + imageUrl: string | null | undefined + }): Promise { + const config = this.gateway.getConfig() + const sourceImageUrl = input.imageUrl?.trim() || null + if (!config) { + return emptyQuote({ + status: 'NOT_CONFIGURED', + sourceImageUrl, + procurementRate: DEFAULT_PROCUREMENT_RATE, + warnings: ['1688 实时同款服务尚未配置,当前继续使用结构拆解预估'], + }) + } + if (!sourceImageUrl) { + return emptyQuote({ + status: 'IMAGE_UNAVAILABLE', + sourceImageUrl: null, + procurementRate: config.procurementRate, + warnings: ['该 SKU 没有可用于 1688 同款检索的主图,当前继续使用结构拆解预估'], + }) + } + + try { + const imageUrl = parseSafeCompetitorUrl(sourceImageUrl) + const imageResponse = await this.gateway.fetch(imageUrl, { + method: 'GET', + headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' }, + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(config.timeoutMs), + }) + if (!imageResponse.ok) throw new Error(`图片读取失败(${imageResponse.status})`) + const contentType = imageResponse.headers.get('content-type')?.split(';')[0]?.trim() ?? '' + if (!contentType.startsWith('image/')) throw new Error('SKU 主图不是可检索图片') + const image = await readBoundedImage(imageResponse, config.maxImageBytes) + const response = await this.gateway.fetch(config.endpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + [config.apiKeyHeader]: config.apiKey, + }, + cache: 'no-store', + signal: AbortSignal.timeout(config.timeoutMs), + body: JSON.stringify({ + imgBase64: image.toString('base64'), + imageUrl: sourceImageUrl, + pageSize: config.pageSize, + purchaseAmount: config.purchaseAmount, + scoreLevel: 'high', + tags: config.tags, + }), + }) + const rawBody = await response.text().catch(() => '') + let payload: unknown = null + try { + payload = rawBody ? JSON.parse(rawBody) : null + } catch { + payload = null + } + if (!response.ok) throw new Error(`1688 同款服务返回 ${response.status}`) + const parsed = imageSearchResponseSchema.safeParse(payload) + if (!parsed.success) throw new Error('1688 同款服务返回格式不完整') + return buildBomCosting1688RealtimeMarketQuote({ + sourceImageUrl, + searchedAt: this.gateway.now(), + procurementRate: config.procurementRate, + minSimilarityScore: config.minSimilarityScore, + returnedOffers: parsed.data.data.data, + }) + } catch (error) { + console.warn('[BOM costing] 1688 realtime image search unavailable', { + error: error instanceof Error ? error.message : String(error), + }) + return emptyQuote({ + status: 'FAILED', + sourceImageUrl, + searchedAt: this.gateway.now().toISOString(), + procurementRate: config.procurementRate, + warnings: ['1688 实时同款检索本次未返回可用价格,当前继续使用结构拆解预估'], + }) + } + } +} + +export const bomCosting1688ImageSearchService = new BomCosting1688ImageSearchService() diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-ai-cost-estimator.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-ai-cost-estimator.ts index 7bd85855..07d8aefc 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-ai-cost-estimator.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-ai-cost-estimator.ts @@ -3,6 +3,7 @@ import 'server-only' import { z } from 'zod' import type { DynamicBomPriceQuote, + DynamicBomRealtimeMarketQuote, DynamicBomScenarioKind, } from '@/lib/bom-costing/dynamic-bom-cost-domain' import { @@ -83,6 +84,7 @@ export interface BomCostingAiEstimateInput { requests: BomCostingAiEstimateRequestLine[] knownLines: BomCostingAiKnownLine[] companyReferences: BomCostingAiCompanyReference[] + realtimeMarketReference?: DynamicBomRealtimeMarketQuote | null } export interface BomCostingAiEstimateResult { @@ -198,6 +200,7 @@ function buildSystemPrompt() { return [ '你是家具制造成本预估助手。任务是补齐缺少直接公司价格的 BOM 明细,不是生成正式采购报价。', '公司历史 BOM、公司主数据和已核算明细都是参考证据,不是缺一不可的硬门槛。', + '若提供了 1688 实时同款采购预估,它是整件 SKU 的当前市场锚点:优先用于让缺价明细的总和保持合理,但不得把整件价格伪装成某一块板材或五金的精确单价。', '当没有完全相同的公司记录时,结合部件结构、材质、尺寸、数量、同类公司记录和常见家具制造成本关系,给出合理的工厂成本区间。', '不得因为缺少完全相同的价格来源而拒绝预估,也不得返回 0、null 或把竞品售价倒推成成本。', '优先使用 companyReferences;证据较弱时降低 confidence、扩大区间并在 reason 中说明。', @@ -245,10 +248,20 @@ export class BomCostingAiCostEstimator { })), knownCostLines: input.knownLines.slice(0, 80), companyReferences: referenceRows, + realtimeMarketReference: input.realtimeMarketReference?.status === 'AVAILABLE' + ? { + procurementCost: input.realtimeMarketReference.procurementCost, + estimateRange: input.realtimeMarketReference.estimateRange, + confidence: input.realtimeMarketReference.confidence, + comparableOfferCount: input.realtimeMarketReference.comparableOfferCount, + procurementRate: input.realtimeMarketReference.procurementRate, + } + : null, constraints: { estimatesAreReferenceOnly: true, officialPriceRequired: false, competitorSalePriceProvided: false, + realtimeMarketReferenceProvided: input.realtimeMarketReference?.status === 'AVAILABLE', preserveUncertainty: true, }, } diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts index 7a1bc5ee..5ef44964 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts @@ -13,6 +13,7 @@ import { buildDynamicCostBreakdown, } from './bom-costing-scenario.service' import { bomCostingAiCostEstimator } from './bom-costing-ai-cost-estimator' +import { bomCosting1688ImageSearchService } from './bom-costing-1688-image-search' const now = new Date() @@ -158,6 +159,22 @@ function mockBase(input: { describe('BomCostingScenarioService', () => { beforeEach(() => { + jest.spyOn(bomCosting1688ImageSearchService, 'findRealtimeQuote').mockResolvedValue({ + schemaVersion: 'bom-costing-1688-realtime/v1', + source: '1688_IMAGE_SEARCH', + status: 'NOT_CONFIGURED', + sourceImageUrl: null, + searchedAt: null, + procurementRate: 0.9, + marketPrice: null, + procurementCost: null, + estimateRange: null, + confidence: null, + comparableOfferCount: 0, + returnedOfferCount: 0, + warnings: [], + offers: [], + }) jest.spyOn(bomCostingAiCostEstimator, 'estimateMissingCosts').mockResolvedValue({ quotes: { SAME_MATERIAL: {}, MATERIAL_SUBSTITUTION: {} }, requestedCount: 0, @@ -178,6 +195,61 @@ describe('BomCostingScenarioService', () => { jest.restoreAllMocks() }) + it('prioritizes a realtime 1688 procurement anchor while retaining the structural BOM estimate for audit', async () => { + mockBase({ + bomLines: [line({ + id: 'drawer-hardware', + category: 'ACCESSORY', + partName: '抽屉盒五金', + materialName: '橡胶木', + unit: '套', + })], + }) + jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([ + candidate({ + id: 'history-hardware', + category: 'ACCESSORY', + partName: '抽屉盒五金', + materialName: '橡胶木', + unitPrice: 100, + }), + ]) + jest.mocked(bomCosting1688ImageSearchService.findRealtimeQuote).mockResolvedValue({ + schemaVersion: 'bom-costing-1688-realtime/v1', + source: '1688_IMAGE_SEARCH', + status: 'AVAILABLE', + sourceImageUrl: 'https://images.example.com/sku.jpg', + searchedAt: '2026-08-15T10:00:00.000Z', + procurementRate: 0.9, + marketPrice: 120, + procurementCost: 108, + estimateRange: { min: 99, max: 117 }, + confidence: 0.84, + comparableOfferCount: 3, + returnedOfferCount: 3, + warnings: [], + offers: [], + }) + + await new BomCostingScenarioService().calculateSameMaterial({ + caseId: 'case-1', + skuId: 'sku-1', + }) + + const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]?.[0] + expect(saved?.sameMaterial).toEqual(expect.objectContaining({ + total: 108, + estimateBasis: 'REALTIME_1688_MARKET', + status: 'PROVISIONAL', + realtimeMarketQuote: expect.objectContaining({ + procurementCost: 108, + structuralCost: 100, + structuralEstimateRange: { min: 78, max: 122 }, + }), + })) + expect(bomCostingAiCostEstimator.estimateMissingCosts).not.toHaveBeenCalled() + }) + it('recalculates both scenarios from company prices and substitutes only the target category', async () => { const bomLines = [ line({ diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts index 75afe6f8..876b1b23 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts @@ -10,6 +10,7 @@ import { type DynamicBomLineCostResult, type DynamicBomEstimateBasis, type DynamicBomPriceQuote, + type DynamicBomRealtimeMarketQuote, type DynamicBomScenarioResult, } from '@/lib/bom-costing/dynamic-bom-cost-domain' import { @@ -28,6 +29,9 @@ import { type BomCostingAiEstimateRequestLine, type BomCostingAiKnownLine, } from './bom-costing-ai-cost-estimator' +import { + bomCosting1688ImageSearchService, +} from './bom-costing-1688-image-search' const CATEGORY_LABELS: Record = { BOARD: '板材', @@ -2247,6 +2251,54 @@ function toAiKnownLine( } } +/** + * A 1688 image-search result is a live price for a finished product, not an + * exact quote for every BOM component. We therefore keep the structural BOM + * result in the attached audit object and use the live procurement estimate as + * the SKU-level headline only for the original-material scenario. + */ +function applyRealtime1688ProcurementAnchor( + result: DynamicBomScenarioResult, + quote: DynamicBomRealtimeMarketQuote, +) { + const structuralCost = result.total + const structuralEstimateRange = result.estimateRange + result.realtimeMarketQuote = { + ...quote, + structuralCost, + structuralEstimateRange, + } + if ( + result.scenario !== 'SAME_MATERIAL' || + quote.status !== 'AVAILABLE' || + quote.procurementCost === null || + quote.estimateRange === null + ) return + + result.total = quote.procurementCost + result.estimateRange = quote.estimateRange + result.estimateBasis = 'REALTIME_1688_MARKET' + result.sampleCount = quote.comparableOfferCount + result.confidence = quote.confidence + // A current image-search result is still an estimate, never a formal + // supplier quotation. It can however avoid presenting the whole SKU as + // unpriced merely because one historical BOM line is absent. + result.status = 'PROVISIONAL' + result.warnings.push( + `已优先采用 1688 实时同款采购预估(同款价 × ${(quote.procurementRate * 100).toFixed(0)}%)`, + ) + if (structuralCost !== null) { + result.warnings.push( + `结构拆解预估 ¥${structuralCost.toFixed(2)} 已保留,仅用于解释构成与偏差复核`, + ) + } else { + result.warnings.push( + '结构拆解仍有缺价明细;当前先给出实时同款采购预估,明细需继续补充事实或由模型预估', + ) + } + result.warning = Array.from(new Set(result.warnings)).join(';') +} + function withRequiredEstimateCategories( lines: BomCostingLine[], skuId: string, @@ -2618,6 +2670,9 @@ function serializeScenario(record: BomCostingScenarioRecord) { syncStatus: record.syncRun?.status ?? null, syncCompletedAt: record.syncRun?.completedAt?.toISOString() ?? null, rulesetVersion: record.rulesetVersion, + realtimeMarketQuote: record.realtimeMarketQuote + ? record.realtimeMarketQuote as unknown as DynamicBomRealtimeMarketQuote + : null, createdAt: record.createdAt.toISOString(), boardCutting: readBoardCuttingSummary(record.lines), lines: record.lines.map((line) => ({ @@ -2846,6 +2901,12 @@ export class BomCostingScenarioService { }) { const sku = await bomCostingScenarioRepository.findSkuForScenario(input) if (!sku) throw new AppError(ErrorCode.BOM_COSTING_CASE_NOT_FOUND, 'BOM SKU 不存在或无权访问') + // Start the remote request immediately so it overlaps the local history + // and master-data reads below. A failed/unconfigured search resolves to a + // visible degradation object and never blocks a BOM calculation. + const realtimeMarketQuotePromise = bomCosting1688ImageSearchService.findRealtimeQuote({ + imageUrl: sku.imageUrl, + }) const materials = await bomCostingScenarioRepository.listEnabledMaterials() const target = input.targetMaterialId ? materials.find((material) => material.id === input.targetMaterialId) ?? null @@ -3147,6 +3208,7 @@ export class BomCostingScenarioService { }) } const hasProductStructure = sku.bomLines.length > 0 + const realtimeMarketQuote = await realtimeMarketQuotePromise let comparison = calculateDynamicBomScenarios({ rows, sameMaterialPrices, @@ -3238,6 +3300,7 @@ export class BomCostingScenarioService { requests: aiRequests, knownLines, companyReferences: referenceGroups.flat().slice(0, 80), + realtimeMarketReference: realtimeMarketQuote, }) Object.assign(sameMaterialPrices, aiEstimate.quotes.SAME_MATERIAL) Object.assign(substitutionPrices, aiEstimate.quotes.MATERIAL_SUBSTITUTION) @@ -3374,6 +3437,16 @@ export class BomCostingScenarioService { result.warnings.push(failedWarning) } } + if (hasProductStructure) { + applyRealtime1688ProcurementAnchor(comparison.sameMaterial, realtimeMarketQuote) + // A replacement-material scenario cannot use the original product's + // real-time market price as its total. Retain the same source for audit + // and comparison, but leave its calculated total untouched. + applyRealtime1688ProcurementAnchor(comparison.materialSubstitution, realtimeMarketQuote) + } else { + comparison.sameMaterial.realtimeMarketQuote = realtimeMarketQuote + comparison.materialSubstitution.realtimeMarketQuote = realtimeMarketQuote + } const warning = (result: DynamicBomScenarioResult) => [ ...result.warnings, result.marketPriceAlertKind === 'HIGH' ? '预估成本高于竞品售价,需复核主要驱动项' : '', @@ -3431,6 +3504,7 @@ export class BomCostingScenarioService { categoryTotals: sameBreakdown.categoryTotals, unresolvedCount: comparison.sameMaterial.unresolvedLineCount, warning: warning(comparison.sameMaterial), + realtimeMarketQuote: comparison.sameMaterial.realtimeMarketQuote ?? null, lines: comparison.sameMaterial.lines.map((line) => ( toSavedLine(line, line.materialName) )), @@ -3482,6 +3556,7 @@ export class BomCostingScenarioService { categoryTotals: substitutionBreakdown.categoryTotals, unresolvedCount: comparison.materialSubstitution.unresolvedLineCount, warning: warning(comparison.materialSubstitution), + realtimeMarketQuote: comparison.materialSubstitution.realtimeMarketQuote ?? null, lines: comparison.materialSubstitution.lines.map((line) => ( toSavedLine( line, From 4e892573df15f2bd0706a5e2abe5f781fb1bfc8f Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 17 Aug 2026 18:48:02 +0800 Subject: [PATCH 2/8] fix(bom-costing): sign 1688 image search requests --- furniture-3d-system/.env.example | 27 +- .../bom-costing-1688-image-search.test.ts | 132 +++++++- .../bom-costing-1688-image-search.ts | 284 +++++++++++++++--- 3 files changed, 372 insertions(+), 71 deletions(-) diff --git a/furniture-3d-system/.env.example b/furniture-3d-system/.env.example index 6bf61fe6..6a90b391 100644 --- a/furniture-3d-system/.env.example +++ b/furniture-3d-system/.env.example @@ -135,20 +135,19 @@ BOM_COSTING_AI_MAX_TOKENS=12000 BOM_COSTING_AI_REASONING_EFFORT=low BOM_COSTING_MODELING_PROVIDER= -# BOM 核算实时同款采购预估(1688 图搜图)。 -# 只配置在服务器环境变量中:实时同款价 × 采购系数作为整件采购预估,历史 BOM 仅保留为结构参考。 -# ENDPOINT 可直接填写完整接口;或只填写 BASE_URL,由系统追加 /api/find_product/1.0.0。 -BOM_COSTING_1688_IMAGE_SEARCH_ENDPOINT= -BOM_COSTING_1688_IMAGE_SEARCH_BASE_URL= -BOM_COSTING_1688_IMAGE_SEARCH_API_KEY=replace-with-1688-image-search-api-key -# 供应商若不使用 X-API-KEY,可在此指定实际请求头名称;密钥本身不得提交。 -BOM_COSTING_1688_IMAGE_SEARCH_API_KEY_HEADER=X-API-KEY -BOM_COSTING_1688_IMAGE_SEARCH_TIMEOUT_MS=45000 -BOM_COSTING_1688_IMAGE_SEARCH_MIN_SIMILARITY=0.78 -BOM_COSTING_1688_PROCUREMENT_RATE=0.90 -BOM_COSTING_1688_IMAGE_SEARCH_PAGE_SIZE=10 -BOM_COSTING_1688_IMAGE_SEARCH_PURCHASE_AMOUNT=1 -BOM_COSTING_1688_IMAGE_SEARCH_TAGS=4306497 +# BOM 核算实时同款采购预估(1688 官方图搜)。 +# 网关固定为 https://skills-gateway.1688.com/api/find_product/1.0.0。 +# AK 只放服务器私密环境变量,服务端会按 x-csk-* 协议签名;实时同款价 × 采购系数 +# 是整件采购预估,历史 BOM 仅保留为结构参考。不要把 AK 写入 Git 或 NEXT_PUBLIC_* 变量。 +ALIBABA_1688_IMAGE_SEARCH_AK=replace-with-1688-image-search-ak +# 仅在官方网关地址调整时设置;系统会拒绝非官方 HTTPS 地址。 +ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL=https://skills-gateway.1688.com/api/find_product/1.0.0 +ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS=45000 +ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY=0.78 +ALIBABA_1688_PROCUREMENT_RATE=0.90 +ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE=10 +ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT=1 +ALIBABA_1688_IMAGE_SEARCH_TAGS=4306497 # 素材库白底图回传 MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts index 71021be4..34454133 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts @@ -1,13 +1,18 @@ import { BomCosting1688ImageSearchService, + buildAlibaba1688SignedHeaders, buildBomCosting1688RealtimeMarketQuote, + canonicalAlibaba1688Resource, + extractAlibaba1688AkKeys, + getBomCosting1688ImageSearchConfig, type BomCosting1688ImageSearchConfig, } from './bom-costing-1688-image-search' +const testAk = Buffer.from('0123456789abcdef0123456789abcdeftest-ak-id').toString('base64url') + const config: BomCosting1688ImageSearchConfig = { - endpoint: new URL('https://1688.example.com/api/find_product/1.0.0'), - apiKey: 'test-key', - apiKeyHeader: 'X-API-KEY', + endpoint: new URL('https://skills-gateway.1688.com/api/find_product/1.0.0'), + ak: testAk, timeoutMs: 10_000, maxImageBytes: 5 * 1024 * 1024, minSimilarityScore: 0.78, @@ -17,6 +22,58 @@ const config: BomCosting1688ImageSearchConfig = { tags: '4306497', } +describe('Alibaba 1688 CSK signing', () => { + it('decodes the URL-safe AK into an AK id and a 32-character signing secret', () => { + expect(extractAlibaba1688AkKeys(`1688apikey:${testAk}`)).toEqual({ + secret: '0123456789abcdef0123456789abcdef', + akId: 'test-ak-id', + }) + expect(extractAlibaba1688AkKeys('bad')).toBeNull() + }) + + it('uses the official gateway and server-only ALIBABA environment variables', () => { + const previousAk = process.env.ALIBABA_1688_IMAGE_SEARCH_AK + const previousEndpoint = process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL + process.env.ALIBABA_1688_IMAGE_SEARCH_AK = testAk + process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL = 'https://skills-gateway.1688.com/api/find_product/1.0.0' + try { + expect(getBomCosting1688ImageSearchConfig()).toEqual(expect.objectContaining({ + ak: testAk, + endpoint: new URL('https://skills-gateway.1688.com/api/find_product/1.0.0'), + pageSize: 10, + })) + } finally { + if (previousAk === undefined) delete process.env.ALIBABA_1688_IMAGE_SEARCH_AK + else process.env.ALIBABA_1688_IMAGE_SEARCH_AK = previousAk + if (previousEndpoint === undefined) delete process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL + else process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL = previousEndpoint + } + }) + + it('sorts canonical query pairs and signs exactly the body sent to the gateway', () => { + expect(canonicalAlibaba1688Resource('https://skills-gateway.1688.com/api/find_product/1.0.0?z=2&a=b&a=a')).toBe( + '/api/find_product/1.0.0?a=a&a=b&z=2', + ) + + const body = '{"imgBase64":"aW1hZ2U=","pageSize":10}' + expect(buildAlibaba1688SignedHeaders({ + uri: 'https://skills-gateway.1688.com/api/find_product/1.0.0', + body, + ak: testAk, + timestamp: '1786903200', + nonce: '01020304', + })).toEqual({ + 'Content-Type': 'application/json', + 'x-csk-ak': 'test-ak-id', + 'x-csk-time': '1786903200', + 'x-csk-nonce': '01020304', + 'x-csk-content-md5': 'x1mlqy1bkZMZMp7OFnTphA==', + 'x-csk-version': '1.7.0', + 'x-csk-sign': 'IYDSV+EEOCylcqpmITFvMyIlicUU6XxUVl81cbogwhE=', + }) + }) +}) + describe('buildBomCosting1688RealtimeMarketQuote', () => { it('uses the robust realtime price centre and the configured procurement rate', () => { const quote = buildBomCosting1688RealtimeMarketQuote({ @@ -58,7 +115,7 @@ describe('buildBomCosting1688RealtimeMarketQuote', () => { }) describe('BomCosting1688ImageSearchService', () => { - it('sends the SKU image to the configured API and returns the realtime procurement estimate', async () => { + it('signs the pure Base64 request body and accepts official model/item aliases', async () => { const imageResponse = { ok: true, status: 200, @@ -69,9 +126,18 @@ describe('BomCosting1688ImageSearchService', () => { ok: true, status: 200, text: async () => JSON.stringify({ - data: { + success: true, + model: { count: 1, - data: [{ itemId: 1, title: '同款', score: 0.99, currentPrice: 200 }], + data: [{ + item_id: 1, + subject: '同款', + score: 0.99, + price: 200, + quantity_begin: 2, + store_amount: 99, + companyName: '测试工厂', + }], }, }), } as unknown as Response @@ -81,7 +147,8 @@ describe('BomCosting1688ImageSearchService', () => { const service = new BomCosting1688ImageSearchService({ getConfig: () => config, fetch, - now: () => new Date('2026-08-15T10:00:00.000Z'), + now: () => new Date('2026-08-16T10:00:00.000Z'), + randomBytes: () => Buffer.from([1, 2, 3, 4]), }) const quote = await service.findRealtimeQuote({ @@ -89,14 +156,34 @@ describe('BomCosting1688ImageSearchService', () => { }) expect(quote.procurementCost).toBe(180) + expect(quote.offers[0]).toEqual(expect.objectContaining({ + itemId: '1', + supplier: '测试工厂', + quantityBegin: 2, + stockAmount: 99, + detailUrl: 'https://detail.1688.com/offer/1.html', + })) expect(fetch).toHaveBeenCalledTimes(2) const request = fetch.mock.calls[1]?.[1] - expect(request?.headers).toEqual(expect.objectContaining({ 'X-API-KEY': 'test-key' })) - expect(JSON.parse(request?.body ?? '{}')).toEqual(expect.objectContaining({ - imageUrl: 'https://images.example.com/sku.jpg', + const expectedBody = JSON.stringify({ + imgBase64: Buffer.from('image-bytes').toString('base64'), + pageSize: 10, purchaseAmount: 1, + sortType: 'yx_desc', + scoreLevel: 'high', tags: '4306497', + icTags: '', + }) + expect(request?.body).toBe(expectedBody) + expect(request?.headers).toEqual(expect.objectContaining({ + 'Content-Type': 'application/json', + 'x-csk-ak': 'test-ak-id', + 'x-csk-time': '1786874400', + 'x-csk-nonce': '01020304', + 'x-csk-content-md5': 'o38VnblUKGb4d8GTwvYg7Q==', + 'x-csk-version': '1.7.0', })) + expect(JSON.parse(request?.body ?? '{}')).not.toHaveProperty('imageUrl') }) it('degrades without a hidden browser fallback when the service is not configured', async () => { @@ -105,6 +192,7 @@ describe('BomCosting1688ImageSearchService', () => { getConfig: () => null, fetch, now: () => new Date('2026-08-15T10:00:00.000Z'), + randomBytes: () => Buffer.from([1, 2, 3, 4]), }) await expect(service.findRealtimeQuote({ @@ -112,4 +200,28 @@ describe('BomCosting1688ImageSearchService', () => { })).resolves.toMatchObject({ status: 'NOT_CONFIGURED', procurementCost: null }) expect(fetch).not.toHaveBeenCalled() }) + + it('keeps the structural estimate when the gateway returns a business error', async () => { + const imageResponse = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'image/jpeg' }), + arrayBuffer: async () => Buffer.from('image-bytes'), + } as unknown as Response + const apiResponse = { + ok: true, + status: 200, + text: async () => JSON.stringify({ success: false, code: 'INVALID_AK', msgInfo: '无效 AK' }), + } as unknown as Response + const service = new BomCosting1688ImageSearchService({ + getConfig: () => config, + fetch: jest.fn().mockResolvedValueOnce(imageResponse).mockResolvedValueOnce(apiResponse), + now: () => new Date('2026-08-15T10:00:00.000Z'), + randomBytes: () => Buffer.from([1, 2, 3, 4]), + }) + + await expect(service.findRealtimeQuote({ + imageUrl: 'https://images.example.com/sku.jpg', + })).resolves.toMatchObject({ status: 'FAILED', procurementCost: null }) + }) }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts index 49d79a75..715a4f63 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts @@ -1,5 +1,6 @@ import 'server-only' +import { createHash, createHmac, randomBytes } from 'node:crypto' import { z } from 'zod' import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety' import type { @@ -7,7 +8,9 @@ import type { DynamicBomRealtimeMarketQuote, } from '@/lib/bom-costing/dynamic-bom-cost-domain' +const OFFICIAL_IMAGE_SEARCH_ENDPOINT = 'https://skills-gateway.1688.com/api/find_product/1.0.0' const IMAGE_SEARCH_PATH = '/api/find_product/1.0.0' +const CSK_SIGNATURE_VERSION = '1.7.0' const DEFAULT_IMAGE_MAX_BYTES = 5 * 1024 * 1024 const DEFAULT_TIMEOUT_MS = 45_000 const DEFAULT_MINIMUM_SCORE = 0.78 @@ -25,7 +28,7 @@ const finiteNumber = z.union([z.number(), z.string()]).transform((value, context const nullableFiniteNumber = finiteNumber.nullish().transform((value) => value ?? null) const imageSearchItemSchema = z.object({ - itemId: z.union([z.string(), z.number()]).transform(String), + itemId: z.union([z.string(), z.number()]).transform(String).pipe(z.string().trim().min(1)), title: z.string().trim().max(500).optional().default(''), imageUrl: z.string().url().optional().nullable().default(null), detailUrl: z.string().url().optional().nullable().default(null), @@ -39,21 +42,13 @@ const imageSearchItemSchema = z.object({ quantityBegin: nullableFiniteNumber, unit: z.string().trim().max(80).optional().nullable().default(null), company: z.string().trim().max(300).optional().nullable().default(null), - soldOut: nullableFiniteNumber, stockAmount: nullableFiniteNumber, }).passthrough() -const imageSearchResponseSchema = z.object({ - data: z.object({ - data: z.array(imageSearchItemSchema).optional().default([]), - count: z.number().int().nonnegative().optional().default(0), - }).passthrough(), -}).passthrough() - export interface BomCosting1688ImageSearchConfig { endpoint: URL - apiKey: string - apiKeyHeader: string + /** URL-safe Base64 AK. It is never exposed to browser clients or logs. */ + ak: string timeoutMs: number maxImageBytes: number minSimilarityScore: number @@ -70,6 +65,7 @@ export interface BomCosting1688ImageSearchGateway { getConfig(): BomCosting1688ImageSearchConfig | null fetch(input: RequestInfo | URL, init?: RequestInit): Promise now(): Date + randomBytes(size: number): Buffer } function boundedPositiveInteger( @@ -97,62 +93,77 @@ function boundedDecimal( } export function getBomCosting1688ImageSearchConfig(): BomCosting1688ImageSearchConfig | null { - const explicitEndpoint = process.env.BOM_COSTING_1688_IMAGE_SEARCH_ENDPOINT?.trim() - const baseUrl = process.env.BOM_COSTING_1688_IMAGE_SEARCH_BASE_URL?.trim() - const apiKey = process.env.BOM_COSTING_1688_IMAGE_SEARCH_API_KEY?.trim() - if ((!explicitEndpoint && !baseUrl) || !apiKey) return null + const explicitEndpoint = ( + process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL ?? + process.env.BOM_COSTING_1688_IMAGE_SEARCH_ENDPOINT + )?.trim() + const ak = ( + process.env.ALIBABA_1688_IMAGE_SEARCH_AK ?? + process.env.BOM_COSTING_1688_IMAGE_SEARCH_API_KEY + )?.trim() + if (!ak || !extractAlibaba1688AkKeys(ak)) return null let endpoint: URL try { - endpoint = explicitEndpoint - ? new URL(explicitEndpoint) - : new URL(IMAGE_SEARCH_PATH, baseUrl) + endpoint = new URL(explicitEndpoint || OFFICIAL_IMAGE_SEARCH_ENDPOINT) } catch { return null } - if (endpoint.protocol !== 'https:' && endpoint.protocol !== 'http:') return null + if ( + endpoint.protocol !== 'https:' || + endpoint.hostname !== 'skills-gateway.1688.com' || + endpoint.pathname !== IMAGE_SEARCH_PATH + ) return null return { endpoint, - apiKey, - apiKeyHeader: process.env.BOM_COSTING_1688_IMAGE_SEARCH_API_KEY_HEADER?.trim() || 'X-API-KEY', + ak, timeoutMs: boundedPositiveInteger( - process.env.BOM_COSTING_1688_IMAGE_SEARCH_TIMEOUT_MS, + process.env.ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS ?? + process.env.BOM_COSTING_1688_IMAGE_SEARCH_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 5_000, 120_000, ), maxImageBytes: boundedPositiveInteger( - process.env.BOM_COSTING_1688_IMAGE_SEARCH_MAX_IMAGE_BYTES, + process.env.ALIBABA_1688_IMAGE_SEARCH_MAX_IMAGE_BYTES ?? + process.env.BOM_COSTING_1688_IMAGE_SEARCH_MAX_IMAGE_BYTES, DEFAULT_IMAGE_MAX_BYTES, 1_024, DEFAULT_IMAGE_MAX_BYTES, ), minSimilarityScore: boundedDecimal( - process.env.BOM_COSTING_1688_IMAGE_SEARCH_MIN_SIMILARITY, + process.env.ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY ?? + process.env.BOM_COSTING_1688_IMAGE_SEARCH_MIN_SIMILARITY, DEFAULT_MINIMUM_SCORE, 0.5, 0.9999, ), procurementRate: boundedDecimal( - process.env.BOM_COSTING_1688_PROCUREMENT_RATE, + process.env.ALIBABA_1688_PROCUREMENT_RATE ?? + process.env.BOM_COSTING_1688_PROCUREMENT_RATE, DEFAULT_PROCUREMENT_RATE, 0.5, 1, ), pageSize: boundedPositiveInteger( - process.env.BOM_COSTING_1688_IMAGE_SEARCH_PAGE_SIZE, + process.env.ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE ?? + process.env.BOM_COSTING_1688_IMAGE_SEARCH_PAGE_SIZE, 10, - 1, - 20, + 10, + 50, ), purchaseAmount: boundedPositiveInteger( - process.env.BOM_COSTING_1688_IMAGE_SEARCH_PURCHASE_AMOUNT, + process.env.ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT ?? + process.env.BOM_COSTING_1688_IMAGE_SEARCH_PURCHASE_AMOUNT, 1, 1, - 10_000, + 100_000, ), - tags: process.env.BOM_COSTING_1688_IMAGE_SEARCH_TAGS?.trim() || '4306497', + tags: ( + process.env.ALIBABA_1688_IMAGE_SEARCH_TAGS ?? + process.env.BOM_COSTING_1688_IMAGE_SEARCH_TAGS + )?.trim() || '4306497', } } @@ -160,12 +171,183 @@ const defaultGateway: BomCosting1688ImageSearchGateway = { getConfig: getBomCosting1688ImageSearchConfig, fetch: (...args) => fetch(...args), now: () => new Date(), + randomBytes: (size) => randomBytes(size), +} + +interface Alibaba1688AkKeys { + akId: string + secret: string +} + +/** + * The official AK is a URL-safe Base64 text. Its decoded first 32 characters + * are the signing secret; the remainder is the public AK id. + */ +export function extractAlibaba1688AkKeys(rawAk: string | null | undefined): Alibaba1688AkKeys | null { + const labeled = String(rawAk ?? '').trim() + const raw = labeled.replace(/^1688apikey\s*:/i, '').trim() + if (!raw) return null + + let decoded = '' + try { + decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8') + } catch { + decoded = raw + } + if (decoded.length <= 32) decoded = raw + const secret = decoded.slice(0, 32) + const akId = decoded.slice(32).trim() + return secret.length === 32 && akId ? { secret, akId } : null +} + +export function canonicalAlibaba1688Resource(uri: string | URL): string { + const parsed = new URL(uri, OFFICIAL_IMAGE_SEARCH_ENDPOINT) + const entries = Array.from(parsed.searchParams.entries()).sort(([leftKey, leftValue], [rightKey, rightValue]) => ( + leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue) + )) + const query = entries + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&') + return `${parsed.pathname || '/'}${query ? `?${query}` : ''}` +} + +export function buildAlibaba1688SignedHeaders(input: { + method?: string + uri: string | URL + body: string + ak: string + timestamp?: string + nonce?: string +}): Record { + const keys = extractAlibaba1688AkKeys(input.ak) + if (!keys) throw new Error('1688 AK 格式无效') + const contentType = 'application/json' + const timestamp = input.timestamp ?? String(Math.trunc(Date.now() / 1000)) + const nonce = input.nonce ?? randomBytes(4).toString('hex') + if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{8}$/i.test(nonce)) { + throw new Error('1688 签名时间或随机数格式无效') + } + const contentMd5 = input.body + ? createHash('md5').update(input.body, 'utf8').digest('base64') + : '' + const cskHeaders: Record = { + 'x-csk-ak': keys.akId, + 'x-csk-time': timestamp, + 'x-csk-nonce': nonce, + 'x-csk-content-md5': contentMd5, + 'x-csk-version': CSK_SIGNATURE_VERSION, + } + const canonicalHeaders = Object.keys(cskHeaders) + .sort() + .map((key) => `${key}:${cskHeaders[key]!.trim()}\n`) + .join('') + const stringToSign = `${(input.method ?? 'POST').toUpperCase()}\n${contentMd5}\n${contentType}\n${timestamp}\n${canonicalHeaders}${canonicalAlibaba1688Resource(input.uri)}` + + return { + 'Content-Type': contentType, + 'x-csk-sign': createHmac('sha256', keys.secret).update(stringToSign, 'utf8').digest('base64'), + ...cskHeaders, + } } function roundMoney(value: number) { return Number(value.toFixed(2)) } +type ImageSearchItem = z.infer + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function firstDefined(record: Record, keys: readonly string[]): unknown { + for (const key of keys) { + if (record[key] !== undefined && record[key] !== null) return record[key] + } + return null +} + +function textValue(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null + const normalized = String(value).trim() + return normalized || null +} + +function finiteValue(value: unknown): number | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null + if (typeof value !== 'string') return null + const parsed = Number(value.trim()) + return Number.isFinite(parsed) ? parsed : null +} + +function httpUrlValue(value: unknown): string | null { + const text = textValue(value) + if (!text) return null + try { + const parsed = new URL(text) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.toString() : null + } catch { + return null + } +} + +function normalizeResponseItem(value: unknown): ImageSearchItem | null { + const raw = asRecord(value) + if (!raw) return null + const itemId = textValue(firstDefined(raw, ['itemId', 'item_id', 'offerId', 'offer_id'])) + if (!itemId) return null + const parsed = imageSearchItemSchema.safeParse({ + itemId, + title: textValue(firstDefined(raw, ['title', 'subject', 'name'])) ?? '', + imageUrl: httpUrlValue(firstDefined(raw, ['imageUrl', 'image_url', 'image', 'imgUrl', 'img_url'])), + detailUrl: httpUrlValue(firstDefined(raw, ['detailUrl', 'detail_url', 'url'])) ?? `https://detail.1688.com/offer/${encodeURIComponent(itemId)}.html`, + score: finiteValue(firstDefined(raw, ['score', 'similarityScore', 'similarity_score'])), + currentPrice: finiteValue(firstDefined(raw, ['currentPrice', 'current_price', 'price'])), + skuId: textValue(firstDefined(raw, ['skuId', 'sku_id'])), + skuTitle: textValue(firstDefined(raw, ['skuTitle', 'sku_title'])), + yxIndex: finiteValue(firstDefined(raw, ['yxIndex', 'yx_index'])), + quantityBegin: finiteValue(firstDefined(raw, ['quantityBegin', 'quantity_begin'])), + unit: textValue(firstDefined(raw, ['unit'])), + company: textValue(firstDefined(raw, ['company', 'companyName', 'supplierName', 'supplier_name'])), + stockAmount: finiteValue(firstDefined(raw, ['storeAmount', 'store_amount', 'stockAmount', 'stock_amount'])), + raw, + }) + return parsed.success ? parsed.data : null +} + +function responseFailureMessage(envelope: Record): string | null { + const success = envelope.success + const httpCode = finiteValue(envelope.httpCode) + if (success !== false && (httpCode === null || httpCode < 400)) return null + const code = textValue(firstDefined(envelope, ['code', 'msgCode', 'errorCode'])) + const message = textValue(firstDefined(envelope, ['msgInfo', 'message', 'error', 'errorMessage'])) + return `1688 同款服务业务失败${code ? `(${code})` : ''}${message ? `:${message}` : ''}` +} + +function readImageSearchResponse(payload: unknown): ImageSearchItem[] { + const envelope = asRecord(payload) + if (!envelope) throw new Error('1688 同款服务返回格式不完整') + const businessFailure = responseFailureMessage(envelope) + if (businessFailure) throw new Error(businessFailure) + + const bodies = [envelope.model, envelope.data, envelope.result, envelope] + for (const body of bodies) { + const rows = Array.isArray(body) + ? body + : (() => { + const record = asRecord(body) + if (!record) return null + if (Array.isArray(record.data)) return record.data + if (Array.isArray(record.items)) return record.items + return null + })() + if (rows) return rows.map(normalizeResponseItem).filter((item): item is ImageSearchItem => item !== null) + } + throw new Error('1688 同款服务未返回商品列表') +} + function percentile(values: readonly number[], ratio: number) { if (values.length === 0) return null const ordered = [...values].sort((left, right) => left - right) @@ -216,7 +398,7 @@ export function buildBomCosting1688RealtimeMarketQuote(input: { searchedAt: Date procurementRate: number minSimilarityScore: number - returnedOffers: z.infer[] + returnedOffers: ImageSearchItem[] }): BomCosting1688RealtimeMarketQuote { const offers = input.returnedOffers.flatMap((item) => { const price = item.currentPrice @@ -238,7 +420,7 @@ export function buildBomCosting1688RealtimeMarketQuote(input: { qualityIndex: item.yxIndex == null ? null : Number(item.yxIndex.toFixed(4)), quantityBegin: item.quantityBegin == null ? null : Math.max(1, Math.floor(item.quantityBegin)), unit: item.unit, - soldCount: item.soldOut == null ? null : Math.max(0, Math.floor(item.soldOut)), + soldCount: null, stockAmount: item.stockAmount == null ? null : Math.max(0, Math.floor(item.stockAmount)), } satisfies BomCosting1688ComparableOffer] }).sort((left, right) => ( @@ -332,23 +514,33 @@ export class BomCosting1688ImageSearchService { const contentType = imageResponse.headers.get('content-type')?.split(';')[0]?.trim() ?? '' if (!contentType.startsWith('image/')) throw new Error('SKU 主图不是可检索图片') const image = await readBoundedImage(imageResponse, config.maxImageBytes) + const body = JSON.stringify({ + imgBase64: image.toString('base64'), + pageSize: config.pageSize, + purchaseAmount: config.purchaseAmount, + sortType: 'yx_desc', + scoreLevel: 'high', + tags: config.tags, + icTags: '', + }) + const searchedAt = this.gateway.now() const response = await this.gateway.fetch(config.endpoint, { method: 'POST', headers: { Accept: 'application/json', - 'Content-Type': 'application/json', - [config.apiKeyHeader]: config.apiKey, + ...buildAlibaba1688SignedHeaders({ + method: 'POST', + uri: config.endpoint, + body, + ak: config.ak, + timestamp: String(Math.trunc(searchedAt.getTime() / 1000)), + nonce: this.gateway.randomBytes(4).toString('hex'), + }), }, cache: 'no-store', signal: AbortSignal.timeout(config.timeoutMs), - body: JSON.stringify({ - imgBase64: image.toString('base64'), - imageUrl: sourceImageUrl, - pageSize: config.pageSize, - purchaseAmount: config.purchaseAmount, - scoreLevel: 'high', - tags: config.tags, - }), + // The body is signed. Never let a client re-serialize it after MD5. + body, }) const rawBody = await response.text().catch(() => '') let payload: unknown = null @@ -358,14 +550,12 @@ export class BomCosting1688ImageSearchService { payload = null } if (!response.ok) throw new Error(`1688 同款服务返回 ${response.status}`) - const parsed = imageSearchResponseSchema.safeParse(payload) - if (!parsed.success) throw new Error('1688 同款服务返回格式不完整') return buildBomCosting1688RealtimeMarketQuote({ sourceImageUrl, - searchedAt: this.gateway.now(), + searchedAt, procurementRate: config.procurementRate, minSimilarityScore: config.minSimilarityScore, - returnedOffers: parsed.data.data.data, + returnedOffers: readImageSearchResponse(payload), }) } catch (error) { console.warn('[BOM costing] 1688 realtime image search unavailable', { From d3a1049b5543a20f0fe7f8ed240f8e6a20eb97c8 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 17 Aug 2026 20:54:45 +0800 Subject: [PATCH 3/8] feat(bom-costing): complete unified cost estimates --- .../migration.sql | 5 + furniture-3d-system/prisma/schema.prisma | 1 + .../cases/[id]/scenarios/route.test.ts | 32 +- .../bom-costing/cases/[id]/scenarios/route.ts | 16 +- .../BomCostingWorkbenchClient.test.tsx | 255 +--- .../bom-costing/BomCostingWorkbenchClient.tsx | 1268 ++--------------- .../src/lib/bom-costing/client-types.ts | 167 +-- .../bom-costing-scenario.repository.ts | 4 + .../repositories/bom-costing.repository.ts | 15 - .../bom-costing-1688-image-search.ts | 9 +- .../bom-costing-ai-cost-estimator.test.ts | 76 + .../bom-costing-ai-cost-estimator.ts | 191 +++ .../bom-costing-scenario.service.test.ts | 264 +++- .../bom-costing-scenario.service.ts | 298 ++-- .../bom-costing/bom-costing.service.test.ts | 136 +- .../bom-costing/bom-costing.service.ts | 104 +- 16 files changed, 1002 insertions(+), 1839 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260817143000_add_bom_costing_whole_product_estimate_audit/migration.sql diff --git a/furniture-3d-system/prisma/migrations/20260817143000_add_bom_costing_whole_product_estimate_audit/migration.sql b/furniture-3d-system/prisma/migrations/20260817143000_add_bom_costing_whole_product_estimate_audit/migration.sql new file mode 100644 index 00000000..9483cb91 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260817143000_add_bom_costing_whole_product_estimate_audit/migration.sql @@ -0,0 +1,5 @@ +-- Keeps the server-only audit for whole-SKU agent estimates. Public BOM DTOs +-- deliberately omit this field, so it cannot reveal private model context or +-- internal reference records to browsers or exports. +ALTER TABLE "BomCostingScenario" +ADD COLUMN "wholeProductEstimateAudit" JSONB; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 07c841d0..f47a0449 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1401,6 +1401,7 @@ model BomCostingScenario { competitorPrice Decimal? @db.Decimal(18, 2) priceDelta Decimal? @db.Decimal(18, 2) realtimeMarketQuote Json? + wholeProductEstimateAudit Json? confidence Decimal? @db.Decimal(5, 4) warning String @default("") @db.Text unresolvedCount Int @default(0) diff --git a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.test.ts b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.test.ts index 14d993fd..fb2f7b6b 100644 --- a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.test.ts +++ b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.test.ts @@ -5,8 +5,7 @@ export {} const mockHandleRequest = jest.fn() const mockJsonError = jest.fn() const mockJsonSuccess = jest.fn() -const mockAssertCanModifyCurrentCosting = jest.fn() -const mockCalculate = jest.fn() +const mockGetCase = jest.fn() jest.mock('@/server/core/response', () => ({ handleRequest: (...args: unknown[]) => mockHandleRequest(...args), @@ -16,15 +15,12 @@ jest.mock('@/server/core/response', () => ({ jest.mock('@/server/services/bom-costing/bom-costing.service', () => ({ bomCostingService: { - assertCanModifyCurrentCosting: (...args: unknown[]) => - mockAssertCanModifyCurrentCosting(...args), + getCase: (...args: unknown[]) => mockGetCase(...args), }, })) jest.mock('@/server/services/bom-costing/bom-costing-scenario.service', () => ({ - bomCostingScenarioService: { - calculate: (...args: unknown[]) => mockCalculate(...args), - }, + bomCostingScenarioService: {}, })) const { AppError, ErrorCode } = require('@/server/core/errors') @@ -56,9 +52,8 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => { })) }) - it('只在当前核算可修改时生成材质替换场景', async () => { - mockAssertCanModifyCurrentCosting.mockResolvedValue({ id: 'case-ready' }) - mockCalculate.mockResolvedValue({ scenario: { id: 'scenario-1' } }) + it('关闭换材质计算,但仍验证当前用户可以访问目标案例', async () => { + mockGetCase.mockResolvedValue({ id: 'case-ready' }) const request = { json: async () => ({ skuId: 'sku-1', @@ -70,20 +65,19 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => { params: Promise.resolve({ id: 'case-ready' }), }) - expect(mockAssertCanModifyCurrentCosting).toHaveBeenCalledWith( + expect(mockGetCase).toHaveBeenCalledWith( 'case-ready', user, ) - expect(mockCalculate).toHaveBeenCalledWith({ - caseId: 'case-ready', - skuId: 'sku-1', - targetMaterialId: 'material-1', + expect(response).toMatchObject({ + success: false, + status: 409, + error: { code: ErrorCode.VALIDATION_FAILED, message: expect.stringContaining('换材质预估已暂时关闭') }, }) - expect(response).toMatchObject({ success: true, status: 200 }) }) - it('旧版或处理中的结果被守卫拒绝时不调用成本引擎', async () => { - mockAssertCanModifyCurrentCosting.mockRejectedValue( + it('没有案例读取权限时维持原有鉴权错误', async () => { + mockGetCase.mockRejectedValue( new AppError( ErrorCode.VALIDATION_FAILED, '当前核算尚未完成,请等待本次处理结束后再调整成本', @@ -105,6 +99,6 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => { status: 422, error: { code: ErrorCode.VALIDATION_FAILED }, }) - expect(mockCalculate).not.toHaveBeenCalled() + expect(mockGetCase).toHaveBeenCalledWith('case-processing', user) }) }) diff --git a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.ts b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.ts index 0cd1fddc..93f9a1f0 100644 --- a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.ts +++ b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.ts @@ -1,5 +1,4 @@ import { NextRequest } from 'next/server' -import { bomCostingScenarioCalculationSchema } from '@/lib/bom-costing/contracts' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service' @@ -38,15 +37,14 @@ export async function POST(request: NextRequest, context: RouteContext) { async (ctx) => { const { id } = await context.params const caseId = decodeURIComponent(id) - const body = await request.json().catch(() => null) - const parsed = bomCostingScenarioCalculationSchema.safeParse(body) - if (!parsed.success) { - return jsonError(ErrorCode.VALIDATION_FAILED, '目标材质参数不正确', 400, ctx.requestId) - } try { - await bomCostingService.assertCanModifyCurrentCosting(caseId, ctx.user) - const result = await bomCostingScenarioService.calculate({ caseId, ...parsed.data }) - return jsonSuccess(result, 200, ctx.requestId) + await bomCostingService.getCase(caseId, ctx.user) + return jsonError( + ErrorCode.VALIDATION_FAILED, + '换材质预估已暂时关闭,请使用重新核算更新当前预估结果', + 409, + ctx.requestId, + ) } catch (error) { if (error instanceof AppError) { return jsonError(error.code, error.message, error.statusCode, ctx.requestId) 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 a69d1532..0c3010b7 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -1576,30 +1576,30 @@ describe('BomCostingWorkbenchClient', () => { expect(await screen.findByText('预计出厂成本')).toBeInTheDocument() const dynamicCostNotice = screen.getByTestId('bom-dynamic-cost-partial-notice') expect(dynamicCostNotice).toHaveTextContent( - '1/3 个 SKU 的原材质预估已完成', + '1/3 个 SKU 的预估已完成', ) expect(dynamicCostNotice).toHaveTextContent( - '这是原材质批次结果,不代表当前材质替换方案已经完成', + '其余 SKU 可按现有事实重新核算', ) expect(dynamicCostNotice).toHaveTextContent( - '失败规格 · 事务启动超时', + '失败规格 · 当前资料不足,可补充结构或尺寸后重新核算', ) expect(screen.getByRole('alert', { - name: '原材质批次部分完成,包含成本生成失败', + name: '本轮批次部分完成,部分 SKU 暂无法预估', })).toBe(dynamicCostNotice) const failedSkuButton = screen.getByRole('button', { name: /失败规格/ }) - expect(within(failedSkuButton).getByText('生成失败')).toBeInTheDocument() + expect(within(failedSkuButton).getByText('资料不足')).toBeInTheDocument() fireEvent.click(failedSkuButton) - expect(screen.getAllByText('成本生成失败').length).toBeGreaterThan(0) + expect(screen.getAllByText('资料不足,暂无法预估').length).toBeGreaterThan(0) expect(screen.getByText( - '本轮该 SKU 成本生成失败:事务启动超时,两次重试仍失败。已保留结构事实,可按现有事实重新核算。', + '本轮未形成可用整件预估。已保留结构事实,可补充结构或尺寸后重新核算。', )).toBeInTheDocument() expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument() expect(screen.queryByText('约 ¥999.00')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /待补价格规格/ })) expect(screen.getAllByText('待补成本').length).toBeGreaterThan(0) expect(screen.getByText( - '本轮该 SKU 待补成本:结构事实已保存,但公司价格资料尚未闭合,补齐后再生成完整预估。', + '本轮该 SKU 的结构事实已保存;待补信息完善后可继续形成完整预估。', )).toBeInTheDocument() expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument() expect(screen.queryByText('约 ¥888.00')).not.toBeInTheDocument() @@ -1675,7 +1675,7 @@ describe('BomCostingWorkbenchClient', () => { screen.getByText('按现有事实重算').closest('[data-motion="reveal"]'), ).toBeInTheDocument() const reuseFactsExplanation = screen.getByText( - '只重新汇总结构、BOM 与资料库价格,复用已保存的逐图结果。', + '只重新汇总结构、BOM 与预估结果,复用已保存的逐图结果。', ) expect(reuseFactsExplanation.closest('[role="tooltip"]')).toHaveClass('sr-only') fireEvent.click(screen.getByRole('button', { name: '查看按现有事实重算说明' })) @@ -1721,7 +1721,7 @@ describe('BomCostingWorkbenchClient', () => { } }) - it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => { + it('未匹配结构项不显示零元,并保留中性的预估待补状态', async () => { const originalFetch = global.fetch const now = new Date().toISOString() const unresolvedLine = { @@ -1803,69 +1803,7 @@ describe('BomCostingWorkbenchClient', () => { }], taskEvents: [], } - const resolvedDetail = { - ...detail, - skus: [{ - ...detail.skus[0], - estimatedCost: null, - costCalculatedAt: null, - costSummary: { - material: 51.57, - processing: 83, - labor: 35, - packaging: 0, - lossIncludedTotal: 169.57, - unresolvedLineCount: 0, - missingCostGroups: ['PACKAGING'], - }, - bomLines: [{ - ...unresolvedLine, - heightMm: 18, - matchStatus: 'MATCHED', - matchedLibraryType: 'SUBSTRATE', - matchedLibraryRecordId: 'rubberwood-18', - matchedLibraryName: '18ENF级橡胶木', - matchedLibrarySpec: '18mm · ENF', - resolvedUnitPrice: 80.63, - subtotal: 51.57, - }], - }], - } - - global.fetch = jest.fn(async (input, init) => { - const url = String(input) - if (url.endsWith('/lines/line-rubberwood') && init?.method === 'PATCH') { - const patchBody = - typeof init.body === 'string' ? JSON.parse(init.body) : {} - return { - ok: true, - json: async () => ({ - success: true, - data: { case: patchBody.review ? detail : resolvedDetail }, - }), - } as Response - } - if (url.endsWith('/lines/line-rubberwood')) { - return { - ok: true, - json: async () => ({ - success: true, - data: { - candidates: [{ - recordId: 'rubberwood-18', - libraryType: 'SUBSTRATE', - code: 'RW-18', - name: '18ENF级橡胶木', - spec: '18mm · ENF', - unit: '㎡', - unitPrice: 80.63, - thicknessMm: 18, - recommended: true, - }], - }, - }), - } as Response - } + global.fetch = jest.fn(async () => { return { ok: true, json: async () => ({ @@ -1907,83 +1845,20 @@ describe('BomCostingWorkbenchClient', () => { expect(await screen.findByText('橡胶木桌面板')).toBeInTheDocument() expect(screen.getByText('SKU 结构已就绪')).toBeInTheDocument() - expect(screen.getAllByText('核算未完成').length).toBeGreaterThan(0) + expect(screen.getAllByText('资料不足,暂无法预估').length).toBeGreaterThan(0) expect(screen.getByText('缺板厚')).toBeInTheDocument() - expect(screen.getByText('暂缺')).toBeInTheDocument() + expect(screen.getByText('预估待补')).toBeInTheDocument() expect(screen.queryByText('¥0.00')).not.toBeInTheDocument() - - fireEvent.click( - screen.getByRole('button', { name: '记录橡胶木桌面板人工复核值' }), - ) - expect(screen.getByText('材质与规格')).toBeInTheDocument() - expect(screen.getByText('部件尺寸')).toBeInTheDocument() - expect(screen.getByText('计价参数')).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('材质名称'), { - target: { value: '橡胶木实木板' }, - }) - fireEvent.change(screen.getByLabelText('单价'), { - target: { value: '80' }, - }) - fireEvent.change(screen.getByLabelText('价格单位'), { - target: { value: '㎡' }, - }) - fireEvent.click(screen.getByRole('button', { name: '保存并留档' })) - - await waitFor(() => { - const reviewPatchCall = (global.fetch as jest.Mock).mock.calls.find( - ([url, init]) => { - if ( - !String(url).endsWith('/lines/line-rubberwood') || - init?.method !== 'PATCH' || - typeof init.body !== 'string' - ) { - return false - } - return Boolean(JSON.parse(init.body).review) - }, - ) - expect(reviewPatchCall).toBeDefined() - expect(JSON.parse(reviewPatchCall?.[1]?.body)).toEqual({ - review: expect.objectContaining({ - materialName: '橡胶木实木板', - unitPrice: 80, - priceUnit: '㎡', - }), - }) - }) - - fireEvent.click(screen.getByRole('button', { name: /确认价格/ })) - fireEvent.click( - await screen.findByRole('button', { name: /18ENF级橡胶木/ }), - ) - - await waitFor(() => { - const patchCall = (global.fetch as jest.Mock).mock.calls.find( - ([url, init]) => { - if ( - !String(url).endsWith('/lines/line-rubberwood') || - init?.method !== 'PATCH' || - typeof init.body !== 'string' - ) { - return false - } - return JSON.parse(init.body).matchedLibraryRecordId === 'rubberwood-18' - }, - ) - expect(patchCall).toBeDefined() - expect(JSON.parse(patchCall?.[1]?.body)).toEqual({ - matchedLibraryRecordId: 'rubberwood-18', - heightMm: 18, - }) - }) - expect((await screen.findAllByText('¥51.57')).length).toBeGreaterThan(0) - expect(screen.getByText('18ENF级橡胶木')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /人工复核|确认价格|保存并留档/ })).not.toBeInTheDocument() + expect((global.fetch as jest.Mock).mock.calls.some(([url]) => ( + String(url).endsWith('/lines/line-rubberwood') + ))).toBe(false) } finally { global.fetch = originalFetch } }) - it('动态成本优先展示,并提交目标材质生成双场景且保留超售价金额', async () => { + it('统一展示同材质预估,单项待补不影响整件完成且不展示换材质入口', async () => { const originalFetch = global.fetch const now = new Date().toISOString() const scenario = (input: { @@ -2182,14 +2057,17 @@ describe('BomCostingWorkbenchClient', () => { kind: 'SAME_MATERIAL', targetMaterialId: null, targetMaterialName: '', - status: 'INSUFFICIENT_DATA', - totalCost: null, - knownCost: 500, + status: 'PROVISIONAL', + totalCost: 1_350, + knownCost: 1_350, unresolvedCount: 1, competitorPrice: 600, - priceDelta: null, - unitPrice: null, - subtotal: null, + priceDelta: 750, + unitPrice: 1_600, + subtotal: 800, + secondarySubtotal: 550, + aiSubtotal: 18.5, + rulesetVersion: 'dynamic-bom-cost/v6', }), scenario({ id: 'scenario-substitution-old', @@ -2460,13 +2338,11 @@ describe('BomCostingWorkbenchClient', () => { />, ) - expect((await screen.findAllByText('旧版结果待重新核算')).length).toBeGreaterThan(0) - expect(screen.getAllByText('已计部分 ¥500.00(不是整件成本)').length).toBeGreaterThan(0) - expect(screen.getAllByText('旧版结果待重新核算').length).toBeGreaterThan(0) - expect(screen.getByText('1 行没有可验证价格来源')).toBeInTheDocument() - expect(screen.getByText(/最近同步未成功:上游连接超时/)).toBeInTheDocument() + expect((await screen.findAllByText('约 ¥1,350.00')).length).toBeGreaterThan(0) + expect(screen.getAllByText('预计出厂成本').length).toBeGreaterThan(0) + expect(screen.getByText('1 行预估待补,不影响已形成的整件预估')).toBeInTheDocument() const skuButton = screen.getByRole('button', { name: /黑胡桃色 120cm/ }) - expect(within(skuButton).getByText('旧版结果待重新核算')).toBeInTheDocument() + expect(within(skuButton).getByText('约 ¥1,350.00')).toBeInTheDocument() expect(screen.getByText('旧规则留档:¥1,300.00')).toBeInTheDocument() const structureSummary = screen.getByText('结构与推导依据') fireEvent.click(structureSummary) @@ -2476,31 +2352,8 @@ describe('BomCostingWorkbenchClient', () => { expect(within(structureDetails as HTMLElement).getByText(/结构初判 1 轮 · 定向复核 2 轮/)).toBeInTheDocument() expect(within(structureDetails as HTMLElement).getByText('待复核')).toBeInTheDocument() expect(within(structureDetails as HTMLElement).getAllByText(/装配关系推导/).length).toBeGreaterThan(0) - expect(within(structureDetails as HTMLElement).getByRole('button', { name: '复核' })).toBeInTheDocument() + expect(within(structureDetails as HTMLElement).queryByRole('button', { name: '复核' })).not.toBeInTheDocument() - await waitFor(() => { - expect(screen.getByLabelText('选择替换目标材质')).toHaveValue( - 'material-rubberwood', - ) - }) - fireEvent.click(screen.getByRole('button', { name: '替换为 橡胶木' })) - expect(screen.getAllByText('约 ¥550.00').length).toBeGreaterThan(0) - - const calculateButton = screen.getByRole('button', { name: '生成替换方案' }) - await waitFor(() => expect(calculateButton).toBeEnabled()) - fireEvent.click(calculateButton) - - await waitFor(() => { - const postCall = (global.fetch as jest.Mock).mock.calls.find( - ([url, init]) => String(url).endsWith('/cases/case-dynamic/scenarios') && init?.method === 'POST', - ) - expect(postCall).toBeDefined() - expect(JSON.parse(postCall?.[1]?.body)).toEqual({ - skuId: 'sku-dynamic', - targetMaterialId: 'material-rubberwood', - }) - }) - expect(await screen.findByText('预计出厂成本 · 橡胶木')).toBeInTheDocument() const dynamicCostingHeading = screen.getByRole('heading', { name: '分层成本预估', level: 2, @@ -2526,42 +2379,22 @@ describe('BomCostingWorkbenchClient', () => { ) expect(screen.queryByText('暂无图片')).not.toBeInTheDocument() expect(screen.getByRole('heading', { name: '选择 SKU', level: 3 })).toBeInTheDocument() - expect(screen.getAllByText('约 ¥1,300.00').length).toBeGreaterThan(0) + expect(screen.getAllByText('约 ¥1,350.00').length).toBeGreaterThan(0) const costInfoButton = screen.getAllByRole('button', { name: '查看预计出厂成本说明', })[0] fireEvent.click(costInfoButton) - expect(screen.getAllByText(/板材按 8 件稳定批量排版/).length).toBeGreaterThan(0) - expect(screen.getAllByText(/批量利用率 82%/).length).toBeGreaterThan(0) - expect(screen.getAllByText(/单件整板承担上限 ¥1,000.00/).length).toBeGreaterThan(0) - expect(screen.getAllByText('预估 / 待复核').length).toBeGreaterThan(0) - expect(screen.getByText(/成本高于竞品售价,金额已保留/)).toBeInTheDocument() - expect(screen.getAllByText('约 ¥1,270.00').length).toBeGreaterThan(0) - expect(screen.getAllByText('公司历史 BOM 中位单价').length).toBeGreaterThan(0) - expect(screen.getAllByText('AI 参考预估 · 非正式采购价').length).toBeGreaterThan(0) - expect(screen.getAllByText('多模态模型参考公司知识预估').length).toBeGreaterThan(0) - expect(screen.getByText('1 行由多模态模型参考公司知识补充预估,整单已形成区间并等待复核')).toBeInTheDocument() - expect(screen.getAllByText(/参考 SKU:REF-BOARD/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/估算区间 ¥1,080.00–¥1,620.00/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/结构明细预估待补/).length).toBeGreaterThan(0) + expect(screen.getAllByText('预估完成').length).toBeGreaterThan(0) expect(screen.getByText('成本贡献明细')).toBeInTheDocument() expect(screen.getAllByText('最大贡献').length).toBeGreaterThan(0) - expect(screen.getAllByText(/占(?:已知成本 )?62%/).length).toBeGreaterThan(0) - expect(screen.getAllByText('较同材质').length).toBeGreaterThan(0) - expect(screen.getAllByText('节省 ¥50.00').length).toBeGreaterThan(0) - expect(screen.queryByText('-¥50.00')).not.toBeInTheDocument() - - const costSummaryAside = screen.getByText('预计出厂成本 · 橡胶木').closest('aside') - expect(costSummaryAside).not.toBeNull() - expect(within(costSummaryAside as HTMLElement).getByText('竞品售价 ¥600.00')).toBeInTheDocument() - expect(within(costSummaryAside as HTMLElement).getByText(/高于竞品售价 ¥700.00/)).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: '同材质基准' })) - expect(screen.queryByText('较同材质')).not.toBeInTheDocument() - expect(screen.queryByText('替换后预计')).not.toBeInTheDocument() - expect(screen.queryByText('节省 ¥50.00')).not.toBeInTheDocument() - expect(screen.getAllByText('实时采购预估 · 1688 同款').length).toBeGreaterThan(0) - expect(screen.getAllByText(/1688 同款价 ¥1,500\.00 × 90%/).length).toBeGreaterThan(0) - expect(screen.getAllByText(/结构拆解参考 ¥1,350\.00/).length).toBeGreaterThan(0) - expect(screen.getAllByText('历史价').length).toBeGreaterThan(0) + expect(screen.getAllByText(/占(?:已知成本 )?59%/).length).toBeGreaterThan(0) + expect(screen.queryByLabelText('选择替换目标材质')).not.toBeInTheDocument() + expect(screen.queryByText('生成替换方案')).not.toBeInTheDocument() + expect(screen.queryByText('竞品店铺')).not.toBeInTheDocument() + expect(screen.queryByText('item.example.com')).not.toBeInTheDocument() + expect(screen.queryByText(/1688|实时同款|实时采购|公司单价|公司历史 BOM|参考 SKU|竞品售价|非正式采购价/iu)).not.toBeInTheDocument() } finally { global.fetch = originalFetch } @@ -2752,10 +2585,8 @@ describe('BomCostingWorkbenchClient', () => { />, ) - expect(await screen.findByText('待确认品类商品')).toBeInTheDocument() - expect(screen.getAllByText('品类待确认').length).toBeGreaterThan(0) + expect(await screen.findByText('结构草稿')).toBeInTheDocument() expect(screen.getByText('请先确认品类')).toBeInTheDocument() - expect(screen.getAllByText('暂不估价').length).toBeGreaterThan(0) expect(screen.queryByText('¥0.00')).not.toBeInTheDocument() expect(screen.queryByRole('heading', { name: '分层成本预估' })).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 ff0c589d..c01e418f 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -2,7 +2,6 @@ import Image from 'next/image' import { - Fragment, useCallback, useEffect, useId, @@ -20,12 +19,9 @@ import { ChevronDown, ChevronRight, CircleAlert, - Clock3, - ExternalLink, Link2, ListFilter, PackageSearch, - Pencil, Play, RefreshCw, ScanSearch, @@ -41,10 +37,6 @@ import type { BomCostingCapabilities, BomCostingCaseDetail, BomCostingCaseSummary, - BomCostingDynamicCostingDetail, - BomCostingDynamicScenarioKindValue, - BomCostingLineDetail, - BomCostingPriceCandidateDetail, BomCostingScenarioDetail, BomCostingSkuDetail, } from '@/lib/bom-costing/client-types' @@ -89,55 +81,6 @@ interface RecalculationRequest { categorySkillId?: string } -type LineEditorMode = 'LIBRARY' | 'MANUAL' - -interface ManualReviewDraft { - materialCode: string - materialName: string - materialSpec: string - lengthMm: string - widthMm: string - heightMm: string - quantity: string - unitPrice: string - priceUnit: string - lossFactor: string - note: string -} - -type ManualReviewField = Exclude - -const MANUAL_REVIEW_FIELD_GROUPS: Array<{ - label: string - fields: Array -}> = [ - { - label: '材质与规格', - fields: [ - ['materialName', '材质名称', '如:橡胶木实木板'], - ['materialSpec', '材质规格', '如:18mm'], - ['materialCode', '材质编码', '可选'], - ], - }, - { - label: '部件尺寸', - fields: [ - ['lengthMm', '长度(mm)', '可选'], - ['widthMm', '宽度(mm)', '可选'], - ['heightMm', '厚度/高度(mm)', '可选'], - ], - }, - { - label: '计价参数', - fields: [ - ['quantity', '数量', '必填'], - ['unitPrice', '单价', '可选'], - ['priceUnit', '价格单位', '㎡ / 件 / 米'], - ['lossFactor', '损耗系数', '默认 1'], - ], - }, -] - const RECALCULATION_OPTIONS: Array<{ value: RecalculationMode label: string @@ -147,7 +90,7 @@ const RECALCULATION_OPTIONS: Array<{ { value: 'REUSE_FACTS', label: '按现有事实重算', - description: '只重新汇总结构、BOM 与资料库价格,复用已保存的逐图结果。', + description: '只重新汇总结构、BOM 与预估结果,复用已保存的逐图结果。', scope: '不读取竞品资料 · 不调用图片模型 · 仅汇总实际 SKU', }, { @@ -339,16 +282,6 @@ function estimateConfidenceLabel(value: number | null | undefined) { return '较低' } -const ESTIMATE_BASIS_LABELS = { - MASTER_DATA: '基础资料库', - DIRECT_HISTORY: '直接历史匹配', - SKU_FAMILY: '同款 SKU 互证', - SIMILAR_HISTORY: '相似历史 BOM', - CATEGORY_COHORT: '同分类历史分布', - AI_REFERENCE_ESTIMATE: '多模态模型参考预估', - REALTIME_1688_MARKET: '1688 实时同款采购预估', -} as const - const STRUCTURE_EVIDENCE_BASIS_LABELS = { IMAGE: '图片明确证据', SOURCE_TEXT: '商品文字', @@ -356,114 +289,30 @@ const STRUCTURE_EVIDENCE_BASIS_LABELS = { STRUCTURAL_INFERENCE: '装配关系推导', } as const +function publicEstimateWarning(value: string) { + if (!value) return '' + return /(?:1688|实时同款|同款价|采购系数|市场价|竞品售价|公司(?:历史)?\s*BOM|参考\s*SKU|公司价格来源|基础资料库|正式采购|历史用量|镜像(?:同步|批次)?|供应商|店铺|商品链接)/iu.test(value) + ? '当前预估仍有待补信息,建议补充结构或尺寸后重新核算' + : value +} + function scenarioEvidenceText( scenario: BomCostingScenarioDetail, structureAnalysis?: BomCostingCaseDetail['structureAnalysis'], ) { - const realtimeQuote = scenario.realtimeMarketQuote?.status === 'AVAILABLE' && - scenario.realtimeMarketQuote.procurementCost !== null - ? scenario.realtimeMarketQuote - : null - const basisLabels = Array.from(new Set(scenario.lines.flatMap((line) => ( - line.estimateBasis ? [ESTIMATE_BASIS_LABELS[line.estimateBasis]] : [] - )))) - if (basisLabels.length === 0 && scenario.estimateBasis) { - basisLabels.push(ESTIMATE_BASIS_LABELS[scenario.estimateBasis]) - } - const referenceCount = new Set(scenario.lines.flatMap((line) => line.referenceSkus)).size - const aiEstimatedLineCount = scenario.lines.filter( - (line) => line.estimateBasis === 'AI_REFERENCE_ESTIMATE', - ).length - const historicalLineSampleCount = Math.max(0, ...scenario.lines - .filter((line) => line.estimateBasis !== 'MASTER_DATA') - .map((line) => line.sampleCount ?? 0)) - const sampleCount = referenceCount || historicalLineSampleCount || ( - scenario.estimateBasis !== 'MASTER_DATA' ? scenario.sampleCount : 0 - ) - const boardCutting = scenario.boardCutting ?? [] - const boardCostTotal = boardCutting.reduce( - (sum, item) => sum + item.perUnitSheetCost, - 0, - ) - const weightedBoardUtilization = boardCostTotal > 0 - ? boardCutting.reduce( - (sum, item) => sum + item.utilization * item.perUnitSheetCost, - 0, - ) / boardCostTotal - : null - const selectedBatchSizes = Array.from(new Set( - boardCutting.map((item) => item.selectedBatchSize), - )).sort((left, right) => left - right) - const singleUnitBoardUpper = boardCutting.reduce( - (sum, item) => sum + item.singleUnitSheetCost, - 0, - ) return [ - realtimeQuote - ? `1688 同款实时价 ${formatMoney(realtimeQuote.marketPrice)} × ${(realtimeQuote.procurementRate * 100).toFixed(0)}%` - : '', `估算区间 ${formatEstimateRange(scenario.estimateRange)}`, `可信度 ${estimateConfidenceLabel(scenario.confidence)}${scenario.confidence !== null ? `(${Math.round(scenario.confidence * 100)}%)` : ''}`, - sampleCount > 0 - ? `参考 ${sampleCount} 套历史 BOM` - : basisLabels.includes(ESTIMATE_BASIS_LABELS.MASTER_DATA) - ? '参考基础资料库标准价格' - : aiEstimatedLineCount > 0 - ? '模型结合结构事实、已核算明细与公司知识形成区间' - : '历史样本数待确认', - basisLabels.length > 0 ? `依据 ${basisLabels.join('、')}` : '', - scenario.estimatedLineCount > 0 ? `${scenario.estimatedLineCount} 条明细采用分层预估` : '', - aiEstimatedLineCount > 0 ? `${aiEstimatedLineCount} 条明细为 AI 参考预估,需人工复核` : '', + scenario.unresolvedCount > 0 ? `${scenario.unresolvedCount} 条结构明细预估待补` : '结构明细已纳入整件预估', structureAnalysis ? `结构初判 1 轮,定向复核 ${structureAnalysis.verificationRoundCount} 轮` : '', - selectedBatchSizes.length > 0 - ? `板材按 ${selectedBatchSizes.join('、')} 件稳定批量排版` - : '', - weightedBoardUtilization !== null - ? `批量利用率 ${Math.round(weightedBoardUtilization * 100)}%` - : '', - singleUnitBoardUpper > 0 - ? `单件整板承担上限 ${formatMoney(singleUnitBoardUpper)}` - : '', - scenario.warning ? `提醒 ${scenario.warning}` : '', + scenario.warning ? `提醒 ${publicEstimateWarning(scenario.warning)}` : '', ].filter(Boolean).join(';') } -function realtimeMarketQuoteForScenario(scenario: BomCostingScenarioDetail | null | undefined) { - const quote = scenario?.realtimeMarketQuote - return quote?.status === 'AVAILABLE' && quote.procurementCost !== null - ? quote - : null -} - -function missingScenarioLabel(scenario: BomCostingScenarioDetail | null) { - if ( - !scenario || - !['dynamic-bom-cost/v5', 'dynamic-bom-cost/v6', 'dynamic-bom-cost/v7', 'dynamic-bom-cost/v8'].some((version) => - scenario.rulesetVersion.startsWith(version), - ) - ) { - return '旧版结果待重新核算' - } - return '核算未完成' -} - -function scenarioMarketPriceAlert(scenario: BomCostingScenarioDetail | null) { - if (!scenario) return null - if (scenario.marketPriceAlert) return scenario.marketPriceAlert - if (scenario.totalCost === null || scenario.competitorPrice === null) return null - if (scenario.totalCost > scenario.competitorPrice) return 'HIGH' as const - if (scenario.totalCost < scenario.competitorPrice * 0.1) return 'LOW' as const - return null -} - -function formatScenarioDifference(value: number | null | undefined) { - if (value === null || value === undefined) return '--' - if (Math.abs(value) < 0.005) return '无变化' - return value > 0 - ? `增加 ${formatMoney(value)}` - : `节省 ${formatMoney(Math.abs(value))}` +function missingScenarioLabel(_scenario: BomCostingScenarioDetail | null) { + return '资料不足,暂无法预估' } export function formatBomCategorySkillVersionHint({ @@ -690,7 +539,7 @@ function parseDynamicCostingCompletionNotice( return [{ skuId: readPayloadText(item.skuId) || null, externalSkuId: readPayloadText(item.externalSkuId) || null, - message: readPayloadText(item.message) || '成本预估生成失败', + message: '资料不足,暂无法预估', }] }) : [] @@ -789,8 +638,6 @@ function toSummary(detail: BomCostingCaseDetail): BomCostingCaseSummary { return { id: detail.id, title: detail.title, - sourceUrl: detail.sourceUrl, - sourceHost: detail.sourceHost, status: detail.status, progress: detail.progress, statusMessage: detail.statusMessage, @@ -820,22 +667,6 @@ function toSummary(detail: BomCostingCaseDetail): BomCostingCaseSummary { } } -function buildManualReviewDraft(line: BomCostingLineDetail): ManualReviewDraft { - return { - materialCode: line.materialCode, - materialName: line.materialName, - materialSpec: line.materialSpec, - lengthMm: line.lengthMm === null ? '' : String(line.lengthMm), - widthMm: line.widthMm === null ? '' : String(line.widthMm), - heightMm: line.heightMm === null ? '' : String(line.heightMm), - quantity: String(line.quantity), - unitPrice: line.resolvedUnitPrice === null ? '' : String(line.resolvedUnitPrice), - priceUnit: line.resolvedPriceUnit || line.unit, - lossFactor: String(line.lossFactor), - note: '', - } -} - function getStageState( detail: BomCostingCaseDetail, stage: (typeof PROCESS_STAGES)[number], @@ -893,16 +724,6 @@ function ProcessingCanvas({

{detail.title}

- - - {detail.sourceCacheHit ? ( 已复用资料 @@ -1165,29 +986,20 @@ function ProcessInspector({ ) : null} -
-
- - - {detail.providerTaskId} - -
-
) } function dynamicStatusLabel( status: BomCostingScenarioDetail['status'], - scope: 'scenario' | 'line' = 'scenario', ) { return status === 'FINAL' - ? scope === 'line' ? '历史价' : '预估完成' + ? '预估完成' : status === 'PROVISIONAL' - ? '预估 / 待复核' - : status === 'STALE' - ? '历史数据待更新' - : '核算未完成' + ? '预估完成' + : status === 'STALE' + ? '历史数据待更新' + : '资料不足' } const DYNAMIC_CATEGORY_LABELS: Record = { @@ -1214,66 +1026,18 @@ function dynamicStatusClass(status: BomCostingScenarioDetail['status']) { : 'bg-[#f3eddf] text-[#80613a]' } -function dynamicSyncLabel(state: BomCostingDynamicCostingDetail['sync']['state']) { - return state === 'READY' - ? '镜像新鲜' - : state === 'STALE' - ? '镜像已过期' - : state === 'MIXED' - ? '镜像批次不一致' - : '尚无成功镜像' -} - function DynamicCostingPanel({ sku, costing, structureAnalysis, - scenarioKind, - targetMaterialId, - calculating, - error, - onScenarioKindChange, - onTargetMaterialChange, - onCalculate, }: { sku: BomCostingSkuDetail - costing: BomCostingDynamicCostingDetail | undefined + costing: BomCostingCaseDetail['dynamicCosting'] structureAnalysis: BomCostingCaseDetail['structureAnalysis'] - scenarioKind: BomCostingDynamicScenarioKindValue - targetMaterialId: string - calculating: boolean - error: string - onScenarioKindChange: (kind: BomCostingDynamicScenarioKindValue) => void - onTargetMaterialChange: (id: string) => void - onCalculate: () => void }) { const skuScenarios = costing?.scenarios.filter((scenario) => scenario.skuId === sku.id) ?? [] const sameScenario = skuScenarios.find((scenario) => scenario.kind === 'SAME_MATERIAL') ?? null - const substitutionScenario = skuScenarios.find((scenario) => scenario.kind === 'MATERIAL_SUBSTITUTION') ?? null - const selectedScenario = scenarioKind === 'SAME_MATERIAL' ? sameScenario : substitutionScenario - const marketPriceAlert = scenarioMarketPriceAlert(selectedScenario) - const difference = sameScenario?.totalCost !== null && sameScenario?.totalCost !== undefined && - substitutionScenario?.totalCost !== null && substitutionScenario?.totalCost !== undefined - ? substitutionScenario.totalCost - sameScenario.totalCost - : null - const provisionalLineCount = selectedScenario?.lines.filter((line) => line.status === 'PROVISIONAL').length ?? 0 - const aiEstimatedLineCount = selectedScenario?.lines.filter( - (line) => line.estimateBasis === 'AI_REFERENCE_ESTIMATE', - ).length ?? 0 - const staleLineCount = selectedScenario?.lines.filter((line) => line.status === 'STALE').length ?? 0 - const materialName = costing?.materials.find((material) => material.id === targetMaterialId)?.name ?? '未选择' - const savedTargetMaterialId = substitutionScenario?.targetMaterialId ?? '' - const targetMaterialNeedsRefresh = Boolean( - substitutionScenario && targetMaterialId !== savedTargetMaterialId, - ) - const savedMaterialName = substitutionScenario?.targetMaterialName || materialName - const realtimeMarketQuote = scenarioKind === 'SAME_MATERIAL' - ? realtimeMarketQuoteForScenario(selectedScenario) - : null - const latestFailedAfterSuccess = Boolean( - costing?.sync.lastFailedAt && - (!costing.sync.lastSuccessfulAt || Date.parse(costing.sync.lastFailedAt) > Date.parse(costing.sync.lastSuccessfulAt)), - ) + const selectedScenario = sameScenario const categoryRows: Array<[string, number | null, keyof BomCostingScenarioDetail['categoryEstimateRanges']]> = selectedScenario ? [ ['材料', selectedScenario.materialCost, 'MATERIAL'], @@ -1283,8 +1047,7 @@ function DynamicCostingPanel({ ['损耗', selectedScenario.lossCost, 'LOSS'], ] : [] - const contributionBase = realtimeMarketQuote?.structuralCost ?? - selectedScenario?.totalCost ?? selectedScenario?.knownCost ?? 0 + const contributionBase = selectedScenario?.totalCost ?? 0 const contributionLines = (selectedScenario?.lines ?? []) .map((line, index) => ({ line, index })) .sort((left, right) => ( @@ -1305,7 +1068,7 @@ function DynamicCostingPanel({ id="bom-dynamic-costing" className="order-2 min-w-0 scroll-mt-4 border-t border-black/[0.07] pt-5 xl:order-none xl:col-start-1 xl:row-start-2" aria-labelledby="bom-dynamic-costing-title" - aria-busy={calculating} + aria-busy={false} >
@@ -1313,124 +1076,28 @@ function DynamicCostingPanel({
- {costing ? ( -
- - {dynamicSyncLabel(costing.sync.state)} - - - 镜像完成于 {formatActivityTime(costing.sync.dataAsOf)} - -
- ) : null}
{!costing ? (
- 这条记录还没有 v7 预估结果。重新核算后会自动生成同材质基准;需要比较时再选择替换材质。 + 这条记录还没有可用预估结果。可在“重新核算”中更新当前预估。
) : ( <> -
-
- - -
-
-
- {([ - ['SAME_MATERIAL', '同材质基准'], - ['MATERIAL_SUBSTITUTION', `替换为 ${targetMaterialNeedsRefresh ? materialName : savedMaterialName}${targetMaterialNeedsRefresh ? '(待重新生成)' : ''}`], - ] as const).map(([kind, label]) => ( - - ))} -
- {targetMaterialNeedsRefresh ? ( -

- 目标材质已更改,当前替换结果仍是上次保存的 {savedMaterialName},请重新生成。 -

- ) : null} -
-
- - {costing.materials.length === 0 ? ( -
-
- ) : null} - {costing.sync.state !== 'READY' || latestFailedAfterSuccess ? ( -
-
- ) : null} - {error ? ( -
{error}
- ) : null} - {selectedScenario ? ( <>
- {realtimeMarketQuote - ? '实时采购预估 · 1688 同款' - : scenarioKind === 'SAME_MATERIAL' - ? '预计出厂成本 · 同材质基准' - : `预计出厂成本 · 材质替换:${selectedScenario.targetMaterialName || materialName}`} + 预计出厂成本 {dynamicStatusLabel(selectedScenario.status)} @@ -1446,60 +1113,17 @@ function DynamicCostingPanel({ {scenarioEvidenceText(selectedScenario, structureAnalysis)}
- {selectedScenario.totalCost === null && - selectedScenario.knownCost !== null && - selectedScenario.knownCost > 0 ? ( -

- 已计部分 {formatMoney(selectedScenario.knownCost)}(不是整件成本) -

- ) : null} - {realtimeMarketQuote ? ( -

- 1688 同款实时价 {formatMoney(realtimeMarketQuote.marketPrice)} × {(realtimeMarketQuote.procurementRate * 100).toFixed(0)}% - {realtimeMarketQuote.comparableOfferCount > 0 ? ` · ${realtimeMarketQuote.comparableOfferCount} 条可比同款` : ''} - {realtimeMarketQuote.searchedAt ? ` · 查询于 ${formatActivityTime(realtimeMarketQuote.searchedAt)}` : ''}。 - 结构拆解 {realtimeMarketQuote.structuralCost !== null && realtimeMarketQuote.structuralCost !== undefined - ? `参考 ${formatMoney(realtimeMarketQuote.structuralCost)}` - : '仍在补全'},不会因单项待补价否定本次整件预估。 -

- ) : null} +

+ 结构拆解用于解释成本构成与后续复核;单项预估待补不会影响已形成的整件预估。 +

- {selectedScenario.competitorPrice !== null ? ( -
-
竞品售价
-
{formatMoney(selectedScenario.competitorPrice)}
-
- ) : null} - {scenarioKind === 'MATERIAL_SUBSTITUTION' ? ( -
-
较同材质
-
0 ? 'text-[#96504c]' : 'text-[#4d6b63]')}> - {formatScenarioDifference(difference)} -
-
- ) : null}
- {marketPriceAlert ? ( -
-
- ) : null} - {realtimeMarketQuote ? ( -
- 上方为实时采购预估;以下是结构拆解参考。某个部件暂缺价格时,只标记为待补,不会把整件预估改成失败。 -
- ) : null}
{categoryRows.map(([label, value, rangeKey]) => (
{label} - {value === null ? realtimeMarketQuote ? '待补结构价' : '核算未完成' : formatEstimatedMoney(value)} + {value === null ? '预估待补' : formatEstimatedMoney(value)} {value !== null ? ( 估算区间 {formatEstimateRange(selectedScenario.categoryEstimateRanges?.[rangeKey])} @@ -1529,19 +1153,16 @@ function DynamicCostingPanel({
- {dynamicStatusLabel(line.status, 'line')} + {line.subtotal === null ? '预估待补' : dynamicStatusLabel(line.status)}
-
计价数量 · 系数
+
数量
- {line.pricingQuantity ?? line.quantity ?? '--'} {line.unit || ''} · {line.coefficient ?? '--'} + {line.quantity ?? '--'} {line.unit || ''} {line.confidence !== null ? ` · 置信度 ${Math.round(line.confidence * 100)}%` : ''}
- {line.quantity !== line.pricingQuantity && line.quantity !== null ? ( -
原始数量 {line.quantity} {line.unit || ''}
- ) : null}
行小计
@@ -1550,17 +1171,10 @@ function DynamicCostingPanel({
- {line.estimateBasis === 'AI_REFERENCE_ESTIMATE' - ? AI 参考预估 · 非正式采购价 - : line.unitPrice !== null - ? 公司单价 {formatMoney(line.unitPrice)} / {line.unit || '单位'} - : line.subtotal !== null - ? 按历史分类金额预估 - : 没有有效价格来源} -
{line.priceSource || '待匹配公司历史 BOM'}
- {line.referenceSkus.length > 0 ?
参考 SKU:{line.referenceSkus.slice(0, 3).join('、')}
: null} - {line.warning ?
{line.warning}
: null} - {line.sourceSyncedAt ?
同步 {formatActivityTime(line.sourceSyncedAt)}
: null} + + {line.subtotal === null ? '预估待补' : '已计入整件预估'} + + {line.warning ?
{publicEstimateWarning(line.warning)}
: null}
))} @@ -1572,8 +1186,8 @@ function DynamicCostingPanel({ 分类 部件 / 材质 - 数量 · 系数 - 价格 / 预估依据 + 数量 + 预估金额 状态 行小计 @@ -1591,28 +1205,20 @@ function DynamicCostingPanel({ {[line.originalMaterial, line.appliedMaterial !== line.originalMaterial ? `→ ${line.appliedMaterial}` : '', line.materialSpec] .filter(Boolean).join(' · ') || '材质待确认'}
- {line.warning ?
{line.warning}
: null} + {line.warning ?
{publicEstimateWarning(line.warning)}
: null} - {line.pricingQuantity ?? line.quantity ?? '--'} {line.unit || ''} -
系数 {line.coefficient ?? '--'}
+ {line.quantity ?? '--'} {line.unit || ''} - {line.estimateBasis === 'AI_REFERENCE_ESTIMATE' - ?
AI 参考预估 · 非正式采购价
- : line.unitPrice !== null - ?
{formatMoney(line.unitPrice)} / {line.unit || '单位'}
- : line.subtotal !== null - ?
按历史分类金额预估
- :
没有有效价格来源
} -
{line.priceSource || '待匹配公司历史 BOM'}
- {line.referenceSkus.length > 0 ?
参考 SKU:{line.referenceSkus.slice(0, 3).join('、')}
: null} +
+ {line.subtotal === null ? '预估待补' : formatEstimatedMoney(line.subtotal)} +
+ {line.estimateRange ?
区间 {formatEstimateRange(line.estimateRange)}
: null} - {dynamicStatusLabel(line.status, 'line')} + {line.subtotal === null ? '预估待补' : dynamicStatusLabel(line.status)} {line.confidence !== null ?
置信度 {Math.round(line.confidence * 100)}%
: null} - {line.sourceCreatedAt ?
来源 {formatActivityTime(line.sourceCreatedAt)}
: null} - {line.sourceSyncedAt ?
同步 {formatActivityTime(line.sourceSyncedAt)}
: null} {formatEstimatedMoney(line.subtotal)} @@ -1626,23 +1232,15 @@ function DynamicCostingPanel({
{selectedScenario.unresolvedCount > 0 - ? `${selectedScenario.unresolvedCount} 行没有可验证价格来源` - : aiEstimatedLineCount > 0 - ? `${aiEstimatedLineCount} 行由多模态模型参考公司知识补充预估,整单已形成区间并等待复核` - : selectedScenario.status === 'FINAL' - ? '所有行均有可验证价格来源' - : staleLineCount > 0 - ? `${staleLineCount} 行价格已过期,汇总结果需复核` - : provisionalLineCount > 0 - ? `${provisionalLineCount} 行采用暂估或推导数量,汇总结果需复核` - : '明细均有价格来源,但汇总含暂估判断,需复核主要驱动项'} + ? `${selectedScenario.unresolvedCount} 行预估待补,不影响已形成的整件预估` + : '整件预估已形成,可结合结构明细继续复核。'} - 规则 {selectedScenario.rulesetVersion} · 批次 {selectedScenario.syncRunId || '--'} + 最近核算于 {formatActivityTime(selectedScenario.createdAt)}
) : (
- 选择目标材质并点击“生成双场景”,系统会保存同材质基准和替换材质的完整明细。 + 当前 SKU 暂无可用预估结果,可在“重新核算”中更新。
)} @@ -1656,7 +1254,6 @@ function ResultCanvas({ capabilities, selectedSku, onSelectSku, - onCaseUpdated, onRecalculate, onResumeRecalculation, recalculating, @@ -1665,7 +1262,6 @@ function ResultCanvas({ capabilities: BomCostingCapabilities selectedSku: BomCostingSkuDetail onSelectSku: (skuId: string) => void - onCaseUpdated: (detail: BomCostingCaseDetail) => void onRecalculate: (input: RecalculationRequest) => Promise onResumeRecalculation: () => Promise recalculating: boolean @@ -1703,34 +1299,8 @@ function ResultCanvas({ const [selectedAssetIdBySku, setSelectedAssetIdBySku] = useState< Record >({}) - const [editingPriceLineId, setEditingPriceLineId] = useState( - null, - ) - const [editingLineMode, setEditingLineMode] = useState( - null, - ) - const [manualReviewDraft, setManualReviewDraft] = useState( - null, - ) - const [priceCandidatesByLine, setPriceCandidatesByLine] = useState< - Record - >({}) - const [priceCandidateQuery, setPriceCandidateQuery] = useState('') - const [loadingPriceLineId, setLoadingPriceLineId] = useState( - null, - ) - const [savingPriceLineId, setSavingPriceLineId] = useState( - null, - ) - const [priceEditorError, setPriceEditorError] = useState('') - const [dynamicScenarioKind, setDynamicScenarioKind] = - useState('SAME_MATERIAL') - const [dynamicTargetMaterialId, setDynamicTargetMaterialId] = useState('') - const [dynamicCalculating, setDynamicCalculating] = useState(false) - const [dynamicCalculationError, setDynamicCalculationError] = useState('') const [recalculationOpen, setRecalculationOpen] = useState(false) const [structureEvidenceOpen, setStructureEvidenceOpen] = useState(false) - const [reviewArchiveOpen, setReviewArchiveOpen] = useState(false) const [recalculationMode, setRecalculationMode] = useState('REUSE_FACTS') const [selectedReanalysisAssetIds, setSelectedReanalysisAssetIds] = @@ -1859,67 +1429,13 @@ function ResultCanvas({ const sameMaterialScenario = selectedSkuDynamicScenarios.find( (scenario) => scenario.kind === 'SAME_MATERIAL', ) ?? null - const substitutionScenario = selectedSkuDynamicScenarios.find( - (scenario) => scenario.kind === 'MATERIAL_SUBSTITUTION', - ) ?? null - const selectedDynamicScenario = dynamicScenarioKind === 'SAME_MATERIAL' - ? sameMaterialScenario - : substitutionScenario - const selectedRealtimeMarketQuote = dynamicScenarioKind === 'SAME_MATERIAL' - ? realtimeMarketQuoteForScenario(selectedDynamicScenario) - : null - const dynamicScenarioDifference = sameMaterialScenario?.totalCost !== null && sameMaterialScenario?.totalCost !== undefined && - substitutionScenario?.totalCost !== null && substitutionScenario?.totalCost !== undefined - ? substitutionScenario.totalCost - sameMaterialScenario.totalCost - : null - const selectedDynamicMarketPriceAlert = scenarioMarketPriceAlert(selectedDynamicScenario) - - useEffect(() => { - const materials = dynamicCosting?.materials ?? [] - const savedTargetId = substitutionScenario?.targetMaterialId ?? '' - setDynamicTargetMaterialId((current) => { - if (current && materials.some((material) => material.id === current)) return current - if (savedTargetId && materials.some((material) => material.id === savedTargetId)) return savedTargetId - return materials[0]?.id ?? '' - }) - setDynamicCalculationError('') - }, [detail.id, dynamicCosting?.materials, selectedSku.id, substitutionScenario?.targetMaterialId]) + const selectedDynamicScenario = sameMaterialScenario useEffect(() => { setRecalculationCategorySkillId(selectedCategorySkillId) setCategorySkillEdited(false) }, [detail.id, selectedCategorySkillId]) - const calculateDynamicCosting = async () => { - if (!dynamicTargetMaterialId || dynamicCalculating) return - setDynamicCalculating(true) - setDynamicCalculationError('') - try { - const response = await fetch( - `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/scenarios`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - skuId: selectedSku.id, - targetMaterialId: dynamicTargetMaterialId, - }), - }, - ) - const data = await readApiData<{ - dynamicCosting: BomCostingDynamicCostingDetail - }>(response) - onCaseUpdated({ ...detail, dynamicCosting: data.dynamicCosting }) - setDynamicScenarioKind('MATERIAL_SUBSTITUTION') - } catch (error) { - setDynamicCalculationError( - error instanceof Error ? error.message : '双场景成本生成失败,请重试', - ) - } finally { - setDynamicCalculating(false) - } - } - const setSelectedAssetId = (assetId: string) => { setSelectedAssetIdBySku((current) => ({ ...current, @@ -1952,16 +1468,16 @@ function ResultCanvas({ const failedRecalculationTitle = failedRecalculationRun?.mode === 'REANALYZE_ALL' - ? '上次“全部重新分析”未完成' + ? '上次“全部重新分析”暂未形成预估' : failedRecalculationRun?.mode === 'REANALYZE_SELECTED' - ? '上次“补充分析图片”未完成' - : '上次重新核算未完成' + ? '上次“补充分析图片”暂未形成预估' + : '上次重新核算暂未形成预估' const failedRecalculationResumeLabel = failedRecalculationRun?.mode === 'REANALYZE_ALL' ? '继续全部图片重新分析' : failedRecalculationRun?.mode === 'REANALYZE_SELECTED' ? '继续补充图片分析' - : '继续结构与价格汇总' + : '继续生成预估' const prepareReuseFactsRecalculation = () => { setRecalculationOpen(true) @@ -2031,151 +1547,9 @@ function ResultCanvas({ } const scrollToBom = () => { - const bomLines = document.getElementById('bom-costing-lines') - scrollIntoView(bomLines, { block: 'start' }) - } - - const openPriceEditor = async (line: BomCostingLineDetail) => { - if (editingPriceLineId === line.id && editingLineMode === 'LIBRARY') { - setEditingPriceLineId(null) - setEditingLineMode(null) - setManualReviewDraft(null) - setPriceEditorError('') - return - } - setEditingPriceLineId(line.id) - setEditingLineMode('LIBRARY') - setManualReviewDraft(null) - setPriceCandidateQuery('') - setPriceEditorError('') - if (priceCandidatesByLine[line.id]) return - - setLoadingPriceLineId(line.id) - try { - const response = await fetch( - `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`, - { cache: 'no-store' }, - ) - const data = await readApiData<{ - candidates: BomCostingPriceCandidateDetail[] - }>(response) - setPriceCandidatesByLine((current) => ({ - ...current, - [line.id]: data.candidates, - })) - } catch (error) { - setPriceEditorError( - error instanceof Error ? error.message : '资料库价格读取失败', - ) - } finally { - setLoadingPriceLineId(null) - } - } - - const openManualReviewEditor = (line: BomCostingLineDetail) => { - if (editingPriceLineId === line.id && editingLineMode === 'MANUAL') { - setEditingPriceLineId(null) - setEditingLineMode(null) - setManualReviewDraft(null) - setPriceEditorError('') - return - } - setEditingPriceLineId(line.id) - setEditingLineMode('MANUAL') - setManualReviewDraft(buildManualReviewDraft(line)) - setPriceEditorError('') - } - - const confirmPriceCandidate = async ( - line: BomCostingLineDetail, - candidate: BomCostingPriceCandidateDetail, - ) => { - if (savingPriceLineId) return - setSavingPriceLineId(line.id) - setPriceEditorError('') - try { - const response = await fetch( - `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - matchedLibraryRecordId: candidate.recordId, - ...(line.category === 'BOARD' && - line.heightMm === null && - candidate.thicknessMm !== null - ? { heightMm: candidate.thicknessMm } - : {}), - }), - }, - ) - const data = await readApiData<{ case: BomCostingCaseDetail }>(response) - onCaseUpdated(data.case) - setEditingPriceLineId(null) - setEditingLineMode(null) - setManualReviewDraft(null) - } catch (error) { - setPriceEditorError( - error instanceof Error ? error.message : '价格确认失败,请重试', - ) - } finally { - setSavingPriceLineId(null) - } - } - - const saveManualReview = async (line: BomCostingLineDetail) => { - if (!manualReviewDraft || savingPriceLineId) return - - const parsePositive = ( - value: string, - label: string, - emptyValue: number | null, - ) => { - const normalized = value.trim() - if (!normalized) return emptyValue - const parsed = Number(normalized) - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`${label}必须是大于 0 的数字`) - } - return parsed - } - - try { - const review = { - materialCode: manualReviewDraft.materialCode.trim(), - materialName: manualReviewDraft.materialName.trim(), - materialSpec: manualReviewDraft.materialSpec.trim(), - lengthMm: parsePositive(manualReviewDraft.lengthMm, '长度', null), - widthMm: parsePositive(manualReviewDraft.widthMm, '宽度', null), - heightMm: parsePositive(manualReviewDraft.heightMm, '厚度/高度', null), - quantity: parsePositive(manualReviewDraft.quantity, '数量', line.quantity), - unitPrice: parsePositive(manualReviewDraft.unitPrice, '单价', null), - priceUnit: manualReviewDraft.priceUnit.trim(), - lossFactor: parsePositive(manualReviewDraft.lossFactor, '损耗系数', 1), - note: manualReviewDraft.note.trim(), - } - setSavingPriceLineId(line.id) - setPriceEditorError('') - const response = await fetch( - `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ review }), - }, - ) - const data = await readApiData<{ case: BomCostingCaseDetail }>(response) - onCaseUpdated(data.case) - setEditingPriceLineId(null) - setEditingLineMode(null) - setManualReviewDraft(null) - } catch (error) { - setPriceEditorError( - error instanceof Error ? error.message : '复核记录保存失败,请重试', - ) - } finally { - setSavingPriceLineId(null) - } + scrollIntoView(document.getElementById('bom-costing-lines'), { + block: 'start', + }) } const hasPendingCost = @@ -2191,10 +1565,10 @@ function ResultCanvas({ : requiresRecalculation ? '旧版结果待重新核算' : selectedDynamicCostState === 'FAILED' - ? '成本生成失败' - : selectedDynamicCostState === 'PENDING' + ? '资料不足,暂无法预估' + : selectedDynamicCostState === 'PENDING' ? '待补成本' - : '核算未完成' + : '资料不足,暂无法预估' const missingCostGroups = new Set( selectedSku.costSummary.missingCostGroups ?? [], ) @@ -2227,14 +1601,14 @@ function ResultCanvas({ ? '当前记录由旧规则生成' : '', selectedSku.costSummary.unresolvedLineCount > 0 - ? `${selectedSku.costSummary.unresolvedLineCount} 项价格待确认` + ? `${selectedSku.costSummary.unresolvedLineCount} 项结构明细待补` : '', missingCostGroupLabels.length > 0 ? `${missingCostGroupLabels.join('、')}尚未识别` : '', ].filter(Boolean).join(',') const approximateCostLineCount = selectedSku.bomLines.filter( - (line) => line.matchStatus === 'NEEDS_CONFIRMATION' && line.subtotal !== null, + (line) => line.estimateStatus === 'ESTIMATED' && line.sourceConfidence !== null && line.sourceConfidence < 0.55, ).length const costSummaryRows = [ { @@ -2298,9 +1672,6 @@ function ResultCanvas({ const lowConfidenceStructureCount = selectedStructureItems.filter( (item) => item.confidence < 0.55 || item.reviewReasons.length > 0, ).length - const currentSkuReviewInputs = (detail.reviewInputs ?? []) - .filter((input) => !input.skuId || input.skuId === selectedSku.id) - .slice(0, 8) const recalculationPanelOpen = recalculationOpen && detail.status === 'READY' @@ -2351,25 +1722,25 @@ function ResultCanvas({ className="shrink-0 border-b border-[#b98743]/25 bg-[#fbf8f0]" role={dynamicCompletionNotice.failedSkuCount > 0 ? 'alert' : 'status'} aria-label={dynamicCompletionNotice.failedSkuCount > 0 - ? '原材质批次部分完成,包含成本生成失败' - : '原材质批次部分完成'} + ? '本轮批次部分完成,部分 SKU 暂无法预估' + : '本轮批次部分完成'} data-testid="bom-dynamic-cost-partial-notice" >