merge: preserve BOM estimates after structure review
This commit is contained in:
commit
2be5f59220
@ -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()
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user