= {
FAILED: 'bg-[#b6534e]',
}
-function getHistoryStatusLabel(item: BomCostingCaseSummary) {
+export function getHistoryStatusLabel(item: BomCostingCaseSummary) {
if (item.status !== 'READY') return STATUS_LABELS[item.status]
if (item.requiresRecalculation) return '旧规则结果'
if (item.hasFailedRecalculation) return '重算失败'
@@ -194,17 +137,16 @@ function getHistoryStatusLabel(item: BomCostingCaseSummary) {
(item.dynamicCostingSummary?.failedSkuCount ?? 0) > 0 ||
(item.dynamicCostingSummary?.pendingSkuCount ?? 0) > 0
) return '部分完成'
- if (item.needsPriceReview) return '待复核'
+ if (item.needsPriceReview) return '预估完成(结构待复核)'
return STATUS_LABELS[item.status]
}
-function getHistoryStatusDotClass(item: BomCostingCaseSummary) {
+export function getHistoryStatusDotClass(item: BomCostingCaseSummary) {
if (
item.status === 'READY' &&
(
item.requiresRecalculation ||
item.hasFailedRecalculation ||
- item.needsPriceReview ||
(item.dynamicCostingSummary?.failedSkuCount ?? 0) > 0 ||
(item.dynamicCostingSummary?.pendingSkuCount ?? 0) > 0
)
@@ -339,15 +281,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: '多模态模型参考预估',
-} as const
-
const STRUCTURE_EVIDENCE_BASIS_LABELS = {
IMAGE: '图片明确证据',
SOURCE_TEXT: '商品文字',
@@ -355,100 +288,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 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 [
`估算区间 ${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 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({
@@ -541,6 +404,7 @@ function formatMetric(value: number | null, suffix = '') {
function formatDuration(milliseconds: number | null) {
if (milliseconds === null || milliseconds < 0) return '--'
const totalSeconds = Math.floor(milliseconds / 1_000)
+ if (milliseconds > 0 && totalSeconds === 0) return '不足 1 秒'
if (totalSeconds < 60) return `${totalSeconds} 秒`
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
@@ -576,6 +440,7 @@ interface DynamicCostingCompletionNotice {
pendingSkuCount: number
failedSkuCount: number
reviewSkuCount: number
+ qualityWarningCount: number
succeededSkuIds: string[]
costedSkuIds: string[]
pendingSkuIds: string[]
@@ -675,7 +540,7 @@ function parseDynamicCostingCompletionNotice(
return [{
skuId: readPayloadText(item.skuId) || null,
externalSkuId: readPayloadText(item.externalSkuId) || null,
- message: readPayloadText(item.message) || '成本预估生成失败',
+ message: '资料不足,暂无法预估',
}]
})
: []
@@ -685,6 +550,7 @@ function parseDynamicCostingCompletionNotice(
pendingSkuCount,
failedSkuCount,
reviewSkuCount: readPayloadNumber(payload.reviewSkuCount) ?? 0,
+ qualityWarningCount: readPayloadNumber(payload.qualityWarningCount) ?? 0,
succeededSkuIds: readPayloadTextArray(payload.succeededSkuIds),
costedSkuIds: readPayloadTextArray(payload.costedSkuIds),
pendingSkuIds: readPayloadTextArray(payload.pendingSkuIds),
@@ -774,8 +640,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,
@@ -805,22 +669,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],
@@ -878,16 +726,6 @@ function ProcessingCanvas({
{detail.title}
-
-
-
{detail.sourceCacheHit ? (
已复用资料
@@ -956,8 +794,8 @@ function ProcessInspector({
new Date(detail.processingStartedAt).getTime()
: null
const durationMs =
- readNumber(completedPayload?.durationMs) ??
- processingDurationMs
+ processingDurationMs ??
+ readNumber(completedPayload?.durationMs)
const imageCount =
readNumber(completedPayload?.imageCount) ??
readNumber(startedPayload?.imageCount)
@@ -1150,29 +988,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 = {
@@ -1199,63 +1028,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 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'],
@@ -1265,7 +1049,7 @@ function DynamicCostingPanel({
['损耗', selectedScenario.lossCost, 'LOSS'],
]
: []
- const contributionBase = selectedScenario?.totalCost ?? selectedScenario?.knownCost ?? 0
+ const contributionBase = selectedScenario?.totalCost ?? 0
const contributionLines = (selectedScenario?.lines ?? [])
.map((line, index) => ({ line, index }))
.sort((left, right) => (
@@ -1286,7 +1070,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}
>
@@ -1294,120 +1078,28 @@ function DynamicCostingPanel({
分层成本预估
- 板材优先按基础资料库整板规格和裁切利用率计算,其余项目结合公司历史 BOM 本地镜像;竞品售价只用于异常提示,不会隐藏成本金额。
+ 结构、材质、尺寸与历史知识用于形成预估区间;单项待补不会影响已经形成的整件预估。
- {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 ? (
-
-
-
- {latestFailedAfterSuccess
- ? costing.sync.lastFailedMessage
- ? `最近同步未成功:${costing.sync.lastFailedMessage}。`
- : '最近一次同步未成功,当前仍使用上次成功批次。'
- : '当前结果仍会保留,但镜像不是当天成功批次,需复核数据新鲜度。'}
- {costing.sync.lastSuccessfulAt ? ` 最近成功同步于 ${formatActivityTime(costing.sync.lastSuccessfulAt)}。` : ''}
-
-
- ) : null}
- {error ? (
-
{error}
- ) : null}
-
{selectedScenario ? (
<>
- {scenarioKind === 'SAME_MATERIAL' ? '预计出厂成本 · 同材质基准' : `预计出厂成本 · 材质替换:${selectedScenario.targetMaterialName || materialName}`}
+ 预计出厂成本
{dynamicStatusLabel(selectedScenario.status)}
@@ -1423,46 +1115,18 @@ function DynamicCostingPanel({
{scenarioEvidenceText(selectedScenario, structureAnalysis)}
- {selectedScenario.totalCost === null &&
- selectedScenario.knownCost !== null &&
- selectedScenario.knownCost > 0 ? (
-
- 已计部分 {formatMoney(selectedScenario.knownCost)}(不是整件成本)
-
- ) : null}
+
+ 结构拆解用于解释成本构成与后续复核;单项预估待补不会影响已形成的整件预估。
+
- {selectedScenario.competitorPrice !== null ? (
-
-
竞品售价
-
{formatMoney(selectedScenario.competitorPrice)}
-
- ) : null}
- {scenarioKind === 'MATERIAL_SUBSTITUTION' ? (
-
-
较同材质
-
0 ? 'text-[#96504c]' : 'text-[#4d6b63]')}>
- {formatScenarioDifference(difference)}
-
-
- ) : null}
- {marketPriceAlert ? (
-
-
-
- {marketPriceAlert === 'HIGH'
- ? '预估成本高于竞品售价,金额已保留;请优先复核贡献最大的材料、损耗和单位换算。'
- : '预估成本低于竞品售价的 10%,金额已保留;请优先复核结构是否完整。'}
-
-
- ) : null}
{categoryRows.map(([label, value, rangeKey]) => (
{label}
- {value === null || selectedScenario.totalCost === null ? '核算未完成' : formatEstimatedMoney(value)}
- {value !== null && selectedScenario.totalCost !== null ? (
+ {value === null ? '预估待补' : formatEstimatedMoney(value)}
+ {value !== null ? (
估算区间 {formatEstimateRange(selectedScenario.categoryEstimateRanges?.[rangeKey])}
@@ -1491,19 +1155,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}
行小计
@@ -1512,17 +1173,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}
))}
@@ -1534,8 +1188,8 @@ function DynamicCostingPanel({
| 分类 |
部件 / 材质 |
- 数量 · 系数 |
- 价格 / 预估依据 |
+ 数量 |
+ 预估金额 |
状态 |
行小计 |
@@ -1553,28 +1207,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)}
@@ -1588,23 +1234,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 暂无可用预估结果,可在“重新核算”中更新。
)}
>
@@ -1618,7 +1256,6 @@ function ResultCanvas({
capabilities,
selectedSku,
onSelectSku,
- onCaseUpdated,
onRecalculate,
onResumeRecalculation,
recalculating,
@@ -1627,7 +1264,6 @@ function ResultCanvas({
capabilities: BomCostingCapabilities
selectedSku: BomCostingSkuDetail
onSelectSku: (skuId: string) => void
- onCaseUpdated: (detail: BomCostingCaseDetail) => void
onRecalculate: (input: RecalculationRequest) => Promise
onResumeRecalculation: () => Promise
recalculating: boolean
@@ -1665,34 +1301,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] =
@@ -1821,64 +1431,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 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,
@@ -1911,16 +1470,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)
@@ -1990,151 +1549,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 =
@@ -2150,10 +1567,10 @@ function ResultCanvas({
: requiresRecalculation
? '旧版结果待重新核算'
: selectedDynamicCostState === 'FAILED'
- ? '成本生成失败'
- : selectedDynamicCostState === 'PENDING'
+ ? '资料不足,暂无法预估'
+ : selectedDynamicCostState === 'PENDING'
? '待补成本'
- : '核算未完成'
+ : '资料不足,暂无法预估'
const missingCostGroups = new Set(
selectedSku.costSummary.missingCostGroups ?? [],
)
@@ -2186,14 +1603,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 = [
{
@@ -2257,9 +1674,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'
@@ -2304,31 +1718,41 @@ function ResultCanvas({
{dynamicCompletionNotice && (
dynamicCompletionNotice.pendingSkuCount > 0 ||
dynamicCompletionNotice.failedSkuCount > 0 ||
- dynamicCompletionNotice.reviewSkuCount > 0
+ dynamicCompletionNotice.reviewSkuCount > 0 ||
+ dynamicCompletionNotice.qualityWarningCount > 0
) ? (
0 ? 'alert' : 'status'}
aria-label={dynamicCompletionNotice.failedSkuCount > 0
- ? '原材质批次部分完成,包含成本生成失败'
- : '原材质批次部分完成'}
+ ? '本轮批次部分完成,部分 SKU 暂无法预估'
+ : dynamicCompletionNotice.qualityWarningCount > 0
+ ? '本轮预估已完成,结构拆解待复核'
+ : '本轮批次部分完成'}
data-testid="bom-dynamic-cost-partial-notice"
>
- 原材质批次部分完成
+
+ {dynamicCompletionNotice.failedSkuCount > 0 || dynamicCompletionNotice.pendingSkuCount > 0
+ ? '本轮批次部分完成'
+ : '本轮预估已完成'}
+
- {dynamicCompletionNotice.costedSkuCount}/{dynamicCompletionNotice.totalSkuCount} 个 SKU 的原材质预估已完成
+ {dynamicCompletionNotice.costedSkuCount}/{dynamicCompletionNotice.totalSkuCount} 个 SKU 的预估已完成
{dynamicCompletionNotice.pendingSkuCount > 0
? `,${dynamicCompletionNotice.pendingSkuCount} 个待补成本`
: ''}
{dynamicCompletionNotice.failedSkuCount > 0
- ? `,${dynamicCompletionNotice.failedSkuCount} 个生成失败`
+ ? `,${dynamicCompletionNotice.failedSkuCount} 个暂无法预估`
: ''}
{dynamicCompletionNotice.reviewSkuCount > 0
? `,${dynamicCompletionNotice.reviewSkuCount} 个待复核`
- : ''}。这是原材质批次结果,不代表当前材质替换方案已经完成;其余 SKU 可按现有事实重新核算。
+ : ''}
+ {dynamicCompletionNotice.qualityWarningCount > 0
+ ? ';结构拆解差异已标记待复核,当前整件预估已保留'
+ : '。'}
{dynamicCompletionNotice.failures.length > 0 ? (
@@ -2339,11 +1763,11 @@ function ResultCanvas({
sku.id === failure.skuId || sku.externalSkuId === failure.externalSkuId
))?.name ?? failure.externalSkuId ?? '未知 SKU'}
- · {failure.message}
+ · 当前资料不足,暂无法预估
))}
{dynamicCompletionNotice.failures.length > 4 ? (
- - 其余 {dynamicCompletionNotice.failures.length - 4} 个失败 SKU 已记录。
+ - 其余 {dynamicCompletionNotice.failures.length - 4} 个暂无法预估的 SKU 已记录。
) : null}
) : null}
@@ -2470,13 +1894,6 @@ function ResultCanvas({
sku={selectedSku}
costing={dynamicCosting}
structureAnalysis={detail.structureAnalysis}
- scenarioKind={dynamicScenarioKind}
- targetMaterialId={dynamicTargetMaterialId}
- calculating={dynamicCalculating}
- error={dynamicCalculationError}
- onScenarioKindChange={setDynamicScenarioKind}
- onTargetMaterialChange={setDynamicTargetMaterialId}
- onCalculate={() => void calculateDynamicCosting()}
/>
) : null}
@@ -2566,8 +1983,7 @@ function ResultCanvas({
部件 / 材质 |
尺寸 |
数量 |
- 价格来源 |
- 小计 |
+ 预估状态 |
@@ -2576,29 +1992,8 @@ function ResultCanvas({
const evidenceAssetId = evidence?.imageUrl
? assetIdByStoredUrl.get(evidence.imageUrl) ?? null
: null
- const canConfirmFromLibrary = [
- 'BOARD',
- 'STEEL',
- 'ACCESSORY',
- 'PACKAGING',
- ].includes(line.category)
- const editing = editingPriceLineId === line.id
- const query = priceCandidateQuery.trim().toLowerCase()
- const candidates = priceCandidatesByLine[line.id] ?? []
- const visibleCandidates = query
- ? candidates.filter((candidate) =>
- [
- candidate.code,
- candidate.name,
- candidate.spec,
- ].some((value) =>
- value.toLowerCase().includes(query),
- ),
- )
- : candidates
return (
-
-
+
|
{line.categoryLabel}
@@ -2645,272 +2040,19 @@ function ResultCanvas({
{line.quantity} {line.unit}
|
- 价格来源
-
-
- {line.category === 'PROCESSING' ||
- line.category === 'LABOR' ? (
-
- {line.resolvedUnitPrice === null
- ? 'AI 估算待补'
- : 'AI 估算'}
-
- ) : canConfirmFromLibrary ? (
-
- ) : null}
- {line.resolvedUnitPrice !== null ? (
-
- {formatMoney(line.resolvedUnitPrice)}/{line.resolvedPriceUnit || line.unit}
-
- ) : null}
-
- {canModifyCurrentCosting ? (
-
- ) : null}
-
- |
-
- 小计
- {formatMoney(line.subtotal)}
+ 预估状态
+
+ {line.estimateStatus === 'PENDING' || line.subtotal === null
+ ? '预估待补'
+ : '已纳入预估'}
+
|
- {editing ? (
-
-
-
-
- {editingLineMode === 'MANUAL' && manualReviewDraft ? (
-
-
-
- 记录人工复核值
-
- 保存本次测算的尺寸、材质和价格,后续可用于复核模型与计算规则。
-
-
-
- 不改资料库
-
-
-
- {MANUAL_REVIEW_FIELD_GROUPS.map((group) => (
-
- ))}
-
-
- {priceEditorError ? (
-
- {priceEditorError}
-
- ) : null}
-
-
- ) : (
- <>
-
-
-
- 确认资料库价格
-
-
- {line.category === 'PACKAGING'
- ? '价格只来自资产中心的可用包材规格款'
- : line.category === 'BOARD' && line.heightMm === null
- ? '材种已识别,请确认板厚与对应基材'
- : '选择与当前部件规格一致的资料库记录'}
-
-
- {candidates.length > 6 ? (
-
- setPriceCandidateQuery(event.target.value)
- }
- placeholder="搜索名称、编码或规格"
- aria-label="搜索价格候选"
- leftIcon={ }
- containerClassName="w-full !space-y-0 sm:w-[240px]"
- className="!min-h-9 !rounded-[5px] !bg-white !py-1.5 text-[10px]"
- />
- ) : null}
-
-
- {loadingPriceLineId === line.id ? (
-
-
- 正在读取资料库价格
-
- ) : priceEditorError ? (
-
- {priceEditorError}
-
- ) : candidates.length === 0 ? (
-
- {line.category === 'PACKAGING'
- ? '资产中心暂无已启用且有价格的包材规格款'
- : '基础资料库暂无可用于当前分类的价格'}
-
- ) : visibleCandidates.length === 0 ? (
-
- 没有匹配的资料库记录
-
- ) : (
-
- {visibleCandidates.map((candidate) => (
-
- ))}
-
- )}
- >
- )}
-
-
-
- |
-
- ) : null}
-
)
})}
@@ -2937,9 +2079,6 @@ function ResultCanvas({
{detail.title}
-
- {detail.sourceSnapshot?.shopName || detail.sourceHost}
-
品类
{selectedCategoryLabel}
@@ -2948,16 +2087,6 @@ function ResultCanvas({
-
-
-
@@ -2966,9 +2095,7 @@ function ResultCanvas({
{currentCostingIsProcessing
? '新一轮成本处理中'
: selectedDynamicScenario
- ? dynamicScenarioKind === 'SAME_MATERIAL'
- ? '预计出厂成本 · 同材质基准'
- : `预计出厂成本 · ${selectedDynamicScenario.targetMaterialName || '材质替换'}`
+ ? '预计出厂成本'
: categorySupportStatus === 'AMBIGUOUS'
? '品类待确认'
: categorySupportStatus === 'UNSUPPORTED'
@@ -2976,7 +2103,7 @@ function ResultCanvas({
: requiresRecalculation
? '旧版成本留档'
: selectedDynamicCostState === 'FAILED'
- ? '成本生成失败'
+ ? '资料不足,暂无法预估'
: selectedDynamicCostState === 'PENDING'
? '待补成本'
: hasPendingCost
@@ -2992,13 +2119,13 @@ function ResultCanvas({
: selectedDynamicScenario
? `${scenarioEvidenceText(selectedDynamicScenario, detail.structureAnalysis)};含材料、加工、人工、包材和损耗,不含税费、物流、平台费用和利润。`
: categoryCostBlocked
- ? `${categoryBlockedLabel}。系统已保留结构草稿;在“重新核算”中选择品类后才会进入历史 BOM 定价。`
+ ? `${categoryBlockedLabel}。系统已保留结构草稿;在“重新核算”中选择品类后会继续形成预估。`
: requiresRecalculation
? '当前留档由旧规则生成,不能用于价格判断;重新核算后会保留新的版本。'
: selectedDynamicCostState === 'FAILED'
- ? `本轮该 SKU 成本生成失败:${selectedDynamicFailure?.message ?? '未返回可用成本结果'}。已保留结构事实,可按现有事实重新核算。`
- : selectedDynamicCostState === 'PENDING'
- ? '本轮该 SKU 待补成本:结构事实已保存,但公司价格资料尚未闭合,补齐后再生成完整预估。'
+ ? '本轮未形成可用整件预估。已保留结构事实,可补充结构或尺寸后重新核算。'
+ : selectedDynamicCostState === 'PENDING'
+ ? '本轮该 SKU 的结构事实已保存;待补信息完善后可继续形成完整预估。'
: hasPendingCost
? '图片事实与 SKU 结构已保存;材料裁切、加工、人工和包材规则将逐项补齐后形成完整成本。'
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。'}
@@ -3022,46 +2149,10 @@ function ResultCanvas({
上一轮成本留档:{formatMoney(selectedSku.estimatedCost)}
) : null}
- {selectedDynamicScenario &&
- selectedDynamicScenario.totalCost === null &&
- selectedDynamicScenario.knownCost !== null &&
- selectedDynamicScenario.knownCost > 0 ? (
-
- 已计部分 {formatMoney(selectedDynamicScenario.knownCost)}(不是整件成本)
-
- ) : null}
{selectedDynamicScenario ? (
{dynamicStatusLabel(selectedDynamicScenario.status)}
- 镜像完成于 {formatActivityTime(selectedDynamicScenario.dataAsOf)}
-
- ) : null}
- {selectedDynamicScenario && selectedDynamicScenario.competitorPrice !== null ? (
-
- 竞品售价 {formatMoney(selectedDynamicScenario.competitorPrice)}
-
- ) : null}
- {selectedDynamicScenario && selectedDynamicMarketPriceAlert ? (
-
-
-
- {selectedDynamicMarketPriceAlert === 'HIGH'
- ? `高于竞品售价${selectedDynamicScenario.priceDelta !== null ? ` ${formatMoney(selectedDynamicScenario.priceDelta)}` : ''},金额已保留,需复核主要驱动项。`
- : '低于竞品售价的 10%,金额已保留,需复核结构完整性。'}
-
-
- ) : null}
- {selectedDynamicScenario && dynamicScenarioKind === 'MATERIAL_SUBSTITUTION' ? (
-
- 较同材质
- 0
- ? 'text-[#f2c0b5]'
- : 'text-[#b8d7cc]',
- )}>
- {formatScenarioDifference(dynamicScenarioDifference)}
-
+ 最近核算于 {formatActivityTime(selectedDynamicScenario.createdAt)}
) : null}
{selectedDynamicScenario && selectedSku.estimatedCost !== null ? (
@@ -3105,18 +2196,18 @@ function ResultCanvas({
? '新一轮核算完成前,分层成本只标记为上一轮历史留档,不参与当前判断。'
: selectedDynamicScenario
? selectedDynamicScenario.unresolvedCount > 0
- ? `${selectedDynamicScenario.unresolvedCount} 条明细没有形成预估;本次核算未完成。`
- : `${scenarioEvidenceText(selectedDynamicScenario, detail.structureAnalysis)};规则 ${selectedDynamicScenario.rulesetVersion}。板材按整板裁切利用率计算,竞品售价只做合理性检查。`
+ ? `${selectedDynamicScenario.unresolvedCount} 条结构明细预估待补;整件预估已形成,不受单项待补影响。`
+ : `${scenarioEvidenceText(selectedDynamicScenario, detail.structureAnalysis)};结构拆解用于理解构成与后续复核。`
: categoryCostBlocked
? `${categoryBlockedLabel};当前仅保存结构草稿,不生成 ¥0 或任何推测金额。`
: selectedDynamicCostState === 'FAILED'
- ? `本轮该 SKU 成本生成失败:${selectedDynamicFailure?.message ?? '未返回可用成本结果'}。`
+ ? '该规格暂未形成预估;当前结构与资料已保留。'
: selectedDynamicCostState === 'PENDING'
- ? '本轮该 SKU 待补成本;公司价格资料补齐后再生成完整成本。'
+ ? '该规格仍在整理预估依据;当前结构与资料已保留。'
: hasPendingCost
? 'SKU 结构已就绪;补齐对应成本资料后会形成完整成本。'
: incompleteCostMessage
- ? `${incompleteCostMessage},按现有事实重新核算可补齐。`
+ ? `${incompleteCostMessage},当前资料会继续用于预估。`
: approximateCostLineCount > 0
? `${approximateCostLineCount} 项采用旧资料库近似价,仅作为历史留档。`
: `误差 ≤${detail.targetErrorPercent}% 是核算目标,需经真实采购成本回测验证。`}
@@ -3201,19 +2292,6 @@ function ResultCanvas({
原因:{reviewReasons.slice(0, 2).join(';')}
) : null}
- {line && needsReview && canModifyCurrentCosting ? (
-
- ) : null}
)
@@ -3249,61 +2327,6 @@ function ResultCanvas({
) : null}
- setReviewArchiveOpen(event.currentTarget.open)}
- >
-
- 核算留档
-
- {detail.reviewInputs?.length ?? 0} 条
-
-
-
-
-
-
- {currentSkuReviewInputs.length > 0 ? (
- currentSkuReviewInputs.map((input) => (
-
-
-
- {input.skuName ? `${input.skuName} · ` : ''}{input.partName}
-
-
- {input.source === 'MANUAL' ? '人工' : '资料库'}
-
-
-
- {[input.lengthMm, input.widthMm, input.heightMm]
- .filter((value) => value !== null)
- .join(' × ') || '尺寸未填'}
- {' · '}
- {input.materialName || input.materialSpec || '材质未填'}
- {' · '}
- {input.unitPrice === null
- ? '价格未填'
- : `${formatMoney(input.unitPrice)}/${input.priceUnit || '单位'}`}
-
-
- {input.createdByName} · {new Date(input.createdAt).toLocaleString('zh-CN', {
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- })}
- {input.note ? ` · ${input.note}` : ''}
-
-
- ))
- ) : (
-
- 暂无人工测算留档。
-
- )}
-
-
-
{loadingCaseId === item.id ? (
@@ -4284,7 +3318,6 @@ export default function BomCostingWorkbenchClient({
capabilities={initialCapabilities}
selectedSku={selectedSku}
onSelectSku={setSelectedSkuId}
- onCaseUpdated={applyCaseDetail}
onRecalculate={handleRecalculate}
onResumeRecalculation={handleResumeRecalculation}
recalculating={recalculatingCaseId === selectedCase.id}
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..feaa398e 100644
--- a/furniture-3d-system/src/lib/bom-costing/client-types.ts
+++ b/furniture-3d-system/src/lib/bom-costing/client-types.ts
@@ -14,8 +14,6 @@ export type BomCostingCaseStatusValue =
export interface BomCostingCaseSummary {
id: string
title: string
- sourceUrl: string
- sourceHost: string
status: BomCostingCaseStatusValue
progress: number
statusMessage: string
@@ -61,10 +59,7 @@ export interface BomCostingSourceAssetDetail {
export interface BomCostingSourceSnapshotDetail {
id: string
status: BomCostingSourceStatusValue
- channel: string
- externalItemId: string
title: string
- shopName: string
skuCount: number
imageCount: number
storedImageCount: number
@@ -95,7 +90,6 @@ export interface BomCostingLineDetail {
lossFactor: number
sourceConfidence: number | null
evidence: Array<{
- sourceUrl?: string
imageUrl?: string
basis?: 'IMAGE' | 'SOURCE_TEXT' | 'SKU_METADATA' | 'STRUCTURAL_INFERENCE'
note?: string
@@ -104,30 +98,11 @@ export interface BomCostingLineDetail {
reviewReason?: string
confidence: number
}>
- matchStatus: 'UNMATCHED' | 'MATCHED' | 'NEEDS_CONFIRMATION'
- matchedLibraryType: string
- matchedLibraryRecordId: string
- matchedLibraryName: string
- matchedLibrarySpec: string
- resolvedUnitPrice: number | null
- resolvedPriceUnit?: string
- calculatedQuantity: number | null
+ estimateStatus: 'ESTIMATED' | 'PENDING'
subtotal: number | null
updatedAt: string
}
-export interface BomCostingPriceCandidateDetail {
- recordId: string
- libraryType: string
- code: string
- name: string
- spec: string
- unit: string
- unitPrice: number
- thicknessMm: number | null
- recommended: boolean
-}
-
export interface BomCostingSkuDetail {
id: string
externalSkuId: string
@@ -160,11 +135,7 @@ export interface BomCostingSkuDetail {
export interface BomCostingCaseDetail {
id: string
- sourceUrl: string
- sourceHost: string
title: string
- providerCode: string
- providerTaskId: string
sourceCacheHit: boolean
status: BomCostingCaseStatusValue
progress: number
@@ -209,30 +180,6 @@ export interface BomCostingCaseDetail {
} | null
sourceSnapshot: BomCostingSourceSnapshotDetail | null
skus: BomCostingSkuDetail[]
- reviewInputs?: Array<{
- id: string
- skuId: string | null
- lineId: string | null
- skuName: string
- partName: string
- source: 'MANUAL' | 'LIBRARY_MATCH'
- materialCode: string
- materialName: string
- materialSpec: string
- lengthMm: number | null
- widthMm: number | null
- heightMm: number | null
- quantity: number | null
- unitPrice: number | null
- priceUnit: string
- currency: string
- lossFactor: number | null
- subtotal: number | null
- note: string
- createdById: string | null
- createdByName: string
- createdAt: string
- }>
analysisRuns: Array<{
id: string
revision: number
@@ -269,24 +216,12 @@ export interface BomCostingCaseDetail {
dynamicCosting?: BomCostingDynamicCostingDetail
}
-export type BomCostingDynamicScenarioKindValue =
- | 'SAME_MATERIAL'
- | 'MATERIAL_SUBSTITUTION'
-
export type BomCostingDynamicStatusValue =
| 'FINAL'
| 'PROVISIONAL'
| 'INSUFFICIENT_DATA'
| 'STALE'
-export type BomCostingEstimateBasisValue =
- | 'MASTER_DATA'
- | 'DIRECT_HISTORY'
- | 'SKU_FAMILY'
- | 'SIMILAR_HISTORY'
- | 'CATEGORY_COHORT'
- | 'AI_REFERENCE_ESTIMATE'
-
export interface BomCostingEstimateRangeValue {
min: number
max: number
@@ -294,8 +229,6 @@ export interface BomCostingEstimateRangeValue {
export interface BomCostingScenarioLineDetail {
id: string
- sourceLineId: string | null
- sourceRowId: string
category: string
partName: string
originalMaterial: string
@@ -303,40 +236,22 @@ export interface BomCostingScenarioLineDetail {
materialSpec: string
unit: string
quantity: number | null
- coefficient: number | null
- pricingQuantity: number | null
- unitPrice: number | null
subtotal: number | null
estimateRange: BomCostingEstimateRangeValue | null
- estimateBasis: BomCostingEstimateBasisValue | null
- sampleCount: number
- priceSource: string
- sourceSku: string
- referenceSkus: string[]
- referenceRowIds: string[]
- sourceFingerprint: string
- sourceCreatedAt: string | null
- sourceSyncedAt: string | null
- calculationRule: string
confidence: number | null
status: BomCostingDynamicStatusValue
warning: string
- rawSnapshot: unknown
}
export interface BomCostingScenarioDetail {
id: string
skuId: string
- kind: BomCostingDynamicScenarioKindValue
- targetMaterialId: string | null
- targetMaterialName: string
+ kind: 'SAME_MATERIAL'
revision: number
isCurrent: boolean
status: BomCostingDynamicStatusValue
totalCost: number | null
estimateRange: BomCostingEstimateRangeValue | null
- estimateBasis: BomCostingEstimateBasisValue | null
- sampleCount: number
categoryEstimateRanges: {
MATERIAL: BomCostingEstimateRangeValue | null
PROCESSING: BomCostingEstimateRangeValue | null
@@ -344,59 +259,20 @@ export interface BomCostingScenarioDetail {
PACKAGING: BomCostingEstimateRangeValue | null
LOSS: BomCostingEstimateRangeValue | null
}
- estimatedLineCount: number
- knownCost: number | null
materialCost: number | null
processingCost: number | null
laborCost: number | null
packagingCost: number | null
lossCost: number | null
- competitorPrice: number | null
- priceDelta: number | null
- marketPriceAlert?: 'LOW' | 'HIGH' | null
confidence: number | null
warning: string
unresolvedCount: number
- dataAsOf: string | null
- syncRunId: string | null
- syncStatus: string | null
- syncCompletedAt: string | null
- rulesetVersion: string
createdAt: string
- boardCutting: Array<{
- sourceLineId: string | null
- materialName: string
- rulesetVersion: string
- selectedBatchSize: number
- batchSheetCount: number
- perUnitSheetCost: number
- utilization: number
- singleUnitSheetCount: number
- singleUnitSheetCost: number
- }>
lines: BomCostingScenarioLineDetail[]
}
export interface BomCostingDynamicCostingDetail {
- materials: Array<{
- id: string
- code: string
- name: string
- category: string
- aliases: string[]
- }>
scenarios: BomCostingScenarioDetail[]
- sync: {
- state: 'EMPTY' | 'READY' | 'MIXED' | 'STALE'
- fresh: boolean
- dataAsOf: string | null
- sourceAsOf: string | null
- lastSuccessfulAt: string | null
- lastSuccessfulBatchId: string | null
- lastFailedAt: string | null
- lastFailedMessage: string
- maxAgeDays: number
- }
}
export type {
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..95971d86 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,8 @@ type ScenarioCostResultInput = {
categoryTotals: Record
unresolvedCount: number
warning: string
+ realtimeMarketQuote?: unknown
+ wholeProductEstimateAudit?: unknown
lines: Array>
}
@@ -269,6 +271,12 @@ 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,
+ wholeProductEstimateAudit: input.result.wholeProductEstimateAudit === null || input.result.wholeProductEstimateAudit === undefined
+ ? undefined
+ : input.result.wholeProductEstimateAudit as Prisma.InputJsonValue,
warning: input.result.warning,
unresolvedCount: input.result.unresolvedCount,
confidence: input.result.confidence,
diff --git a/furniture-3d-system/src/server/repositories/bom-costing.repository.ts b/furniture-3d-system/src/server/repositories/bom-costing.repository.ts
index 04746292..c826de82 100644
--- a/furniture-3d-system/src/server/repositories/bom-costing.repository.ts
+++ b/furniture-3d-system/src/server/repositories/bom-costing.repository.ts
@@ -151,21 +151,6 @@ const caseDetailInclude = Prisma.validator()({
orderBy: { revision: 'desc' },
take: 20,
},
- reviewInputs: {
- orderBy: { createdAt: 'desc' },
- take: 100,
- include: {
- creator: {
- select: { id: true, name: true },
- },
- sku: {
- select: { name: true },
- },
- line: {
- select: { partName: true },
- },
- },
- },
})
export type BomCostingCaseDetailRecord = Prisma.BomCostingCaseGetPayload<{
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..b6672287
--- /dev/null
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.test.ts
@@ -0,0 +1,270 @@
+import {
+ BomCosting1688ImageSearchService,
+ buildAlibaba1688SignedHeaders,
+ buildBomCosting1688RealtimeMarketQuote,
+ canonicalAlibaba1688Resource,
+ extractAlibaba1688AkKeys,
+ getBomCosting1688ImageSearchConfig,
+ resolveBomCostingImageSearchSourceUrl,
+ type BomCosting1688ImageSearchConfig,
+} from './bom-costing-1688-image-search'
+
+const testAk = Buffer.from('0123456789abcdef0123456789abcdeftest-ak-id').toString('base64url')
+
+const config: BomCosting1688ImageSearchConfig = {
+ 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,
+ procurementRate: 0.9,
+ pageSize: 10,
+ purchaseAmount: 1,
+ 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('turns only the public same-origin storage route into an absolute image URL', () => {
+ expect(resolveBomCostingImageSearchSourceUrl(
+ '/api/storage/object?key=prod%2Fsku.webp',
+ (key) => `https://objects.example.com/${key}`,
+ )).toEqual(
+ new URL('https://objects.example.com/prod/sku.webp'),
+ )
+ expect(() => resolveBomCostingImageSearchSourceUrl('/api/auth/me')).toThrow('竞品链接格式不正确')
+ })
+
+ 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({
+ 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('signs the pure Base64 request body and accepts official model/item aliases', 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: true,
+ model: {
+ count: 1,
+ data: [{
+ item_id: 1,
+ subject: '同款',
+ score: 0.99,
+ price: 200,
+ quantity_begin: 2,
+ store_amount: 99,
+ companyName: '测试工厂',
+ }],
+ },
+ }),
+ } as unknown as Response
+ const fetch = jest.fn()
+ .mockResolvedValueOnce(imageResponse)
+ .mockResolvedValueOnce(apiResponse)
+ const service = new BomCosting1688ImageSearchService({
+ getConfig: () => config,
+ fetch,
+ now: () => new Date('2026-08-16T10:00:00.000Z'),
+ randomBytes: () => Buffer.from([1, 2, 3, 4]),
+ createStoredObjectReadUrl: (key) => `https://objects.example.com/${key}`,
+ })
+
+ const quote = await service.findRealtimeQuote({
+ imageUrl: 'https://images.example.com/sku.jpg',
+ })
+
+ 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]
+ 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('fetches a stored same-origin product image through its backing object storage URL', 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: true, model: { count: 0, data: [] } }),
+ } as unknown as Response
+ const fetch = jest.fn()
+ .mockResolvedValueOnce(imageResponse)
+ .mockResolvedValueOnce(apiResponse)
+ const service = new BomCosting1688ImageSearchService({
+ getConfig: () => config,
+ fetch,
+ now: () => new Date('2026-08-16T10:00:00.000Z'),
+ randomBytes: () => Buffer.from([1, 2, 3, 4]),
+ createStoredObjectReadUrl: (key) => `https://objects.example.com/${key}`,
+ })
+
+ await expect(service.findRealtimeQuote({
+ imageUrl: '/api/storage/object?key=prod%2Fbom-costing%2Fsku.webp',
+ })).resolves.toMatchObject({ status: 'NO_COMPARABLE_OFFER' })
+ expect(fetch.mock.calls[0]?.[0]).toEqual(
+ new URL('https://objects.example.com/prod/bom-costing/sku.webp'),
+ )
+ })
+
+ 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'),
+ randomBytes: () => Buffer.from([1, 2, 3, 4]),
+ })
+
+ await expect(service.findRealtimeQuote({
+ imageUrl: 'https://images.example.com/sku.jpg',
+ })).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
new file mode 100644
index 00000000..1fcdbc43
--- /dev/null
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-1688-image-search.ts
@@ -0,0 +1,603 @@
+import 'server-only'
+
+import { createHash, createHmac, randomBytes } from 'node:crypto'
+import { z } from 'zod'
+import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
+import { createObjectReadUrl, resolveObjectStorageUrlForServerAccess } from '@/lib/storage'
+import type {
+ DynamicBomRealtimeMarketOffer,
+ 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
+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).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),
+ 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),
+ stockAmount: nullableFiniteNumber,
+}).passthrough()
+
+export interface BomCosting1688ImageSearchConfig {
+ endpoint: URL
+ /** URL-safe Base64 AK. It is never exposed to browser clients or logs. */
+ ak: 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
+ randomBytes(size: number): Buffer
+ createStoredObjectReadUrl?: (key: string) => string
+}
+
+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.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 = new URL(explicitEndpoint || OFFICIAL_IMAGE_SEARCH_ENDPOINT)
+ } catch {
+ return null
+ }
+ if (
+ endpoint.protocol !== 'https:' ||
+ endpoint.hostname !== 'skills-gateway.1688.com' ||
+ endpoint.pathname !== IMAGE_SEARCH_PATH
+ ) return null
+
+ return {
+ endpoint,
+ ak,
+ timeoutMs: boundedPositiveInteger(
+ 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.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.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.ALIBABA_1688_PROCUREMENT_RATE ??
+ process.env.BOM_COSTING_1688_PROCUREMENT_RATE,
+ DEFAULT_PROCUREMENT_RATE,
+ 0.5,
+ 1,
+ ),
+ pageSize: boundedPositiveInteger(
+ process.env.ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE ??
+ process.env.BOM_COSTING_1688_IMAGE_SEARCH_PAGE_SIZE,
+ 10,
+ 10,
+ 50,
+ ),
+ purchaseAmount: boundedPositiveInteger(
+ process.env.ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT ??
+ process.env.BOM_COSTING_1688_IMAGE_SEARCH_PURCHASE_AMOUNT,
+ 1,
+ 1,
+ 100_000,
+ ),
+ tags: (
+ process.env.ALIBABA_1688_IMAGE_SEARCH_TAGS ??
+ process.env.BOM_COSTING_1688_IMAGE_SEARCH_TAGS
+ )?.trim() || '4306497',
+ }
+}
+
+/**
+ * Source snapshots keep object-storage links as same-origin relative paths.
+ * The server-side image-search request must read the backing object directly:
+ * the browser proxy correctly requires a login cookie and therefore cannot be
+ * used by a background pricing job. Only its storage-object route is eligible
+ * for this conversion.
+ */
+export function resolveBomCostingImageSearchSourceUrl(
+ rawValue: string,
+ buildStoredObjectReadUrl: (key: string) => string = createObjectReadUrl,
+): URL {
+ const trimmed = rawValue.trim()
+ if (trimmed.startsWith('/api/storage/object?')) {
+ const parsed = new URL(trimmed, 'https://bom-costing.invalid')
+ const objectKey = parsed.searchParams.get('key')?.trim()
+ if (!objectKey) throw new Error('SKU 主图缺少对象存储 key')
+ return parseSafeCompetitorUrl(buildStoredObjectReadUrl(objectKey))
+ }
+ return parseSafeCompetitorUrl(resolveObjectStorageUrlForServerAccess(trimmed))
+}
+
+const defaultGateway: BomCosting1688ImageSearchGateway = {
+ getConfig: getBomCosting1688ImageSearchConfig,
+ fetch: (...args) => fetch(...args),
+ now: () => new Date(),
+ randomBytes: (size) => randomBytes(size),
+ createStoredObjectReadUrl: createObjectReadUrl,
+}
+
+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)
+ 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: ImageSearchItem[]
+}): 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: null,
+ 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 = resolveBomCostingImageSearchSourceUrl(
+ sourceImageUrl,
+ this.gateway.createStoredObjectReadUrl,
+ )
+ 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 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',
+ ...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),
+ // The body is signed. Never let a client re-serialize it after MD5.
+ body,
+ })
+ 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}`)
+ return buildBomCosting1688RealtimeMarketQuote({
+ sourceImageUrl,
+ searchedAt,
+ procurementRate: config.procurementRate,
+ minSimilarityScore: config.minSimilarityScore,
+ returnedOffers: readImageSearchResponse(payload),
+ })
+ } catch {
+ // The provider response may contain offer data or diagnostics. Keep it
+ // out of ordinary application logs; the calling estimate degrades to a
+ // source-neutral no-reference result instead.
+ console.warn('[BOM costing] realtime procurement reference unavailable')
+ 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.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-ai-cost-estimator.test.ts
index 6c309571..e410de5a 100644
--- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-ai-cost-estimator.test.ts
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-ai-cost-estimator.test.ts
@@ -3,6 +3,7 @@
import {
BomCostingAiCostEstimator,
parseBomCostingAiEstimatePayload,
+ parseBomCostingAiWholeProductEstimatePayload,
type BomCostingAiEstimateInput,
} from './bom-costing-ai-cost-estimator'
@@ -178,4 +179,79 @@ describe('BomCostingAiCostEstimator', () => {
quotes: { SAME_MATERIAL: {}, MATERIAL_SUBSTITUTION: {} },
})
})
+
+ it('在明细资料不完整时仍将整件模型预估规范化为低可信区间', async () => {
+ const parsed = parseBomCostingAiWholeProductEstimatePayload({
+ estimatedTotal: 180,
+ estimateLow: 210,
+ estimateHigh: 150,
+ confidence: 0.73,
+ reason: '结合鞋柜尺寸、抽屉结构和相近产品成本关系形成整件预估',
+ referenceRowIds: ['row-1', 'invented-row', 'row-1'],
+ }, new Set(['row-1']))
+
+ expect(parsed).toEqual({
+ totalCost: 180,
+ estimateRange: { min: 150, max: 210 },
+ confidence: 0.5,
+ reason: '结合鞋柜尺寸、抽屉结构和相近产品成本关系形成整件预估',
+ referenceRowIds: ['row-1'],
+ })
+ })
+
+ it('产品资料存在时请求整件模型预估,不要求完整 BOM 明细', async () => {
+ let callInput: Record | null = null
+ const estimator = new BomCostingAiCostEstimator({
+ getConfig: async () => ({
+ baseUrl: 'https://example.com/v1',
+ apiKey: 'test-key',
+ model: 'test-multimodal-model',
+ }),
+ call: async (input) => {
+ callInput = input as unknown as Record
+ return {
+ id: 'whole-call-1',
+ model: 'test-multimodal-model',
+ content: '{}',
+ promptTokens: 180,
+ completionTokens: 70,
+ durationMs: 430,
+ finishReason: 'stop',
+ }
+ },
+ parse: () => ({
+ estimatedTotal: 188,
+ estimateLow: 145,
+ estimateHigh: 232,
+ confidence: 0.48,
+ reason: '结合鞋柜名称、外形尺寸和相近柜体制造成本关系形成预估',
+ referenceRowIds: ['row-1'],
+ }),
+ })
+
+ const result = await estimator.estimateWholeProductCost({
+ product: {
+ ...estimateInput().product,
+ attributes: { color: '原木色', drawerCount: 3 },
+ },
+ knownLines: [],
+ companyReferences: estimateInput().companyReferences,
+ })
+
+ expect(result).toMatchObject({
+ totalCost: 188,
+ estimateRange: { min: 145, max: 232 },
+ confidence: 0.48,
+ callId: 'whole-call-1',
+ model: 'test-multimodal-model',
+ referenceRowIds: ['row-1'],
+ sourceAudit: expect.objectContaining({
+ calculationRule: 'multimodal-whole-product-estimate/v1',
+ companyReferenceCount: 1,
+ }),
+ })
+ const payload = JSON.parse(String(callInput?.userContent))
+ expect(payload.product.attributes).toContain('原木色')
+ expect(payload.constraints.mustAttemptEstimateWhenProductContextExists).toBe(true)
+ })
})
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..c57c4218 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 {
@@ -16,6 +17,8 @@ import {
const AI_COST_ESTIMATE_RULE = 'multimodal-reference-cost-estimate/v1'
const AI_COST_PROMPT_VERSION = 'bom-costing-ai-estimate/v1'
+const AI_WHOLE_PRODUCT_ESTIMATE_RULE = 'multimodal-whole-product-estimate/v1'
+const AI_WHOLE_PRODUCT_PROMPT_VERSION = 'bom-costing-whole-product-estimate/v1'
export interface BomCostingAiEstimateRequestLine {
requestId: string
@@ -83,6 +86,27 @@ export interface BomCostingAiEstimateInput {
requests: BomCostingAiEstimateRequestLine[]
knownLines: BomCostingAiKnownLine[]
companyReferences: BomCostingAiCompanyReference[]
+ realtimeMarketReference?: DynamicBomRealtimeMarketQuote | null
+}
+
+export interface BomCostingAiWholeProductEstimateInput {
+ product: BomCostingAiEstimateInput['product'] & {
+ attributes: unknown
+ }
+ knownLines: BomCostingAiKnownLine[]
+ companyReferences: BomCostingAiCompanyReference[]
+}
+
+export interface BomCostingAiWholeProductEstimateResult {
+ totalCost: number | null
+ estimateRange: { min: number; max: number } | null
+ confidence: number | null
+ reason: string | null
+ referenceRowIds: string[]
+ warning: string | null
+ callId: string | null
+ model: string | null
+ sourceAudit: Record | null
}
export interface BomCostingAiEstimateResult {
@@ -120,6 +144,15 @@ const aiEstimateLineSchema = z.object({
referenceRowIds: z.array(z.string().trim().min(1).max(200)).max(30).default([]),
})
+const wholeProductEstimateSchema = z.object({
+ estimatedTotal: z.coerce.number().finite().positive().max(1_000_000),
+ estimateLow: z.coerce.number().finite().positive().max(1_000_000),
+ estimateHigh: z.coerce.number().finite().positive().max(1_000_000),
+ confidence: z.coerce.number().finite().min(0.1).max(0.75),
+ reason: z.string().trim().min(4).max(500),
+ referenceRowIds: z.array(z.string().trim().min(1).max(200)).max(30).default([]),
+})
+
export interface ParsedBomCostingAiEstimate {
requestId: string
estimatedSubtotal: number
@@ -165,6 +198,27 @@ export function parseBomCostingAiEstimatePayload(
return estimates
}
+export function parseBomCostingAiWholeProductEstimatePayload(
+ payload: unknown,
+ availableReferenceIds: ReadonlySet,
+) {
+ const parsed = wholeProductEstimateSchema.parse(payload)
+ const point = Number(parsed.estimatedTotal.toFixed(2))
+ const lower = Math.min(parsed.estimateLow, parsed.estimateHigh, point)
+ const upper = Math.max(parsed.estimateLow, parsed.estimateHigh, point)
+ return {
+ totalCost: point,
+ estimateRange: {
+ min: Number(lower.toFixed(2)),
+ max: Number(upper.toFixed(2)),
+ },
+ confidence: Math.min(0.5, parsed.confidence),
+ reason: parsed.reason,
+ referenceRowIds: Array.from(new Set(parsed.referenceRowIds
+ .filter((rowId) => availableReferenceIds.has(rowId)))),
+ }
+}
+
function emptyResult(input: {
requestedCount: number
warning?: string | null
@@ -198,6 +252,7 @@ function buildSystemPrompt() {
return [
'你是家具制造成本预估助手。任务是补齐缺少直接公司价格的 BOM 明细,不是生成正式采购报价。',
'公司历史 BOM、公司主数据和已核算明细都是参考证据,不是缺一不可的硬门槛。',
+ '若提供了 1688 实时同款采购预估,它是整件 SKU 的当前市场锚点:优先用于让缺价明细的总和保持合理,但不得把整件价格伪装成某一块板材或五金的精确单价。',
'当没有完全相同的公司记录时,结合部件结构、材质、尺寸、数量、同类公司记录和常见家具制造成本关系,给出合理的工厂成本区间。',
'不得因为缺少完全相同的价格来源而拒绝预估,也不得返回 0、null 或把竞品售价倒推成成本。',
'优先使用 companyReferences;证据较弱时降低 confidence、扩大区间并在 reason 中说明。',
@@ -208,6 +263,37 @@ function buildSystemPrompt() {
].join('\n')
}
+function buildWholeProductSystemPrompt() {
+ return [
+ '你是家具制造整件成本预估助手。任务是根据产品资料、已识别的结构、尺寸、材质和公司历史知识,为一个 SKU 给出可用于业务判断的整件成本预估。',
+ '这不是正式采购报价,也不是要求资料齐全后才能计算的精确核算。公司历史 BOM 和已核算明细只是参考证据,不是硬门槛。',
+ '即使有部分部件没有直接价格,也必须在产品名称、尺寸、结构或已知成本中存在任一有效信息时给出非零整件预估;通过降低 confidence 和扩大区间表达不确定性。',
+ '不能返回 0、null、或因为缺少完全相同的价格来源而拒绝预估。只有完全没有可识别产品资料时才可以不输出结果。',
+ '不得把市场售价、外部商品价格或链接当作正式采购价;输出仅代表结合现有资料得出的制造成本区间。',
+ '严格输出一个 JSON 对象:',
+ '{"estimatedTotal":123.45,"estimateLow":100,"estimateHigh":150,"confidence":0.4,"reason":"...","referenceRowIds":["..."]}',
+ '不要输出 Markdown,不要输出 JSON 以外的文字。',
+ ].join('\n')
+}
+
+function emptyWholeProductResult(input?: {
+ warning?: string | null
+ callId?: string | null
+ model?: string | null
+}): BomCostingAiWholeProductEstimateResult {
+ return {
+ totalCost: null,
+ estimateRange: null,
+ confidence: null,
+ reason: null,
+ referenceRowIds: [],
+ warning: input?.warning ?? null,
+ callId: input?.callId ?? null,
+ model: input?.model ?? null,
+ sourceAudit: null,
+ }
+}
+
export class BomCostingAiCostEstimator {
constructor(private readonly gateway: BomCostingAiEstimatorGateway = defaultGateway) {}
@@ -245,10 +331,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,
},
}
@@ -343,6 +439,114 @@ export class BomCostingAiCostEstimator {
})
}
}
+
+ async estimateWholeProductCost(
+ input: BomCostingAiWholeProductEstimateInput,
+ ): Promise {
+ const hasUsableProductContext = Boolean(
+ input.product.name.trim() ||
+ input.product.structureGroupKey.trim() ||
+ input.product.costFamilyKey.trim() ||
+ input.product.lengthMm ||
+ input.product.widthMm ||
+ input.product.heightMm ||
+ input.knownLines.length > 0,
+ )
+ if (!hasUsableProductContext) {
+ return emptyWholeProductResult({
+ warning: '产品资料不足,暂无法形成整件预估',
+ })
+ }
+
+ let config: BomCostingMultimodalProviderConfig | null
+ try {
+ config = await this.gateway.getConfig()
+ } catch (error) {
+ console.warn('[BOM costing] whole-product AI estimator config unavailable', {
+ error: error instanceof Error ? error.message : String(error),
+ })
+ return emptyWholeProductResult({
+ warning: '多模态整件预估服务暂时不可用,可稍后重新核算',
+ })
+ }
+ if (!config) {
+ return emptyWholeProductResult({
+ warning: '尚未配置多模态整件预估服务,暂无法形成整件预估',
+ })
+ }
+
+ const referenceRows = input.companyReferences.slice(0, 80)
+ const userPayload = {
+ schemaVersion: AI_WHOLE_PRODUCT_PROMPT_VERSION,
+ currency: 'CNY',
+ product: {
+ ...input.product,
+ attributes: compactEvidence(input.product.attributes),
+ },
+ knownCostLines: input.knownLines.slice(0, 80),
+ companyReferences: referenceRows,
+ constraints: {
+ estimatesAreReferenceOnly: true,
+ officialPriceRequired: false,
+ preserveUncertainty: true,
+ mustAttemptEstimateWhenProductContextExists: true,
+ },
+ }
+
+ try {
+ const call = await this.gateway.call({
+ config,
+ operation: 'SYNTHESIS',
+ systemPrompt: buildWholeProductSystemPrompt(),
+ userContent: JSON.stringify(userPayload),
+ maxTokens: readBomCostingBoundedInteger(
+ process.env.BOM_COSTING_AI_WHOLE_PRODUCT_MAX_TOKENS,
+ 1_500,
+ 500,
+ 4_000,
+ ),
+ timeoutMs: readBomCostingBoundedInteger(
+ process.env.BOM_COSTING_AI_WHOLE_PRODUCT_TIMEOUT_MS,
+ 60_000,
+ 10_000,
+ 120_000,
+ ),
+ retrySynthesisTimeout: false,
+ })
+ const estimate = parseBomCostingAiWholeProductEstimatePayload(
+ this.gateway.parse(call.content),
+ new Set(referenceRows.map((row) => row.sourceRowId)),
+ )
+ return {
+ ...estimate,
+ warning: null,
+ callId: call.id,
+ model: call.model,
+ sourceAudit: {
+ promptVersion: AI_WHOLE_PRODUCT_PROMPT_VERSION,
+ calculationRule: AI_WHOLE_PRODUCT_ESTIMATE_RULE,
+ model: call.model,
+ callId: call.id,
+ reason: estimate.reason,
+ durationMs: call.durationMs,
+ promptTokens: call.promptTokens,
+ completionTokens: call.completionTokens,
+ companyReferenceCount: referenceRows.length,
+ knownCostLineCount: input.knownLines.length,
+ referenceRowIds: estimate.referenceRowIds,
+ },
+ }
+ } catch (error) {
+ console.warn('[BOM costing] whole-product AI estimate failed without blocking scenario persistence', {
+ model: config.model,
+ error: error instanceof Error ? error.message : String(error),
+ })
+ return emptyWholeProductResult({
+ model: config.model,
+ warning: '多模态整件预估本次未返回可用区间,可稍后重新核算',
+ })
+ }
+ }
}
export const bomCostingAiCostEstimator = new BomCostingAiCostEstimator()
diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts
index 39a23c7c..249aca5b 100644
--- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts
@@ -1254,6 +1254,96 @@ describe('BomCostingProcessingService', () => {
)
})
+ it('平台 SKU 规格差异与相同结构拆解冲突时仍进入整件预估阶段', async () => {
+ const variantSnapshot: MarketDetailSnapshot = {
+ ...snapshot,
+ skus: [
+ {
+ ...snapshot.skus[0]!,
+ externalSkuId: 'sku-walnut-80',
+ name: '胡桃木 80cm 规格',
+ size: '80cm',
+ attributes: { 材质: '胡桃木', 尺寸: '80cm' },
+ },
+ {
+ ...snapshot.skus[0]!,
+ externalSkuId: 'sku-oak-100',
+ name: '橡木 100cm 规格',
+ size: '100cm',
+ attributes: { 材质: '橡木', 尺寸: '100cm' },
+ },
+ ],
+ skuTotalRows: 2,
+ skuReturnedRows: 2,
+ skuTruncated: false,
+ }
+ mockBuildStoredSnapshot.mockResolvedValue(variantSnapshot)
+ jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
+ .mockResolvedValue(factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY) as never)
+ jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
+ .mockResolvedValue(factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY) as never)
+ mockRunAnalysis.mockResolvedValue({
+ result: {
+ ...analysisResult,
+ skus: variantSnapshot.skus.map((sku) => ({
+ ...analysisResult.skus[0]!,
+ externalSkuId: sku.externalSkuId,
+ name: sku.name,
+ attributes: sku.attributes,
+ })),
+ },
+ trace: {
+ model: 'grok-4.5',
+ callId: 'call-structure-review',
+ imageCount: 2,
+ inputSkuCount: 2,
+ durationMs: 12_000,
+ promptTokens: 50,
+ completionTokens: 80,
+ },
+ })
+ jest.spyOn(bomCostingScenarioService, 'calculateSameMaterialForCase')
+ .mockResolvedValueOnce({
+ totalSkuCount: 2,
+ succeededSkuCount: 2,
+ costedSkuCount: 2,
+ pendingSkuCount: 0,
+ failedSkuCount: 0,
+ succeededSkuIds: ['sku-db-walnut-80', 'sku-db-oak-100'],
+ costedSkuIds: ['sku-db-walnut-80', 'sku-db-oak-100'],
+ pendingSkuIds: [],
+ failures: [],
+ reviewSkuCount: 0,
+ reviewSkuIds: [],
+ consistencyWarnings: [],
+ })
+ const { BomCostingProcessingService } = await import(
+ './bom-costing-processing.service'
+ )
+
+ await new BomCostingProcessingService().processCase('case-1')
+
+ expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
+ expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
+ expect.objectContaining({
+ skus: expect.arrayContaining([
+ expect.objectContaining({ externalSkuId: 'sku-walnut-80' }),
+ expect.objectContaining({ externalSkuId: 'sku-oak-100' }),
+ ]),
+ eventPayload: expect.objectContaining({ qualityWarningCount: 1 }),
+ }),
+ )
+ expect(bomCostingScenarioService.calculateSameMaterialForCase)
+ .toHaveBeenCalledWith('case-1')
+ expect(bomCostingRepository.finalizeCaseDynamicCosting).toHaveBeenCalledWith(
+ expect.objectContaining({
+ caseId: 'case-1',
+ message: expect.stringContaining('整件预估已保留'),
+ payload: expect.objectContaining({ qualityWarningCount: 1 }),
+ }),
+ )
+ })
+
it('紧凑成本阶段仅有个别 SKU 待补成本时仍完成整单并保留可继续核算状态', async () => {
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(
diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts
index da78deec..cbac20e7 100644
--- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts
@@ -284,6 +284,7 @@ async function completeDynamicCostingPhase(input: {
caseId: string
analysisRunId: string
skus: Array<{ externalSkuId: string }>
+ qualityWarningCount?: number
}) {
let ownershipLost = false
let heartbeatInFlight: Promise | null = null
@@ -319,13 +320,15 @@ async function completeDynamicCostingPhase(input: {
}, DYNAMIC_COST_HEARTBEAT_INTERVAL_MS)
const skuCount = input.skus.length
- let dynamicCosting: Awaited>
+ >> & { qualityWarningCount: number }
+ let dynamicCosting: DynamicCostingCompletion
try {
- dynamicCosting = await bomCostingScenarioService.calculateSameMaterialForCase(
- input.caseId,
- )
+ dynamicCosting = {
+ ...await bomCostingScenarioService.calculateSameMaterialForCase(input.caseId),
+ qualityWarningCount: input.qualityWarningCount ?? 0,
+ }
} catch (error) {
const message = readErrorMessage(error)
dynamicCosting = {
@@ -345,6 +348,7 @@ async function completeDynamicCostingPhase(input: {
reviewSkuCount: 0,
reviewSkuIds: [],
consistencyWarnings: [],
+ qualityWarningCount: input.qualityWarningCount ?? 0,
}
} finally {
clearInterval(heartbeatTimer)
@@ -362,13 +366,16 @@ async function completeDynamicCostingPhase(input: {
? `所有 SKU 的成本生成均失败:${firstFailure}`
: '所有 SKU 的成本生成均失败,请按现有事实重新核算')
}
- const finalMessage = dynamicCosting.failedSkuCount > 0
+ const finalMessageBase = dynamicCosting.failedSkuCount > 0
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.failedSkuCount} 个成本生成失败,可按现有事实重新核算`
: dynamicCosting.pendingSkuCount > 0
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.pendingSkuCount} 个仍缺公司价格资料`
: dynamicCosting.reviewSkuCount > 0
? `成本预估已生成,共 ${dynamicCosting.totalSkuCount} 个 SKU;${dynamicCosting.reviewSkuCount} 个尺寸与成本关系待复核`
: `核算完成,共 ${dynamicCosting.totalSkuCount} 个 SKU,已生成同材质成本预估`
+ const finalMessage = dynamicCosting.qualityWarningCount > 0
+ ? `${finalMessageBase};结构拆解差异已标记待复核,整件预估已保留`
+ : finalMessageBase
await bomCostingRepository.finalizeCaseDynamicCosting({
caseId: input.caseId,
analysisRunId: input.analysisRunId,
@@ -1594,16 +1601,20 @@ export class BomCostingProcessingService {
[BomCostingLineCategory.LABOR]: [],
}
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
- if (canGenerateCost) {
- assertBomCostingResultQuality({
+ const qualityWarnings = canGenerateCost
+ ? assertBomCostingResultQuality({
analysis: persistedResult,
sourceSnapshot: marketSnapshot,
preparedSkus,
allowIncompleteCosting,
allowUnverifiedSkuStructures: isFactBasedStructure,
})
+ : []
+ const stats = {
+ ...buildResultStats(preparedSkus),
+ qualityWarningCount: qualityWarnings.length,
+ qualityWarnings,
}
- const stats = buildResultStats(preparedSkus)
const completedFailedFactImages = buildFailedFactImagePayload(
completedFactSnapshot,
analysisPlanByIdentity,
@@ -1695,6 +1706,7 @@ export class BomCostingProcessingService {
caseId,
analysisRunId,
skus: preparedSkus,
+ qualityWarningCount: qualityWarnings.length,
})
} catch (error) {
if (error instanceof BomCostingTaskOwnershipLostError) {
diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts
index 01e1116d..f608b2ef 100644
--- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.test.ts
@@ -194,7 +194,7 @@ function buildV3Analysis(skuIds: string[]): CompetitorAnalysisResultV3 {
}
describe('BOM 核算结果质量保护', () => {
- it('来源 SKU 的尺寸、材质或是否含镜不同却生成相同 BOM 时拒绝保存', () => {
+ it('来源 SKU 的尺寸、材质或是否含镜不同却生成相同 BOM 时保留待复核并继续预估', () => {
const sourceSnapshot = buildSourceSnapshot([
{
externalSkuId: 'solid-64-no-mirror',
@@ -219,11 +219,15 @@ describe('BOM 核算结果质量保护', () => {
])
const analysis = buildAnalysis(sourceSnapshot.skus)
- expect(() => assertBomCostingResultQuality({
+ const warnings = assertBomCostingResultQuality({
analysis,
sourceSnapshot,
preparedSkus: sourceSnapshot.skus.map((sku) => buildPreparedSku(sku.externalSkuId)),
- })).toThrow('SKU 结构可信度校验未通过')
+ })
+
+ expect(warnings).toEqual([
+ expect.stringContaining('已标记待复核;整件预估继续生成'),
+ ])
})
it('仅颜色不同的 SKU 可以复用相同 BOM', () => {
@@ -303,11 +307,11 @@ describe('BOM 核算结果质量保护', () => {
})).not.toThrow()
})
- it('v2 蓝图要求每个兄弟 SKU 都保留必要部件', () => {
+ it('v2 蓝图缺少必要部件时标记待复核,不阻断整件预估', () => {
const analysis = buildV2Analysis(['sku-small', 'sku-large'])
analysis.skus[1]!.bomLines = []
- expect(() => assertBomCostingResultQuality({
+ const warnings = assertBomCostingResultQuality({
analysis,
sourceSnapshot: null,
preparedSkus: [
@@ -315,7 +319,11 @@ describe('BOM 核算结果质量保护', () => {
buildPreparedSku('sku-large', null),
],
allowIncompleteCosting: true,
- })).toThrow('sku-large缺少board-top')
+ })
+
+ expect(warnings).toEqual([
+ expect.stringContaining('sku-large缺少board-top'),
+ ])
})
it('v2 同结构组尺寸变大但板材面积异常时只标记当前 SKU 待复核', () => {
@@ -343,11 +351,11 @@ describe('BOM 核算结果质量保护', () => {
expect(analysis.skus[1]?.bomLines[0]?.confidence).toBeLessThanOrEqual(0.45)
})
- it('v3 每个 SKU 的结构组和成本族必须反向属于蓝图', () => {
+ it('v3 SKU 的结构组或成本族引用异常时保留待复核,不阻断整件预估', () => {
const analysis = buildV3Analysis(['sku-small', 'sku-large'])
analysis.skus[1]!.costFamilyKey = 'wardrobe:cost:不存在'
- expect(() => assertBomCostingResultQuality({
+ const warnings = assertBomCostingResultQuality({
analysis,
sourceSnapshot: null,
preparedSkus: [
@@ -355,7 +363,11 @@ describe('BOM 核算结果质量保护', () => {
buildPreparedSku('sku-large', null),
],
allowIncompleteCosting: true,
- })).toThrow('sku-large的成本族引用无效')
+ })
+
+ expect(warnings).toEqual([
+ expect.stringContaining('sku-large的成本族引用无效'),
+ ])
})
it('能识别米制尺寸和衣柜结构款式差异,避免相同 BOM 混组', () => {
@@ -382,13 +394,17 @@ describe('BOM 核算结果质量保护', () => {
},
])
- expect(() => assertBomCostingResultQuality({
+ const warnings = assertBomCostingResultQuality({
analysis: buildAnalysis(sourceSnapshot.skus),
sourceSnapshot,
preparedSkus: sourceSnapshot.skus.map((sku) => (
buildPreparedSku(sku.externalSkuId)
)),
- })).toThrow('SKU 结构可信度校验未通过')
+ })
+
+ expect(warnings).toEqual([
+ expect.stringContaining('已标记待复核;整件预估继续生成'),
+ ])
})
it('同成本族板材面积增加超过5%却无依据降本超过10%时标记待复核而不拒绝整批', () => {
@@ -494,7 +510,7 @@ describe('BOM 核算结果质量保护', () => {
expect(boardOnly.attributes).not.toHaveProperty('costingConsistencyStatus')
})
- it('显式无抽屉结构出现蓝图未启用的抽屉行时拒绝保存', () => {
+ it('显式无抽屉结构出现未启用的抽屉行时标记待复核,不阻断整件预估', () => {
const analysis = buildV3Analysis(['sku-no-drawer'])
const group = analysis.structureBlueprint.structureGroups[0]!
group.structureGroupKey = 'wardrobe:structure:door-4.hanging.no-drawer'
@@ -510,11 +526,13 @@ describe('BOM 核算结果质量保护', () => {
confidence: 0.8,
})
- expect(() => assertBomCostingResultQuality({
+ const warnings = assertBomCostingResultQuality({
analysis,
sourceSnapshot: null,
preparedSkus: [buildPreparedSku('sku-no-drawer', null)],
allowIncompleteCosting: true,
- })).toThrow(/蓝图未启用的drawer-box|互斥结构禁止的drawer-box/)
+ })
+
+ expect(warnings.join(';')).toMatch(/蓝图未启用的drawer-box|互斥结构禁止的drawer-box/)
})
})
diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts
index 9730d9f2..97629824 100644
--- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-quality.ts
@@ -179,7 +179,7 @@ function buildBomFingerprint(sku: CompetitorSkuV1) {
.join('\u001f')
}
-function assertDistinctSourceSkuStructures(
+function collectDistinctSourceSkuStructureWarnings(
analysis: CompetitorAnalysisResult,
source: MarketDetailSnapshot,
) {
@@ -220,14 +220,18 @@ function assertDistinctSourceSkuStructures(
}
}
- if (violations.length === 0) return
+ if (violations.length === 0) return []
const examples = violations
.slice(0, 3)
.map((item) => `${item.left} 与 ${item.right}(${item.differences.join('、')})`)
.join(';')
- throw new Error(
- `SKU 结构可信度校验未通过:${violations.length} 组平台规格存在结构差异,却生成了相同 BOM(${examples})。请重新核算或补充复核图片`,
- )
+ // Source-side specification differences tell us that this BOM needs review;
+ // they do not mean the product, its images, or the available cost knowledge
+ // disappeared. Keep the signal for audit, but let the whole-product estimate
+ // continue instead of discarding every SKU in the run.
+ return [
+ `检测到 ${violations.length} 组 SKU 规格差异与当前结构拆解不完全一致,已标记待复核;整件预估继续生成(${examples})`,
+ ]
}
function assertPreparedCostRange(skus: BomCostingPreparedSku[]) {
@@ -450,12 +454,12 @@ function horizontalSizeMm(sku: CompetitorSkuV1) {
return values.length > 0 ? Math.max(...values) : null
}
-function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult) {
- if (analysis.schemaVersion === 'competitor-analysis/v1') return
+function collectStructureBlueprintWarnings(analysis: CompetitorAnalysisResult) {
+ if (analysis.schemaVersion === 'competitor-analysis/v1') return []
if (
analysis.schemaVersion === 'competitor-analysis/v3' &&
analysis.structureBlueprint.supportStatus !== 'SUPPORTED'
- ) return
+ ) return []
const analyzedById = new Map(
analysis.skus.map((sku) => [sku.externalSkuId, sku] as const),
)
@@ -466,7 +470,7 @@ function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult)
blueprintSkuIds.length !== analysis.skus.length ||
analysis.skus.some((sku) => !blueprintSkuIds.includes(sku.externalSkuId))
) {
- throw new Error('产品结构蓝图与真实 SKU 数量不一致,系统未接受该结果')
+ return ['产品结构蓝图与真实 SKU 的对应关系不完整,已保留可识别 SKU 的整件预估并标记待复核']
}
const violations: string[] = []
@@ -574,11 +578,9 @@ function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult)
}
}
}
- if (violations.length > 0) {
- throw new Error(
- `结构蓝图一致性校验未通过:${violations.slice(0, 5).join(';')}`,
- )
- }
+ return violations.length > 0
+ ? [`结构拆解存在 ${violations.length} 项待复核:${violations.slice(0, 5).join(';')}`]
+ : []
}
export function assertBomCostingResultQuality(input: {
@@ -600,7 +602,7 @@ export function assertBomCostingResultQuality(input: {
*/
allowUnverifiedSkuStructures?: boolean
}) {
- assertStructureBlueprintConsistency(input.analysis)
+ const warnings = collectStructureBlueprintWarnings(input.analysis)
if (!input.allowIncompleteCosting) {
assertPreparedCostRange(input.preparedSkus)
}
@@ -608,6 +610,10 @@ export function assertBomCostingResultQuality(input: {
const allowUnverifiedSkuStructures =
input.allowUnverifiedSkuStructures ?? input.allowIncompleteCosting ?? false
if (input.sourceSnapshot && !allowUnverifiedSkuStructures) {
- assertDistinctSourceSkuStructures(input.analysis, input.sourceSnapshot)
+ warnings.push(...collectDistinctSourceSkuStructureWarnings(
+ input.analysis,
+ input.sourceSnapshot,
+ ))
}
+ return Array.from(new Set(warnings))
}
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..1c772da9 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,
@@ -166,6 +183,17 @@ describe('BomCostingScenarioService', () => {
callId: null,
model: null,
})
+ jest.spyOn(bomCostingAiCostEstimator, 'estimateWholeProductCost').mockResolvedValue({
+ totalCost: null,
+ estimateRange: null,
+ confidence: null,
+ reason: null,
+ referenceRowIds: [],
+ warning: null,
+ callId: null,
+ model: null,
+ sourceAudit: null,
+ })
jest.spyOn(bomCostingScenarioRepository, 'invalidateCurrentScenarios')
.mockResolvedValue({ count: 0 })
jest.spyOn(bomCostingScenarioRepository, 'listCaseDynamicConsistencyRows')
@@ -178,6 +206,61 @@ describe('BomCostingScenarioService', () => {
jest.restoreAllMocks()
})
+ it('uses a live market reference for the whole-SKU estimate while retaining structural detail 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: 'AI_REFERENCE_ESTIMATE',
+ 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({
@@ -386,6 +469,34 @@ describe('BomCostingScenarioService', () => {
expect(saved.sameMaterial.lines[0]?.warning).toContain('不是公司正式采购价格')
})
+ it('实时参考与模型补齐都没有金额时保留资料不足,而不伪造零元成本', async () => {
+ mockBase({ bomLines: [line({
+ id: 'unpriced-accessory',
+ category: 'ACCESSORY',
+ partName: '未知配件',
+ unit: '件',
+ quantity: 1,
+ })] })
+ jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
+
+ await new BomCostingScenarioService().calculateSameMaterial({
+ caseId: 'case-1',
+ skuId: 'sku-1',
+ })
+
+ const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]?.[0]
+ expect(saved?.sameMaterial).toMatchObject({
+ total: null,
+ estimateRange: null,
+ status: 'INSUFFICIENT_DATA',
+ unresolvedCount: 1,
+ })
+ expect(saved?.sameMaterial.lines[0]).toMatchObject({
+ subtotal: null,
+ status: 'INSUFFICIENT_DATA',
+ })
+ })
+
it('替换材质没有直接公司价格时也形成低可信替换预估,不阻断整套方案', async () => {
const bomLines = [line({
id: 'drawer-box',
@@ -2887,18 +2998,132 @@ describe('BomCostingScenarioService', () => {
})
})
- it('only reports structure insufficiency when the SKU has no BOM lines at all', async () => {
+ it('uses a whole-product model estimate when the SKU has no BOM lines yet', async () => {
mockBase({ bomLines: [] })
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
+ jest.mocked(bomCostingAiCostEstimator.estimateWholeProductCost).mockResolvedValue({
+ totalCost: 188,
+ estimateRange: { min: 145, max: 232 },
+ confidence: 0.42,
+ reason: '结合鞋柜名称、外形尺寸和相近柜体制造成本关系形成预估',
+ referenceRowIds: ['history-row-1'],
+ warning: null,
+ callId: 'whole-call-1',
+ model: 'multimodal-cost-model',
+ sourceAudit: {
+ calculationRule: 'multimodal-whole-product-estimate/v1',
+ callId: 'whole-call-1',
+ },
+ })
const service = new BomCostingScenarioService()
await service.calculateSameMaterial({ caseId: 'case-1', skuId: 'sku-1' })
+ const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
+ expect(saved.sameMaterial).toMatchObject({
+ total: 188,
+ estimateRange: { min: 145, max: 232 },
+ status: 'PROVISIONAL',
+ estimateBasis: 'AI_REFERENCE_ESTIMATE',
+ unresolvedCount: 0,
+ wholeProductEstimateAudit: expect.objectContaining({
+ calculationRule: 'multimodal-whole-product-estimate/v1',
+ }),
+ })
+ expect(saved.sameMaterial.lines).toEqual([])
+ })
+
+ it('uses an available whole-SKU reference even before structure lines exist', async () => {
+ mockBase({ bomLines: [] })
+ 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-17T04:00:00.000Z',
+ procurementRate: 0.9,
+ marketPrice: 200,
+ procurementCost: 180,
+ estimateRange: { min: 162, max: 198 },
+ confidence: 0.7,
+ comparableOfferCount: 5,
+ returnedOfferCount: 6,
+ 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).toMatchObject({
+ total: 180,
+ estimateRange: { min: 162, max: 198 },
+ status: 'PROVISIONAL',
+ unresolvedCount: 0,
+ wholeProductEstimateAudit: null,
+ })
+ expect(bomCostingAiCostEstimator.estimateWholeProductCost).not.toHaveBeenCalled()
+ })
+
+ it('keeps unresolved component detail as pending while still saving a whole-product estimate', async () => {
+ mockBase({ bomLines: [line({
+ id: 'drawer-box',
+ category: 'BOARD',
+ partName: '抽屉盒板件',
+ materialName: '橡胶木',
+ unit: '件',
+ quantity: 1,
+ })] })
+ jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
+ jest.mocked(bomCostingAiCostEstimator.estimateWholeProductCost).mockResolvedValue({
+ totalCost: 180,
+ estimateRange: { min: 135, max: 228 },
+ confidence: 0.4,
+ reason: '结合抽屉柜结构、产品尺寸和公司同类柜体知识形成整件预估',
+ referenceRowIds: [],
+ warning: null,
+ callId: 'whole-call-2',
+ model: 'multimodal-cost-model',
+ sourceAudit: { callId: 'whole-call-2' },
+ })
+
+ await new BomCostingScenarioService().calculateSameMaterial({
+ caseId: 'case-1',
+ skuId: 'sku-1',
+ })
+
+ const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
+ expect(saved.sameMaterial).toMatchObject({
+ total: 180,
+ estimateRange: { min: 135, max: 228 },
+ status: 'PROVISIONAL',
+ unresolvedCount: 1,
+ estimateBasis: 'AI_REFERENCE_ESTIMATE',
+ })
+ expect(saved.sameMaterial.warning).toContain('未覆盖明细已标记为待补')
+ expect(saved.sameMaterial.lines[0]).toMatchObject({
+ subtotal: null,
+ status: 'INSUFFICIENT_DATA',
+ })
+ })
+
+ it('only reports insufficient information when neither a whole-SKU reference nor agent estimate exists', async () => {
+ mockBase({ bomLines: [] })
+ jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
+
+ await new BomCostingScenarioService().calculateSameMaterial({
+ caseId: 'case-1',
+ skuId: 'sku-1',
+ })
+
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
expect(saved.sameMaterial).toMatchObject({
total: null,
status: 'INSUFFICIENT_DATA',
- warning: expect.stringContaining('没有识别出可用于成本预估的产品结构'),
+ warning: expect.stringContaining('资料不足,暂无法预估'),
})
})
@@ -3030,6 +3255,29 @@ describe('BomCostingScenarioService', () => {
})
})
+ it('keeps every component category pending when only a whole-SKU estimate exists', () => {
+ const result = {
+ lines: [],
+ } as DynamicBomScenarioResult
+
+ expect(buildDynamicCostBreakdown(result)).toEqual({
+ categoryTotals: {
+ MATERIAL: null,
+ PROCESSING: null,
+ LABOR: null,
+ PACKAGING: null,
+ },
+ categoryEstimateRanges: {
+ MATERIAL: null,
+ PROCESSING: null,
+ LABOR: null,
+ PACKAGING: null,
+ },
+ lossCost: null,
+ lossEstimateRange: null,
+ })
+ })
+
it('keeps a line unresolved when the company price unit cannot be converted', async () => {
const bomLines = [line({
id: 'board',
@@ -3070,7 +3318,7 @@ describe('BomCostingScenarioService', () => {
})
})
- it('serializes loss and price provenance fields for the workbench', async () => {
+ it('serializes a unified public estimate without price-source provenance', async () => {
jest.spyOn(bomCostingScenarioRepository, 'listCurrentScenarios').mockResolvedValue([{
id: 'scenario-1',
skuId: 'sku-1',
@@ -3095,6 +3343,15 @@ describe('BomCostingScenarioService', () => {
dataAsOf: now,
syncRunId: 'run-success',
rulesetVersion: 'dynamic-bom-cost/v1',
+ estimateBasis: 'REALTIME_1688_MARKET',
+ realtimeMarketQuote: {
+ source: '1688_IMAGE_SEARCH',
+ offers: [{ supplier: 'private', detailUrl: 'https://private.example.com' }],
+ },
+ wholeProductEstimateAudit: {
+ model: 'private-model',
+ referenceRowIds: ['private-row'],
+ },
createdAt: now,
syncRun: {
status: 'SUCCEEDED',
@@ -3146,28 +3403,59 @@ describe('BomCostingScenarioService', () => {
expect(scenario).toMatchObject({
totalCost: 152,
lossCost: 5,
- competitorPrice: 100,
- priceDelta: 52,
- syncRunId: 'run-success',
- syncStatus: 'SUCCEEDED',
- boardCutting: [{
- sourceLineId: 'board',
- materialName: '黑胡桃木',
- rulesetVersion: 'board-sheet-maxrects/v2-batch',
- selectedBatchSize: 16,
- batchSheetCount: 2,
- perUnitSheetCost: 25,
- utilization: 0.9675,
- singleUnitSheetCount: 1,
- singleUnitSheetCost: 200,
- }],
+ estimateRange: null,
})
expect(scenario?.lines[0]).toMatchObject({
- sourceRowId: 'reference-row-1',
- sourceCreatedAt: now.toISOString(),
- sourceSyncedAt: now.toISOString(),
- calculationRule: 'board-sheet-maxrects/v2-batch',
+ partName: '桌面板',
+ subtotal: 55,
})
+ for (const key of [
+ 'estimateBasis',
+ 'sampleCount',
+ 'estimatedLineCount',
+ 'knownCost',
+ 'competitorPrice',
+ 'priceDelta',
+ 'marketPriceAlert',
+ 'realtimeMarketQuote',
+ 'wholeProductEstimateAudit',
+ 'syncRunId',
+ 'syncStatus',
+ 'syncCompletedAt',
+ 'dataAsOf',
+ 'rulesetVersion',
+ 'boardCutting',
+ ]) {
+ expect(scenario).not.toHaveProperty(key)
+ }
+ for (const key of [
+ 'sourceLineId',
+ 'sourceRowId',
+ 'coefficient',
+ 'pricingQuantity',
+ 'unitPrice',
+ 'priceSource',
+ 'sourceSku',
+ 'referenceSkus',
+ 'referenceRowIds',
+ 'sourceFingerprint',
+ 'sourceCreatedAt',
+ 'sourceSyncedAt',
+ 'calculationRule',
+ 'rawSnapshot',
+ ]) {
+ expect(scenario?.lines[0]).not.toHaveProperty(key)
+ }
+ expect(JSON.stringify(scenario)).not.toMatch(/1688|private\.example\.com|supplier|公司历史|参考 SKU|竞品售价/iu)
+ })
+
+ it('hides historical material-substitution scenarios from the regular BOM contract', async () => {
+ jest.spyOn(bomCostingScenarioRepository, 'listCurrentScenarios').mockResolvedValue([{
+ kind: 'MATERIAL_SUBSTITUTION',
+ }] as never)
+
+ await expect(new BomCostingScenarioService().listScenarios({ caseId: 'case-1' }))
+ .resolves.toEqual([])
})
it('reports mirror completion as data freshness while exposing the upstream timestamp separately', async () => {
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..4ec3b7db 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 {
@@ -27,7 +28,11 @@ import {
type BomCostingAiCompanyReference,
type BomCostingAiEstimateRequestLine,
type BomCostingAiKnownLine,
+ type BomCostingAiWholeProductEstimateResult,
} from './bom-costing-ai-cost-estimator'
+import {
+ bomCosting1688ImageSearchService,
+} from './bom-costing-1688-image-search'
const CATEGORY_LABELS: Record = {
BOARD: '板材',
@@ -2247,6 +2252,117 @@ function toAiKnownLine(
}
}
+async function loadWholeProductAiReferences(input: {
+ syncRunId: string | null
+ skuName: string
+ lines: BomCostingLine[]
+}) {
+ if (!input.syncRunId) return []
+ const referenceLines = input.lines.length > 0
+ ? input.lines
+ : [{
+ partName: input.skuName,
+ materialName: '',
+ materialSpec: '',
+ processName: '',
+ unit: '',
+ }]
+ const categories = Array.from(new Set(input.lines.map((line) => line.category)))
+ const referenceCategories = categories.length > 0
+ ? categories
+ : ['BOARD', 'STEEL', 'ACCESSORY', 'PROCESSING', 'LABOR', 'PACKAGING']
+ const groups = await Promise.all(referenceCategories.map(async (category) => {
+ const candidates = await bomCostingScenarioRepository.findCategoryCohortCandidates({
+ syncRunId: input.syncRunId!,
+ category,
+ limit: 40,
+ })
+ return selectAiCompanyReferences({
+ candidates,
+ lines: referenceLines,
+ limit: 10,
+ })
+ }))
+ return Array.from(new Map(groups.flat().map((reference) => [
+ reference.sourceRowId,
+ reference,
+ ])).values()).slice(0, 60)
+}
+
+function applyWholeProductAiEstimate(
+ result: DynamicBomScenarioResult,
+ estimate: BomCostingAiWholeProductEstimateResult,
+) {
+ if (
+ result.scenario !== 'SAME_MATERIAL' ||
+ estimate.totalCost === null ||
+ estimate.estimateRange === null ||
+ estimate.confidence === null
+ ) return null
+
+ result.total = estimate.totalCost
+ result.estimateRange = estimate.estimateRange
+ result.estimateBasis = 'AI_REFERENCE_ESTIMATE'
+ result.sampleCount = Math.max(result.sampleCount, estimate.referenceRowIds.length)
+ result.estimatedLineCount = Math.max(result.estimatedLineCount, 1)
+ result.confidence = Math.min(result.confidence ?? estimate.confidence, estimate.confidence)
+ if (result.status !== 'STALE') result.status = 'PROVISIONAL'
+ result.warnings.push(
+ '已依据产品资料、结构事实与知识参考形成整件预估;未覆盖明细已标记为待补',
+ )
+ result.warning = Array.from(new Set(result.warnings)).join(';')
+ return estimate.sourceAudit
+}
+
+/**
+ * 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
+ // The private quote remains attached for recalculation and diagnostics. The
+ // regular BOM contract exposes this as a source-neutral comprehensive
+ // estimate, consistent with all other non-final estimate inputs.
+ result.estimateBasis = 'AI_REFERENCE_ESTIMATE'
+ 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('已形成整件预估,结构拆解用于解释构成与偏差复核')
+ if (structuralCost !== null) {
+ result.warnings.push(
+ '结构拆解预估已保留,仅用于解释构成与偏差复核',
+ )
+ } else {
+ result.warnings.push(
+ '结构拆解仍有待补明细;当前整件预估已形成,明细可继续补充事实或由模型预估',
+ )
+ }
+ result.warning = Array.from(new Set(result.warnings)).join(';')
+}
+
function withRequiredEstimateCategories(
lines: BomCostingLine[],
skuId: string,
@@ -2366,6 +2482,24 @@ function buildScenarioRawSnapshot(
export function buildDynamicCostBreakdown(result: DynamicBomScenarioResult) {
const zero = new Prisma.Decimal(0)
+ if (result.lines.length === 0) {
+ return {
+ categoryTotals: {
+ MATERIAL: null,
+ PROCESSING: null,
+ LABOR: null,
+ PACKAGING: null,
+ },
+ categoryEstimateRanges: {
+ MATERIAL: null,
+ PROCESSING: null,
+ LABOR: null,
+ PACKAGING: null,
+ },
+ lossCost: null,
+ lossEstimateRange: null,
+ }
+ }
const netByCategory: Record = {
BOARD: zero,
STEEL: zero,
@@ -2464,83 +2598,24 @@ function readMaterialAliases(value: unknown) {
)))
}
-function readBoardCuttingSummary(
- lines: BomCostingScenarioRecord['lines'],
-) {
- const summaries = new Map()
- for (const line of lines) {
- if (line.category !== 'BOARD' || line.calculationRule !== 'board-sheet-maxrects/v2-batch') {
- continue
- }
- if (typeof line.rawSnapshot !== 'object' || line.rawSnapshot === null || Array.isArray(line.rawSnapshot)) {
- continue
- }
- const calculation = (line.rawSnapshot as Record).calculation
- if (typeof calculation !== 'object' || calculation === null || Array.isArray(calculation)) continue
- const audit = (calculation as Record).priceSourceAudit
- if (typeof audit !== 'object' || audit === null || Array.isArray(audit)) continue
- const source = audit as Record
- const singleUnit = typeof source.singleUnit === 'object' && source.singleUnit !== null && !Array.isArray(source.singleUnit)
- ? source.singleUnit as Record
- : null
- const selectedBatchSize = Number(source.selectedBatchSize)
- const batchSheetCount = Number(source.sheetCount)
- const perUnitSheetCost = Number(source.perUnitSheetCost)
- const utilization = Number(source.utilization)
- const singleUnitSheetCount = Number(singleUnit?.sheetCount)
- const singleUnitSheetCost = Number(singleUnit?.perUnitSheetCost)
- if (![
- selectedBatchSize,
- batchSheetCount,
- perUnitSheetCost,
- utilization,
- singleUnitSheetCount,
- singleUnitSheetCost,
- ].every(Number.isFinite)) continue
- const key = `${line.sourceFingerprint}:${line.materialSpec}:${selectedBatchSize}`
- summaries.set(key, {
- sourceLineId: line.sourceLineId,
- materialName: line.appliedMaterial,
- rulesetVersion: line.calculationRule,
- selectedBatchSize,
- batchSheetCount,
- perUnitSheetCost,
- utilization,
- singleUnitSheetCount,
- singleUnitSheetCost,
- })
- }
- return Array.from(summaries.values())
+function publicScenarioWarning(value: string) {
+ const sourceSpecificPattern = /(?:1688|实时同款|同款价|采购系数|市场价|竞品售价|公司(?:历史)?\s*BOM|参考\s*SKU|公司价格来源|基础资料库|正式采购|历史用量|镜像(?:同步|批次)?|供应商|店铺|商品链接)/iu
+ const genericWarning = '当前预估仍有待补信息,建议补充结构或尺寸后重新核算'
+ const warnings = value
+ .split(/;\s*/u)
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .map((part) => (sourceSpecificPattern.test(part) ? genericWarning : part))
+
+ return Array.from(new Set(warnings)).join(';')
}
function serializeScenario(record: BomCostingScenarioRecord) {
const totalCost = record.totalCost === null ? null : Number(record.totalCost)
- const storedCompetitorPrice = record.competitorPrice === null
- ? null
- : Number(record.competitorPrice)
- const competitorPrice =
- record.rulesetVersion === 'dynamic-bom-cost/v3' &&
- storedCompetitorPrice !== null &&
- Number.isInteger(storedCompetitorPrice) &&
- storedCompetitorPrice >= 1_000
- ? storedCompetitorPrice / 100
- : storedCompetitorPrice
return {
id: record.id,
skuId: record.skuId,
kind: record.kind,
- targetMaterialId: record.targetMaterialId,
- targetMaterialName: record.targetMaterialName,
revision: record.revision,
isCurrent: record.isCurrent,
status: record.status,
@@ -2590,40 +2665,17 @@ function serializeScenario(record: BomCostingScenarioRecord) {
: null
return result
})(),
- estimateBasis: record.estimateBasis || null,
- sampleCount: record.sampleCount ?? 0,
- estimatedLineCount: record.estimatedLineCount ?? 0,
- knownCost: record.knownCost === null ? null : Number(record.knownCost),
materialCost: record.materialCost === null ? null : Number(record.materialCost),
processingCost: record.processingCost === null ? null : Number(record.processingCost),
laborCost: record.laborCost === null ? null : Number(record.laborCost),
packagingCost: record.packagingCost === null ? null : Number(record.packagingCost),
lossCost: record.lossCost === null ? null : Number(record.lossCost),
- competitorPrice,
- priceDelta: totalCost === null || competitorPrice === null
- ? null
- : Number((totalCost - competitorPrice).toFixed(2)),
- marketPriceAlert: totalCost === null || competitorPrice === null
- ? null
- : totalCost > competitorPrice + 0.01
- ? 'HIGH' as const
- : totalCost < competitorPrice * 0.1
- ? 'LOW' as const
- : null,
confidence: record.confidence === null ? null : Number(record.confidence),
- warning: record.warning,
+ warning: publicScenarioWarning(record.warning),
unresolvedCount: record.unresolvedCount,
- dataAsOf: record.dataAsOf?.toISOString() ?? null,
- syncRunId: record.syncRunId,
- syncStatus: record.syncRun?.status ?? null,
- syncCompletedAt: record.syncRun?.completedAt?.toISOString() ?? null,
- rulesetVersion: record.rulesetVersion,
createdAt: record.createdAt.toISOString(),
- boardCutting: readBoardCuttingSummary(record.lines),
lines: record.lines.map((line) => ({
id: line.id,
- sourceLineId: line.sourceLineId,
- sourceRowId: line.sourceRowId,
category: line.category,
partName: line.partName,
originalMaterial: line.originalMaterial,
@@ -2631,27 +2683,13 @@ function serializeScenario(record: BomCostingScenarioRecord) {
materialSpec: line.materialSpec,
unit: line.unit,
quantity: line.quantity === null ? null : Number(line.quantity),
- coefficient: line.coefficient === null ? null : Number(line.coefficient),
- pricingQuantity: line.pricingQuantity === null ? null : Number(line.pricingQuantity),
- unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
subtotal: line.subtotal === null ? null : Number(line.subtotal),
estimateRange: line.estimateLow == null || line.estimateHigh == null
? null
: { min: Number(line.estimateLow), max: Number(line.estimateHigh) },
- estimateBasis: line.estimateBasis || null,
- sampleCount: line.sampleCount ?? 0,
- priceSource: line.priceSource,
- sourceSku: line.sourceSku,
- referenceSkus: Array.isArray(line.referenceSkus) ? line.referenceSkus : [],
- referenceRowIds: Array.isArray(line.referenceRowIds) ? line.referenceRowIds : [],
- sourceFingerprint: line.sourceFingerprint,
- sourceCreatedAt: line.sourceCreatedAt?.toISOString() ?? null,
- sourceSyncedAt: line.sourceSyncedAt?.toISOString() ?? null,
- calculationRule: line.calculationRule,
confidence: line.confidence === null ? null : Number(line.confidence),
status: line.status,
- warning: line.warning,
- rawSnapshot: line.rawSnapshot,
+ warning: publicScenarioWarning(line.warning),
})),
}
}
@@ -2800,7 +2838,9 @@ export class BomCostingScenarioService {
async listScenarios(input: { caseId: string; skuId?: string }) {
const records = await bomCostingScenarioRepository.listCurrentScenarios(input)
- return records.map(serializeScenario)
+ return records
+ .filter((record) => record.kind === 'SAME_MATERIAL')
+ .map(serializeScenario)
}
async calculate(input: {
@@ -2846,6 +2886,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 +3193,7 @@ export class BomCostingScenarioService {
})
}
const hasProductStructure = sku.bomLines.length > 0
+ const realtimeMarketQuote = await realtimeMarketQuotePromise
let comparison = calculateDynamicBomScenarios({
rows,
sameMaterialPrices,
@@ -3238,6 +3285,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)
@@ -3278,17 +3326,6 @@ export class BomCostingScenarioService {
// zero/negative price for the missing rows.
applyCategoryProfileConflicts(comparison.sameMaterial, categoryFallback.conflicts)
applyCategoryProfileFallbackWarnings(comparison.sameMaterial, categoryFallback.warnings)
- if (!hasProductStructure) {
- for (const result of [comparison.sameMaterial, comparison.materialSubstitution]) {
- result.status = 'INSUFFICIENT_DATA'
- result.total = null
- result.estimateRange = null
- result.marketPriceAlert = false
- result.marketPriceAlertKind = null
- result.warnings.push('没有识别出可用于成本预估的产品结构')
- result.warning = Array.from(new Set(result.warnings)).join(';')
- }
- }
const scenarioResults = input.sameMaterialOnly
? [comparison.sameMaterial]
: [comparison.sameMaterial, comparison.materialSubstitution]
@@ -3374,6 +3411,49 @@ export class BomCostingScenarioService {
result.warnings.push(failedWarning)
}
}
+ // A finished-product reference can form a useful whole-SKU estimate even
+ // before the structure extractor has identified every BOM line. It is not
+ // used as a component price, and never changes an internal line subtotal.
+ applyRealtime1688ProcurementAnchor(comparison.sameMaterial, realtimeMarketQuote)
+ comparison.materialSubstitution.realtimeMarketQuote = realtimeMarketQuote
+
+ let wholeProductEstimateAudit: unknown = null
+ if (comparison.sameMaterial.total === null) {
+ const companyReferences = await loadWholeProductAiReferences({
+ syncRunId: syncRun?.id ?? null,
+ skuName: sku.name,
+ lines: scenarioLines,
+ })
+ const wholeProductEstimate = await bomCostingAiCostEstimator.estimateWholeProductCost({
+ product: {
+ externalSkuId: sku.externalSkuId,
+ name: sku.name,
+ structureGroupKey: sku.structureGroupKey,
+ costFamilyKey: sku.costFamilyKey,
+ lengthMm: decimalNumber(sku.lengthMm),
+ widthMm: decimalNumber(sku.widthMm),
+ heightMm: decimalNumber(sku.heightMm),
+ attributes: sku.attributes,
+ },
+ knownLines: comparison.sameMaterial.lines.flatMap((line) => {
+ const known = toAiKnownLine(line, 'SAME_MATERIAL')
+ return known ? [known] : []
+ }),
+ companyReferences,
+ })
+ wholeProductEstimateAudit = applyWholeProductAiEstimate(
+ comparison.sameMaterial,
+ wholeProductEstimate,
+ )
+ if (comparison.sameMaterial.total === null) {
+ comparison.sameMaterial.status = 'INSUFFICIENT_DATA'
+ comparison.sameMaterial.estimateRange = null
+ comparison.sameMaterial.warnings.push('资料不足,暂无法预估')
+ comparison.sameMaterial.warning = Array.from(new Set(
+ comparison.sameMaterial.warnings,
+ )).join(';')
+ }
+ }
const warning = (result: DynamicBomScenarioResult) => [
...result.warnings,
result.marketPriceAlertKind === 'HIGH' ? '预估成本高于竞品售价,需复核主要驱动项' : '',
@@ -3431,6 +3511,8 @@ export class BomCostingScenarioService {
categoryTotals: sameBreakdown.categoryTotals,
unresolvedCount: comparison.sameMaterial.unresolvedLineCount,
warning: warning(comparison.sameMaterial),
+ realtimeMarketQuote: comparison.sameMaterial.realtimeMarketQuote ?? null,
+ wholeProductEstimateAudit,
lines: comparison.sameMaterial.lines.map((line) => (
toSavedLine(line, line.materialName)
)),
@@ -3482,6 +3564,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,
@@ -3741,21 +3824,9 @@ export class BomCostingScenarioService {
}
async getCaseDynamicCosting(caseId: string) {
- const [materials, scenarios, sync] = await Promise.all([
- this.listMaterials(),
- this.listScenarios({ caseId }),
- this.getSyncFreshness(),
- ])
+ const scenarios = await this.listScenarios({ caseId })
return {
- materials: materials.map((material) => ({
- id: material.id,
- code: material.code,
- name: material.name,
- category: material.category,
- aliases: Array.isArray(material.aliases) ? material.aliases : [],
- })),
scenarios,
- sync,
}
}
diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.test.ts
index ec52fe79..d361f22d 100644
--- a/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.test.ts
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.test.ts
@@ -86,6 +86,135 @@ describe('BomCostingService', () => {
).rejects.toThrow('当前账号不能访问 BOM 核算')
})
+ it('普通详情只返回结构与中性预估状态,不泄露人工或资料库价格留档', async () => {
+ const now = new Date('2026-08-17T12:00:00.000Z')
+ jest.spyOn(bomCostingRepository, 'findCaseById').mockResolvedValue({
+ id: 'case-public-v1',
+ sourceUrl: 'https://example.com/item/public-v1',
+ sourceHost: 'example.com',
+ title: '测试商品',
+ providerCode: 'fixture',
+ providerTaskId: 'task-public-v1',
+ sourceCacheHit: true,
+ status: 'READY',
+ progress: 100,
+ statusMessage: '已完成',
+ analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,
+ targetErrorPercent: new Prisma.Decimal(10),
+ currency: 'CNY',
+ createdById: operationsUser.id,
+ processingStartedAt: now,
+ processingFinishedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ sourceSnapshot: {
+ id: 'snapshot-public-v1',
+ status: 'READY',
+ channel: 'private-market',
+ externalItemId: 'private-item-1',
+ title: '测试商品快照',
+ shopName: '私有店铺',
+ skuCount: 1,
+ imageCount: 1,
+ storedImageCount: 1,
+ failedImageCount: 0,
+ errorMessage: '',
+ fetchedAt: now,
+ completedAt: now,
+ assets: [],
+ },
+ analysisRuns: [],
+ taskEvents: [],
+ reviewInputs: [{
+ id: 'review-private-price',
+ source: 'MANUAL',
+ unitPrice: new Prisma.Decimal(88),
+ creator: { id: operationsUser.id, name: operationsUser.name },
+ }],
+ skus: [{
+ id: 'sku-public-v1',
+ externalSkuId: 'SKU-PUBLIC-V1',
+ name: '测试规格',
+ structureGroupKey: 'table',
+ costFamilyKey: 'table:oak',
+ imageUrl: null,
+ attributes: {},
+ lengthMm: new Prisma.Decimal(1200),
+ widthMm: new Prisma.Decimal(600),
+ heightMm: new Prisma.Decimal(750),
+ weightKg: null,
+ packageLengthMm: null,
+ packageWidthMm: null,
+ packageHeightMm: null,
+ packageWeightKg: null,
+ estimatedCost: null,
+ costCalculatedAt: null,
+ scenarios: [],
+ bomLines: [{
+ id: 'line-public-v1',
+ externalLineId: 'tabletop',
+ category: 'BOARD',
+ partName: '桌面板',
+ materialCode: 'OAK',
+ materialName: '橡木',
+ materialSpec: '20 mm',
+ processName: '',
+ packagingKind: '',
+ lengthMm: new Prisma.Decimal(1200),
+ widthMm: new Prisma.Decimal(600),
+ heightMm: new Prisma.Decimal(20),
+ quantity: new Prisma.Decimal(1),
+ unit: '块',
+ weightKg: null,
+ lossFactor: new Prisma.Decimal(1),
+ sourceConfidence: new Prisma.Decimal(0.8),
+ evidence: [],
+ matchStatus: 'UNMATCHED',
+ matchedLibraryType: 'SUBSTRATE',
+ matchedLibraryRecordId: 'private-library-row',
+ matchedLibraryName: '内部板材记录',
+ matchedLibrarySpec: '20 mm',
+ resolvedUnitPrice: new Prisma.Decimal(88),
+ calculatedQuantity: new Prisma.Decimal(0.72),
+ subtotal: null,
+ priceSnapshots: [{ unit: '㎡' }],
+ updatedAt: now,
+ }],
+ }],
+ } as never)
+
+ const detail = await bomCostingService.getCase('case-public-v1', operationsUser)
+ const [line] = detail.skus[0]!.bomLines
+
+ expect(detail).not.toHaveProperty('reviewInputs')
+ expect(detail).not.toHaveProperty('sourceUrl')
+ expect(detail).not.toHaveProperty('sourceHost')
+ expect(detail).not.toHaveProperty('providerCode')
+ expect(detail).not.toHaveProperty('providerTaskId')
+ expect(line).toMatchObject({
+ partName: '桌面板',
+ estimateStatus: 'PENDING',
+ })
+ for (const key of [
+ 'matchStatus',
+ 'matchedLibraryType',
+ 'matchedLibraryRecordId',
+ 'matchedLibraryName',
+ 'matchedLibrarySpec',
+ 'resolvedUnitPrice',
+ 'resolvedPriceUnit',
+ 'calculatedQuantity',
+ ]) {
+ expect(line).not.toHaveProperty(key)
+ }
+ expect(JSON.stringify(detail)).not.toContain('review-private-price')
+ expect(JSON.stringify(detail)).not.toContain('private-library-row')
+ expect(JSON.stringify(detail)).not.toContain('https://example.com/item/public-v1')
+ expect(JSON.stringify(detail)).not.toContain('private-market')
+ expect(JSON.stringify(detail)).not.toContain('private-item-1')
+ expect(JSON.stringify(detail)).not.toContain('私有店铺')
+ })
+
it('旧版结果必须重新核算后才能生成动态成本场景', async () => {
jest.spyOn(bomCostingService, 'getCase').mockResolvedValue({
id: 'case-legacy',
@@ -1144,7 +1273,7 @@ describe('BomCostingService', () => {
})
})
- it('商品跳转优先使用归档商品标识,不继续打开登录或追踪地址', async () => {
+ it('归档商品标识只供服务端内部使用,普通列表不返回链接或站点来源', async () => {
jest.spyOn(bomCostingRepository, 'listCases').mockResolvedValue([{
id: 'case-canonical-url',
title: '规范商品地址',
@@ -1172,6 +1301,9 @@ describe('BomCostingService', () => {
const [summary] = await bomCostingService.listCases(operationsUser)
- expect(summary?.sourceUrl).toBe('https://item.taobao.com/item.htm?id=123456789')
+ expect(summary).not.toHaveProperty('sourceUrl')
+ expect(summary).not.toHaveProperty('sourceHost')
+ expect(JSON.stringify(summary)).not.toContain('login.taobao.com')
+ expect(JSON.stringify(summary)).not.toContain('123456789')
})
})
diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.ts
index 83803212..6f76543b 100644
--- a/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.ts
+++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing.service.ts
@@ -47,9 +47,7 @@ import { bomCostingProcessingService } from './bom-costing-processing.service'
import { BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION } from './bom-costing-structure-knowledge'
import {
buildCanonicalMarketProductUrl,
- canonicalizeMarketProductUrl,
parseMarketProductIdentity,
- type MarketProductIdentity,
} from './market-detail-client'
const CATEGORY_LABELS: Record = {
@@ -65,24 +63,6 @@ const CURRENT_PACKAGING_LIBRARY_TYPE = 'ASSET_PACKAGING_VARIANT'
const MANUAL_REVIEW_PRICE_SOURCE = 'MANUAL_REVIEW'
const EXTERNAL_ESTIMATE_PRICE_SOURCE = 'EXTERNAL_ANALYSIS_ESTIMATE'
-function canonicalCaseSourceUrl(record: {
- sourceUrl: string
- sourceSnapshot?: { channel?: string; externalItemId?: string } | null
-}) {
- const channel = record.sourceSnapshot?.channel
- const itemId = record.sourceSnapshot?.externalItemId?.trim()
- if (
- itemId &&
- (channel === 'taobao' || channel === 'tmall' || channel === '1688' || channel === 'jd' || channel === 'douyin')
- ) {
- return buildCanonicalMarketProductUrl({
- channel: channel as MarketProductIdentity['channel'],
- itemId,
- }, record.sourceUrl)
- }
- return canonicalizeMarketProductUrl(record.sourceUrl)
-}
-
function decimalToNumber(value: Prisma.Decimal | null | undefined) {
return value === null || value === undefined ? null : value.toNumber()
}
@@ -267,31 +247,10 @@ function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLine
weightKg: decimalToNumber(line.weightKg),
lossFactor: decimalToNumber(line.lossFactor) ?? 1,
sourceConfidence: decimalToNumber(line.sourceConfidence),
- evidence: line.evidence,
- matchStatus: needsPackagingReconfirmation
- ? BomCostingMatchStatus.NEEDS_CONFIRMATION
- : line.matchStatus,
- matchedLibraryType: needsPackagingReconfirmation
- ? ''
- : line.matchedLibraryType,
- matchedLibraryRecordId: needsPackagingReconfirmation
- ? ''
- : line.matchedLibraryRecordId,
- matchedLibraryName: needsPackagingReconfirmation
- ? ''
- : line.matchedLibraryName,
- matchedLibrarySpec: needsPackagingReconfirmation
- ? ''
- : line.matchedLibrarySpec,
- resolvedUnitPrice: needsPackagingReconfirmation
- ? null
- : decimalToNumber(line.resolvedUnitPrice),
- resolvedPriceUnit: needsPackagingReconfirmation
- ? ''
- : line.priceSnapshots[0]?.unit || line.unit,
- calculatedQuantity: needsPackagingReconfirmation
- ? null
- : decimalToNumber(line.calculatedQuantity),
+ evidence: serializePublicLineEvidence(line.evidence),
+ estimateStatus: needsPackagingReconfirmation || line.subtotal === null
+ ? 'PENDING'
+ : 'ESTIMATED',
subtotal: needsPackagingReconfirmation
? null
: decimalToNumber(line.subtotal),
@@ -299,6 +258,28 @@ function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLine
}
}
+function serializePublicLineEvidence(evidence: Prisma.JsonValue | null) {
+ if (!Array.isArray(evidence)) return []
+
+ return evidence.flatMap((entry) => {
+ if (!entry || Array.isArray(entry) || typeof entry !== 'object') return []
+ const record = entry as Prisma.JsonObject
+ const factIds = Array.isArray(record.factIds)
+ ? record.factIds.filter((factId): factId is string => typeof factId === 'string')
+ : []
+
+ return [{
+ imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : null,
+ basis: typeof record.basis === 'string' ? record.basis : null,
+ note: typeof record.note === 'string' ? record.note : null,
+ factIds,
+ derivation: typeof record.derivation === 'string' ? record.derivation : null,
+ reviewReason: typeof record.reviewReason === 'string' ? record.reviewReason : null,
+ confidence: typeof record.confidence === 'number' ? record.confidence : null,
+ }]
+ })
+}
+
function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
const resultQuality = getBomCostingResultQuality({
status: record.status,
@@ -355,11 +336,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
})()
return {
id: record.id,
- sourceUrl: canonicalCaseSourceUrl(record),
- sourceHost: record.sourceHost,
title: record.title,
- providerCode: record.providerCode,
- providerTaskId: record.providerTaskId,
sourceCacheHit: record.sourceCacheHit,
status: record.status,
progress: record.progress,
@@ -377,10 +354,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
? {
id: record.sourceSnapshot.id,
status: record.sourceSnapshot.status,
- channel: record.sourceSnapshot.channel,
- externalItemId: record.sourceSnapshot.externalItemId,
title: record.sourceSnapshot.title,
- shopName: record.sourceSnapshot.shopName,
skuCount: record.sourceSnapshot.skuCount,
imageCount: record.sourceSnapshot.imageCount,
storedImageCount: record.sourceSnapshot.storedImageCount,
@@ -449,30 +423,6 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
costSummary,
}
}),
- reviewInputs: record.reviewInputs.map((input) => ({
- id: input.id,
- skuId: input.skuId,
- lineId: input.lineId,
- skuName: input.sku?.name ?? input.skuName,
- partName: input.line?.partName ?? input.partName,
- source: input.source,
- materialCode: input.materialCode,
- materialName: input.materialName,
- materialSpec: input.materialSpec,
- lengthMm: decimalToNumber(input.lengthMm),
- widthMm: decimalToNumber(input.widthMm),
- heightMm: decimalToNumber(input.heightMm),
- quantity: decimalToNumber(input.quantity),
- unitPrice: decimalToNumber(input.unitPrice),
- priceUnit: input.priceUnit,
- currency: input.currency,
- lossFactor: decimalToNumber(input.lossFactor),
- subtotal: decimalToNumber(input.subtotal),
- note: input.note,
- createdById: input.createdById,
- createdByName: input.creator?.name ?? '已离职用户',
- createdAt: input.createdAt.toISOString(),
- })),
analysisRuns: record.analysisRuns.map((run) => ({
id: run.id,
revision: run.revision,
@@ -544,8 +494,6 @@ export class BomCostingService {
return {
id: record.id,
title: record.title,
- sourceUrl: canonicalCaseSourceUrl(record),
- sourceHost: record.sourceHost,
status: record.status,
progress: record.progress,
statusMessage: record.statusMessage,
|