fix(bom-costing): guard fact snapshot fingerprints

This commit is contained in:
WuYongJie 2026-08-12 20:49:07 +08:00
parent bdc8f393a8
commit a51ec8a162
4 changed files with 422 additions and 8 deletions

View File

@ -6,6 +6,9 @@ jest.mock('@/lib/prisma', () => ({
bomCostingCase: {
updateMany: jest.fn(),
},
bomCostingSourceFactAnalysis: {
findFirst: jest.fn(),
},
},
}))
@ -18,6 +21,8 @@ import { BomCostingRepository } from './bom-costing.repository'
const transaction = prisma.$transaction as jest.Mock
const updateManyCase = prisma.bomCostingCase.updateMany as jest.Mock
const findFirstSourceFactAnalysis =
prisma.bomCostingSourceFactAnalysis.findFirst as jest.Mock
describe('BomCostingRepository dynamic-cost state transitions', () => {
beforeEach(() => {
@ -378,3 +383,70 @@ describe('BomCostingRepository dynamic-cost state transitions', () => {
})
})
})
describe('BomCostingRepository reusable fact snapshot selection', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('never searches a different asset fingerprint for reusable facts', async () => {
findFirstSourceFactAnalysis.mockResolvedValue(null)
await new BomCostingRepository().findLatestReadySourceFactAnalysis({
sourceSnapshotId: 'source-1',
schemaVersion: 'competitor-facts/v1',
assetFingerprint: 'current-assets',
})
expect(findFirstSourceFactAnalysis).toHaveBeenCalledWith({
where: {
sourceSnapshotId: 'source-1',
schemaVersion: 'competitor-facts/v1',
assetFingerprint: 'current-assets',
status: 'READY',
},
orderBy: [
{ analyzedImageCount: 'desc' },
{ revision: 'desc' },
{ updatedAt: 'desc' },
],
})
})
it('treats analyzed images with empty facts as successful coverage', async () => {
const readySnapshot = {
id: 'facts-empty-but-successful',
analyzedImageCount: 77,
revision: 1,
factSnapshot: {
schemaVersion: 'competitor-facts/v1',
imageFacts: [{
imageId: 'IMG_0001',
sourceImageUrl: 'https://example.com/image.jpg',
kind: 'DETAIL',
label: '详情图 1',
externalSkuIds: [],
observedTexts: [],
facts: [],
}],
failedImages: [],
extractedAt: '2026-08-12T00:00:00.000Z',
},
}
findFirstSourceFactAnalysis.mockResolvedValue(readySnapshot)
const selected = await new BomCostingRepository()
.findLatestReadySourceFactAnalysis({
sourceSnapshotId: 'source-1',
schemaVersion: 'competitor-facts/v1',
assetFingerprint: 'current-assets',
})
expect(selected).toBe(readySnapshot)
expect(findFirstSourceFactAnalysis.mock.calls[0]?.[0]?.orderBy).toEqual([
{ analyzedImageCount: 'desc' },
{ revision: 'desc' },
{ updatedAt: 'desc' },
])
})
})

View File

@ -624,7 +624,14 @@ export class BomCostingRepository {
assetFingerprint: input.assetFingerprint,
status: BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
},
orderBy: { revision: 'desc' },
orderBy: [
// READY may still carry a retry ledger. Prefer the revision that
// actually retained the most successful image records. An image with
// no extracted facts is still a successful analysis result.
{ analyzedImageCount: 'desc' },
{ revision: 'desc' },
{ updatedAt: 'desc' },
],
})
}

View File

