merge: preserve BOM estimates after structure review

This commit is contained in:
WuYongJie 2026-08-17 23:39:17 +08:00
commit 2be5f59220
6 changed files with 192 additions and 53 deletions

View File

@ -1371,6 +1371,7 @@ describe('BomCostingWorkbenchClient', () => {
costedSkuCount: 1,
pendingSkuCount: 1,
failedSkuCount: 1,
qualityWarningCount: 1,
succeededSkuIds: ['sku-recalculate', 'sku-pending'],
costedSkuIds: ['sku-recalculate'],
pendingSkuIds: ['sku-pending'],
@ -1579,10 +1580,10 @@ describe('BomCostingWorkbenchClient', () => {
'1/3 个 SKU 的预估已完成',
)
expect(dynamicCostNotice).toHaveTextContent(
'其余 SKU 可按现有事实重新核算',
'失败规格 · 当前资料不足,暂无法预估',
)
expect(dynamicCostNotice).toHaveTextContent(
'失败规格 · 当前资料不足,可补充结构或尺寸后重新核算',
'结构拆解差异已标记待复核,当前整件预估已保留',
)
expect(screen.getByRole('alert', {
name: '本轮批次部分完成,部分 SKU 暂无法预估',
@ -1592,14 +1593,14 @@ describe('BomCostingWorkbenchClient', () => {
fireEvent.click(failedSkuButton)
expect(screen.getAllByText('资料不足,暂无法预估').length).toBeGreaterThan(0)
expect(screen.getByText(
'本轮未形成可用整件预估。已保留结构事实,可补充结构或尺寸后重新核算。',
'该规格暂未形成预估;当前结构与资料已保留。',
)).toBeInTheDocument()
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
expect(screen.queryByText('约 ¥999.00')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /待补价格规格/ }))
expect(screen.getAllByText('待补成本').length).toBeGreaterThan(0)
expect(screen.getByText(
'本轮该 SKU 的结构事实已保存;待补信息完善后可继续形成完整预估。',
'该规格仍在整理预估依据;当前结构与资料已保留。',
)).toBeInTheDocument()
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
expect(screen.queryByText('约 ¥888.00')).not.toBeInTheDocument()

View File

@ -440,6 +440,7 @@ interface DynamicCostingCompletionNotice {
pendingSkuCount: number
failedSkuCount: number
reviewSkuCount: number
qualityWarningCount: number
succeededSkuIds: string[]
costedSkuIds: string[]
pendingSkuIds: string[]
@ -549,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),
@ -1716,20 +1718,27 @@ function ResultCanvas({
{dynamicCompletionNotice && (
dynamicCompletionNotice.pendingSkuCount > 0 ||
dynamicCompletionNotice.failedSkuCount > 0 ||
dynamicCompletionNotice.reviewSkuCount > 0
dynamicCompletionNotice.reviewSkuCount > 0 ||
dynamicCompletionNotice.qualityWarningCount > 0
) ? (
<section
className="shrink-0 border-b border-[#b98743]/25 bg-[#fbf8f0]"
role={dynamicCompletionNotice.failedSkuCount > 0 ? 'alert' : 'status'}
aria-label={dynamicCompletionNotice.failedSkuCount > 0
? '本轮批次部分完成,部分 SKU 暂无法预估'
: '本轮批次部分完成'}
: dynamicCompletionNotice.qualityWarningCount > 0
? '本轮预估已完成,结构拆解待复核'
: '本轮批次部分完成'}
data-testid="bom-dynamic-cost-partial-notice"
>
<div className="mx-auto flex w-full max-w-[1500px] items-start gap-2 px-4 py-2.5 text-[10px] leading-4 text-[#715632] xl:px-5">
<CircleAlert size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="font-bold">本轮批次部分完成</p>
<p className="font-bold">
{dynamicCompletionNotice.failedSkuCount > 0 || dynamicCompletionNotice.pendingSkuCount > 0
? '本轮批次部分完成'
: '本轮预估已完成'}
</p>
<p className="mt-0.5 text-[#806944]">
{dynamicCompletionNotice.costedSkuCount}/{dynamicCompletionNotice.totalSkuCount} 个 SKU 的预估已完成
{dynamicCompletionNotice.pendingSkuCount > 0
@ -1740,7 +1749,10 @@ function ResultCanvas({
: ''}
{dynamicCompletionNotice.reviewSkuCount > 0
? `,${dynamicCompletionNotice.reviewSkuCount} 个待复核`
: ''}。其余 SKU 可按现有事实重新核算。
: ''}
{dynamicCompletionNotice.qualityWarningCount > 0
? ';结构拆解差异已标记待复核,当前整件预估已保留'
: '。'}
</p>
{dynamicCompletionNotice.failures.length > 0 ? (
<ul className="mt-1 space-y-0.5 text-[#806944]">
@ -1751,7 +1763,7 @@ function ResultCanvas({
sku.id === failure.skuId || sku.externalSkuId === failure.externalSkuId
))?.name ?? failure.externalSkuId ?? '未知 SKU'}
</span>
<span> · 当前资料不足,可补充结构或尺寸后重新核算</span>
<span> · 当前资料不足,暂无法预估</span>
</li>
))}
{dynamicCompletionNotice.failures.length > 4 ? (
@ -2189,13 +2201,13 @@ function ResultCanvas({
: categoryCostBlocked
? `${categoryBlockedLabel};当前仅保存结构草稿,不生成 ¥0 或任何推测金额。`
: selectedDynamicCostState === 'FAILED'
? '本轮未形成可用整件预估;可补充结构或尺寸后重新核算。'
? '该规格暂未形成预估;当前结构与资料已保留。'
: selectedDynamicCostState === 'PENDING'
? '本轮该 SKU 仍有待补信息;补充结构或尺寸后可继续形成完整预估。'
? '该规格仍在整理预估依据;当前结构与资料已保留。'
: hasPendingCost
? 'SKU 结构已就绪;补齐对应成本资料后会形成完整成本。'
: incompleteCostMessage
? `${incompleteCostMessage},按现有事实重新核算可补齐。`
? `${incompleteCostMessage},当前资料会继续用于预估。`
: approximateCostLineCount > 0
? `${approximateCostLineCount} 项采用旧资料库近似价,仅作为历史留档。`
: `误差 ≤${detail.targetErrorPercent}% 是核算目标,需经真实采购成本回测验证。`}
@ -2812,14 +2824,14 @@ export default function BomCostingWorkbenchClient({
detail.status === 'READY' &&
recalculationFailed
) {
showError('重新核算未形成可用预估,仍显示上一次结果')
showError('本轮暂未形成预估,仍显示上一次结果')
}
if (
previousStatus &&
previousStatus !== 'FAILED' &&
detail.status === 'FAILED'
) {
showError('本轮未形成可用预估,可补充资料后重新核算')
showError('本轮服务未能完成预估,仍显示已保存结果')
}
}, [showError, showSuccess])

View File

@ -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(

View File

@ -284,6 +284,7 @@ async function completeDynamicCostingPhase(input: {
caseId: string
analysisRunId: string
skus: Array<{ externalSkuId: string }>
qualityWarningCount?: number
}) {
let ownershipLost = false
let heartbeatInFlight: Promise<void> | null = null
@ -319,13 +320,15 @@ async function completeDynamicCostingPhase(input: {
}, DYNAMIC_COST_HEARTBEAT_INTERVAL_MS)
const skuCount = input.skus.length
let dynamicCosting: Awaited<ReturnType<
type DynamicCostingCompletion = Awaited<ReturnType<
typeof bomCostingScenarioService.calculateSameMaterialForCase
>>
>> & { 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) {

View File

@ -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/)
})
})

View File

@ -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))
}