diff --git a/furniture-3d-system/src/server/repositories/bom-costing.repository.test.ts b/furniture-3d-system/src/server/repositories/bom-costing.repository.test.ts index 61373960..f261aa83 100644 --- a/furniture-3d-system/src/server/repositories/bom-costing.repository.test.ts +++ b/furniture-3d-system/src/server/repositories/bom-costing.repository.test.ts @@ -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' }, + ]) + }) +}) 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 ea0bb813..512c0fba 100644 --- a/furniture-3d-system/src/server/repositories/bom-costing.repository.ts +++ b/furniture-3d-system/src/server/repositories/bom-costing.repository.ts @@ -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' }, + ], }) } 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 07c8473e..6a05e5c4 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 @@ -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 + } + 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, 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 506c7f43..6f1c88be 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 @@ -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) {