@ -23,6 +23,8 @@ const mockBuildStoredSnapshot = jest.fn()
const mockRefreshSourceMetadata = jest.fn()
const mockArchive = jest.fn()
const mockSourceImagesChangedError = class extends Error {}
const SOURCE_ASSET_FINGERPRINT =
'3f2d926e8473d40c4778839482560ac9869f0c6dcb4277b6844fcf1a941cb3f3'
jest.mock('@/lib/storage', () => ({
readObjectStorageKeyFromUrl: (value: string) => {
@ -307,7 +309,7 @@ function factAnalysisRecord(
origin: 'INITIAL',
baseAnalysisId: null,
selectedAssetIds: [],
assetFingerprint: 'asset-fingerprint',
assetFingerprint: SOURCE_ASSET_FINGERPRINT,
status,
model: 'grok-4.5',
factSnapshot:
@ -483,6 +485,110 @@ describe('BomCostingProcessingService', () => {
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
})
it('成本阶段恢复也不能绕过当前图片 fingerprint 门禁', async () => {
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: 'analysis-run-stale-cost-recovery',
caseId: 'case-1',
revision: 2,
mode: BomCostingAnalysisRunMode.REUSE_FACTS,
status: BomCostingAnalysisRunStatus.PROCESSING,
factAnalysisId: 'fact-analysis-old-images',
selectedAssetIds: [],
instruction: '',
resultSnapshot: supportedV3AnalysisResult,
} as never)
jest.spyOn(bomCostingRepository, 'findCaseForProcessing')
.mockResolvedValue({
...caseRecord(),
progress: 97,
statusMessage: '已恢复后台任务,继续生成分层成本',
skus: [{ id: 'sku-db-1', externalSkuId: 'sku-1' }],
} as never)
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
.mockResolvedValue({
...factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY),
id: 'fact-analysis-old-images',
assetFingerprint: 'old-source-asset-fingerprint',
} as never)
jest.spyOn(bomCostingRepository, 'findLatestReadySourceFactAnalysis')
.mockResolvedValue(null)
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingScenarioService.calculateSameMaterialForCase)
.not.toHaveBeenCalled()
expect(bomCostingRepository.updateCaseStage).not.toHaveBeenCalledWith(
'case-1',
expect.objectContaining({ eventType: 'DYNAMIC_COST_RECOVERY_RESUMED' }),
)
expect(bomCostingRepository.failCase).toHaveBeenCalledWith(
expect.objectContaining({
analysisRunId: 'analysis-run-stale-cost-recovery',
message: expect.stringContaining(
'当前归档图片版本没有可复用的图片事实',
),
}),
)
})
it('市场图片暂时归档为零张时也不能用旧 fingerprint 提前恢复成本', async () => {
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: 'analysis-run-empty-rearchive',
caseId: 'case-1',
revision: 2,
mode: BomCostingAnalysisRunMode.REUSE_FACTS,
status: BomCostingAnalysisRunStatus.PROCESSING,
factAnalysisId: 'fact-analysis-old-images',
selectedAssetIds: [],
instruction: '',
resultSnapshot: supportedV3AnalysisResult,
} as never)
jest.spyOn(bomCostingRepository, 'findCaseForProcessing')
.mockResolvedValue({
...caseRecord(),
progress: 97,
statusMessage: '已恢复后台任务,继续生成分层成本',
sourceSnapshot: {
...caseRecord().sourceSnapshot,
storedImageCount: 0,
},
skus: [{ id: 'sku-db-1', externalSkuId: 'sku-1' }],
} as never)
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
.mockResolvedValue({
...factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY),
id: 'fact-analysis-old-images',
assetFingerprint: 'old-source-asset-fingerprint',
} as never)
jest.spyOn(bomCostingRepository, 'findLatestReadySourceFactAnalysis')
.mockResolvedValue(null)
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingScenarioService.calculateSameMaterialForCase)
.not.toHaveBeenCalled()
expect(bomCostingRepository.updateCaseStage).not.toHaveBeenCalledWith(
'case-1',
expect.objectContaining({ eventType: 'DYNAMIC_COST_RECOVERY_RESUMED' }),
)
expect(bomCostingRepository.failCase).toHaveBeenCalledWith(
expect.objectContaining({
analysisRunId: 'analysis-run-empty-rearchive',
message: expect.stringContaining(
'当前归档图片版本没有可复用的图片事实',
),
}),
)
})
it('所有 SKU 动态成本均失败时不伪装已完成,保留可重新核算状态', async () => {
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
@ -1705,6 +1811,192 @@ describe('BomCostingProcessingService', () => {
)
})
it.each([
{
label: '复用事实且 schemaVersion 已变化',
mode: BomCostingAnalysisRunMode.REUSE_FACTS,
selectedAssetIds: [] as string[],
linkedFactOverrides: {
sourceSnapshotId: 'source-1',
schemaVersion: 'competitor-analysis/facts-v2',
assetFingerprint: SOURCE_ASSET_FINGERPRINT,
},
},
{
label: '补充图片分析且 sourceSnapshotId 已变化',
mode: BomCostingAnalysisRunMode.REANALYZE_SELECTED,
selectedAssetIds: ['asset-sku'],
linkedFactOverrides: {
sourceSnapshotId: 'source-old',
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint: SOURCE_ASSET_FINGERPRINT,
},
},
])(
'$label 恢复时拒绝三元组不一致的已链接事实,且只查询当前 fingerprint',
async ({ mode, selectedAssetIds, linkedFactOverrides }) => {
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: `analysis-run-stale-${mode}`,
caseId: 'case-1',
revision: 4,
mode,
status: BomCostingAnalysisRunStatus.PROCESSING,
factAnalysisId: 'fact-analysis-old-images',
selectedAssetIds,
instruction: '',
} as never)
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
.mockResolvedValue({
...factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY),
id: 'fact-analysis-old-images',
...linkedFactOverrides,
} as never)
const currentReadySpy = jest
.spyOn(bomCostingRepository, 'findLatestReadySourceFactAnalysis')
.mockResolvedValue(null)
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(currentReadySpy).toHaveBeenCalledWith({
sourceSnapshotId: 'source-1',
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint: SOURCE_ASSET_FINGERPRINT,
})
expect(mockRunAnalysis).not.toHaveBeenCalled()
expect(bomCostingRepository.linkAnalysisRunFact).not.toHaveBeenCalled()
expect(bomCostingRepository.failCase).toHaveBeenCalledWith(
expect.objectContaining({
analysisRunId: `analysis-run-stale-${mode}`,
message: expect.stringContaining(
'当前归档图片版本没有可复用的图片事实',
),
}),
)
},
)
it('全部重新分析恢复时图片变化会忽略旧链接并创建当前 fingerprint 的全量修订', async () => {
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: 'analysis-run-all-changed-images',
caseId: 'case-1',
revision: 5,
mode: BomCostingAnalysisRunMode.REANALYZE_ALL,
status: BomCostingAnalysisRunStatus.PROCESSING,
factAnalysisId: 'fact-analysis-old-images',
selectedAssetIds: [],
instruction: '重新核对全部图片',
} as never)
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
.mockResolvedValue({
...factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY),
id: 'fact-analysis-old-images',
assetFingerprint: 'old-source-asset-fingerprint',
} as never)
jest
.spyOn(bomCostingRepository, 'claimSourceSnapshotMetadataRefresh')
.mockResolvedValue(true)
jest.spyOn(bomCostingRepository, 'updateCaseTitle')
.mockResolvedValue({} as never)
jest.spyOn(marketDetailClient, 'readProduct')
.mockResolvedValue(snapshot)
mockRefreshSourceMetadata.mockResolvedValue(sourceSnapshot)
const currentReadySpy = jest
.spyOn(bomCostingRepository, 'findLatestReadySourceFactAnalysis')
.mockResolvedValue(null)
const createRevisionSpy = jest
.spyOn(bomCostingRepository, 'createSourceFactAnalysisRevision')
.mockImplementation(async (input) => ({
...factAnalysisRecord(),
id: 'fact-analysis-current-images',
revision: 6,
origin: 'FULL_REANALYSIS',
baseAnalysisId: null,
assetFingerprint: input.assetFingerprint,
factSnapshot: input.factSnapshot,
analyzedImageCount: 0,
}) as never)
mockRunAnalysis.mockImplementation(async (input) => {
const analysisInput = input as {
existingFactSnapshot?: BomCostingSourceFactSnapshot | null
reuseExistingFactsOnly?: boolean
imageIdentitiesToAnalyze?: string[]
onFactExtractionCompleted?: (result: unknown) => Promise<void>
}
expect(analysisInput.existingFactSnapshot?.imageFacts).toEqual([])
expect(analysisInput.reuseExistingFactsOnly).toBe(false)
expect(new Set(analysisInput.imageIdentitiesToAnalyze)).toEqual(new Set([
'https://storage.example.com/stored-main',
'https://storage.example.com/stored-sku',
]))
await analysisInput.onFactExtractionCompleted?.({
snapshot: factSnapshot,
trace: {
model: 'grok-4.5',
callIds: ['fact-call-current-images'],
batchCount: 1,
reusedImageCount: 0,
analyzedImageCount: 2,
failedImageCount: 0,
durationMs: 10_000,
promptTokens: 40,
completionTokens: 60,
},
})
return {
result: analysisResult,
trace: {
model: 'grok-4.5',
callId: 'synthesis-call-current-images',
imageCount: 2,
inputSkuCount: 1,
factBatchCount: 1,
reusedImageCount: 0,
analyzedImageCount: 2,
durationMs: 20_000,
promptTokens: 100,
completionTokens: 150,
},
}
})
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(currentReadySpy).toHaveBeenCalledWith({
sourceSnapshotId: 'source-1',
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint: SOURCE_ASSET_FINGERPRINT,
})
expect(createRevisionSpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceSnapshotId: 'source-1',
assetFingerprint: SOURCE_ASSET_FINGERPRINT,
origin: 'FULL_REANALYSIS',
baseAnalysisId: null,
analyzedImageCount: 0,
factSnapshot: expect.objectContaining({
imageFacts: [],
failedImages: expect.arrayContaining([
expect.objectContaining({ kind: 'MAIN', reason: 'NOT_ANALYZED' }),
expect.objectContaining({ kind: 'SKU', reason: 'NOT_ANALYZED' }),
]),
}),
}),
)
expect(bomCostingRepository.linkAnalysisRunFact).toHaveBeenCalledWith(
'analysis-run-all-changed-images',
'fact-analysis-current-images',
)
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
})
it('明确复用事实时即使仍有失败图片也不创建新修订或触发图片重试', async () => {
const readyWithFailure: BomCostingSourceFactSnapshot = {
...factSnapshot,

View File

@ -137,6 +137,26 @@ function buildSourceAssetFingerprint(
return createHash('sha256').update(input).digest('hex')
}
function isFactAnalysisForCurrentSource(
factAnalysis: {
sourceSnapshotId: string
schemaVersion: string
assetFingerprint: string
} | null,
input: {
sourceSnapshotId: string
schemaVersion: string
assetFingerprint: string
},
) {
return Boolean(
factAnalysis &&
factAnalysis.sourceSnapshotId === input.sourceSnapshotId &&
factAnalysis.schemaVersion === input.schemaVersion &&
factAnalysis.assetFingerprint === input.assetFingerprint,
)
}
function parseSourceFactSnapshot(value: Prisma.JsonValue | null) {
if (value === null) return null
return bomCostingSourceFactSnapshotSchema.parse(value)
@ -579,6 +599,20 @@ export class BomCostingProcessingService {
if (!record) throw new Error('BOM 核算案例不存在')
sourceSnapshotId = record.sourceSnapshotId
const linkedFactAnalysis = analysisRun.factAnalysisId
? await bomCostingRepository.findSourceFactAnalysis(
analysisRun.factAnalysisId,
)
: null
const linkedFactMatchesCurrentArchivedSource = Boolean(
record.sourceSnapshot &&
isFactAnalysisForCurrentSource(linkedFactAnalysis, {
sourceSnapshotId: record.sourceSnapshot.id,
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint: buildSourceAssetFingerprint(record.sourceSnapshot),
}),
)
const persistedAnalysis = competitorAnalysisResultSchema.safeParse(
analysisRun.resultSnapshot,
)
@ -593,7 +627,11 @@ export class BomCostingProcessingService {
persistedAnalysis.data.structureBlueprint.supportStatus === 'SUPPORTED' &&
savedSkuIds.length > 0 &&
savedSkuIds.length === persistedSkuIds.length &&
savedSkuIds.every((skuId, index) => skuId === persistedSkuIds[index])
savedSkuIds.every((skuId, index) => skuId === persistedSkuIds[index]) &&
(
!record.sourceSnapshot ||
linkedFactMatchesCurrentArchivedSource
)
if (canResumeDynamicCosting) {
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'DYNAMIC_COST_RECOVERY_RESUMED',
@ -898,10 +936,15 @@ export class BomCostingProcessingService {
analysisInputStats.imageCount > 0
) {
const assetFingerprint = buildSourceAssetFingerprint(archivedSource)
let factAnalysis = analysisRun.factAnalysisId
? await bomCostingRepository.findSourceFactAnalysis(
analysisRun.factAnalysisId,
)
let factAnalysis = isFactAnalysisForCurrentSource(
linkedFactAnalysis,
{
sourceSnapshotId: archivedSource.id,
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint,
},
)
? linkedFactAnalysis
: null
if (!factAnalysis) {
if (analysisRun.mode === BomCostingAnalysisRunMode.INITIAL) {
@ -926,7 +969,7 @@ export class BomCostingProcessingService {
!baseFactAnalysis
) {
throw new Error(
'当前竞品没有可复用的完整图片事实,请选择全部重新分析',
'当前归档图片版本没有可复用的图片事实;图片或归档版本可能已变化,请选择“全部重新分析”',
)
}
if (analysisRun.mode === BomCostingAnalysisRunMode.REUSE_FACTS) {