fix(bom): guard historical and invalid costing results

This commit is contained in:
WuYongJie 2026-08-05 11:42:28 +08:00
parent f1dc44376a
commit 3d18a89e55
11 changed files with 673 additions and 94 deletions

View File

@ -662,6 +662,7 @@ export function BomCostingAnalysisProgressPanel({
)
)
const stageLabel = PROCESS_PHASE_LABELS[activity.phase]
const processingStopped = detail.status === 'FAILED'
const displayMessage =
detail.status === 'FAILED' ? detail.statusMessage : activity.latestMessage
const activeBatchLabel = activity.activeBatchIndexes.length > 0
@ -731,7 +732,7 @@ export function BomCostingAnalysisProgressPanel({
</span>
) : null}
<span className="text-[12px] font-black tabular-nums text-[#35484c]">
{detail.progress}%
{processingStopped ? '已停止' : `${detail.progress}%`}
</span>
<BomGsapRotate active={open} degrees={180}>
<ChevronDown size={12} />
@ -746,6 +747,11 @@ export function BomCostingAnalysisProgressPanel({
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={detail.progress}
aria-valuetext={
processingStopped
? `任务已停止,最后记录进度 ${detail.progress}%`
: undefined
}
>
<BomGsapProgress
value={Math.max(2, detail.progress)}

View File

@ -725,6 +725,7 @@ describe('BomCostingWorkbenchClient', () => {
progress: 52,
statusMessage: '多模态分析服务暂时不可用',
sourceCacheHit: false,
sourceSkuCount: 7,
storedImageCount: 33,
failedImageCount: 0,
createdById: 'user-1',
@ -743,6 +744,9 @@ describe('BomCostingWorkbenchClient', () => {
).toBeInTheDocument()
expect(screen.queryByText('当前案例')).not.toBeInTheDocument()
expect(screen.getByLabelText('失败')).toBeInTheDocument()
expect(screen.getByText('已停止 · 已读取 7 SKU · 33 图')).toBeInTheDocument()
expect(screen.queryByText('0 SKU · 33 图')).not.toBeInTheDocument()
expect(screen.getAllByText('已停止').length).toBeGreaterThan(0)
expect(screen.queryByText('本次核算已停止')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续处理' }))

View File

@ -142,6 +142,55 @@ const STATUS_DOT_CLASSES: Record<BomCostingCaseSummary['status'], string> = {
FAILED: 'bg-[#b6534e]',
}
function getHistoryStatusLabel(item: BomCostingCaseSummary) {
if (item.status !== 'READY') return STATUS_LABELS[item.status]
if (item.requiresRecalculation) return '旧规则结果'
if (item.hasFailedRecalculation) return '重算失败'
if (item.needsPriceReview) return '待复核'
return STATUS_LABELS[item.status]
}
function getHistoryStatusDotClass(item: BomCostingCaseSummary) {
if (
item.status === 'READY' &&
(item.requiresRecalculation || item.hasFailedRecalculation || item.needsPriceReview)
) {
return 'bg-[#c68b37]'
}
return STATUS_DOT_CLASSES[item.status]
}
function getKnownSourceSkuCount(item: BomCostingCaseSummary) {
return item.sourceSkuCount ?? item.skuCount
}
function getHistoryMetrics(item: BomCostingCaseSummary) {
const sourceSkuCount = getKnownSourceSkuCount(item)
const sourceSkuText = sourceSkuCount > 0
? `已读取 ${sourceSkuCount} SKU`
: ''
if (['QUEUED', 'ANALYZING'].includes(item.status)) {
return [
`${item.progress}%`,
sourceSkuText,
`${item.storedImageCount} 图`,
].filter(Boolean).join(' · ')
}
if (item.status === 'FAILED') {
return [
'已停止',
sourceSkuText,
`${item.storedImageCount} 图`,
].filter(Boolean).join(' · ')
}
const resultSkuText = item.skuCount > 0 ? `${item.skuCount} SKU` : '结果待核'
return [
item.requiresRecalculation ? '历史结果' : '',
resultSkuText,
`${item.storedImageCount} 图`,
].filter(Boolean).join(' · ')
}
const HISTORY_STATUS_OPTIONS: Array<{
value: HistoryStatusFilter
label: string
@ -348,6 +397,7 @@ async function readApiData<T>(response: Response) {
}
function toSummary(detail: BomCostingCaseDetail): BomCostingCaseSummary {
const requiresRecalculation = Boolean(detail.requiresRecalculation)
const costs = detail.skus
.map((sku) => sku.estimatedCost)
.filter((value): value is number => value !== null)
@ -359,13 +409,17 @@ function toSummary(detail: BomCostingCaseDetail): BomCostingCaseSummary {
status: detail.status,
progress: detail.progress,
statusMessage: detail.statusMessage,
sourceSkuCount: detail.sourceSnapshot?.skuCount ?? detail.skus.length,
requiresRecalculation,
hasFailedRecalculation: Boolean(detail.hasFailedRecalculation),
needsPriceReview: Boolean(detail.needsPriceReview),
sourceCacheHit: detail.sourceCacheHit,
storedImageCount: detail.sourceSnapshot?.storedImageCount ?? 0,
failedImageCount: detail.sourceSnapshot?.failedImageCount ?? 0,
createdById: detail.createdById,
createdAt: detail.createdAt,
skuCount: detail.skus.length,
estimatedCostRange: costs.length
estimatedCostRange: !requiresRecalculation && costs.length
? { min: Math.min(...costs), max: Math.max(...costs) }
: null,
}
@ -546,12 +600,17 @@ function ProcessInspector({
(focusAsset
? `${focusAsset.kind === 'MAIN' ? '主图' : focusAsset.kind === 'SKU' ? 'SKU 图' : '详情图'} ${focusAsset.sortOrder + 1}`
: detail.statusMessage)
const processingStopped = detail.status === 'FAILED'
return (
<aside className="min-w-0 border-t border-black/[0.07] bg-[#f6f7f7] px-3 py-3 xl:min-h-0 xl:overflow-y-auto xl:border-l xl:border-t-0">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[12px] font-bold">处理进度</h3>
<span className="text-[11px] font-black tabular-nums">{detail.progress}%</span>
<h3 className="text-[12px] font-bold">
{processingStopped ? '处理已停止' : '处理进度'}
</h3>
<span className="text-[11px] font-black tabular-nums">
{processingStopped ? '已停止' : `${detail.progress}%`}
</span>
</div>
<ol className="relative">
@ -1032,10 +1091,12 @@ function ResultCanvas({
}
}
const requiresRecalculation = Boolean(detail.requiresRecalculation)
const missingCostGroups = new Set(
selectedSku.costSummary.missingCostGroups ?? [],
)
const materialHasUnresolved =
requiresRecalculation ||
missingCostGroups.has('MATERIAL') ||
selectedSku.bomLines.some(
(line) =>
@ -1043,6 +1104,7 @@ function ResultCanvas({
line.subtotal === null,
)
const packagingHasUnresolved =
requiresRecalculation ||
missingCostGroups.has('PACKAGING') ||
selectedSku.bomLines.some(
(line) => line.category === 'PACKAGING' && line.subtotal === null,
@ -1054,6 +1116,9 @@ function ResultCanvas({
missingCostGroups.has('PACKAGING') ? '包材' : '',
].filter(Boolean)
const incompleteCostMessage = [
requiresRecalculation
? '当前记录由旧规则生成'
: '',
selectedSku.costSummary.unresolvedLineCount > 0
? `${selectedSku.costSummary.unresolvedLineCount} 项价格待确认`
: '',
@ -1069,25 +1134,25 @@ function ResultCanvas({
label: '材料',
value: selectedSku.costSummary.material,
unresolved: materialHasUnresolved,
missing: missingCostGroups.has('MATERIAL'),
missing: requiresRecalculation || missingCostGroups.has('MATERIAL'),
},
{
label: '加工(AI 估算)',
value: selectedSku.costSummary.processing,
unresolved: missingCostGroups.has('PROCESSING'),
missing: missingCostGroups.has('PROCESSING'),
unresolved: requiresRecalculation || missingCostGroups.has('PROCESSING'),
missing: requiresRecalculation || missingCostGroups.has('PROCESSING'),
},
{
label: '人工(AI 估算)',
value: selectedSku.costSummary.labor,
unresolved: missingCostGroups.has('LABOR'),
missing: missingCostGroups.has('LABOR'),
unresolved: requiresRecalculation || missingCostGroups.has('LABOR'),
missing: requiresRecalculation || missingCostGroups.has('LABOR'),
},
{
label: '包材',
value: selectedSku.costSummary.packaging,
unresolved: packagingHasUnresolved,
missing: missingCostGroups.has('PACKAGING'),
missing: requiresRecalculation || missingCostGroups.has('PACKAGING'),
},
]
const currentSkuReviewInputs = (detail.reviewInputs ?? []).slice(0, 8)
@ -1107,6 +1172,27 @@ function ResultCanvas({
/>
</div>
) : null}
{requiresRecalculation ? (
<section
className="border-y border-[#b98743]/30 bg-[#fbf8f0]"
role="alert"
aria-label="旧规则核算结果提示"
>
<div className="mx-auto flex w-full max-w-[1500px] items-center justify-between gap-3 px-4 py-2.5 xl:px-5">
<span className="flex min-w-0 items-center gap-2 text-[10px] leading-4 text-[#7a5a31]">
<CircleAlert size={14} className="shrink-0" />
<span>当前记录由旧规则生成,重新核算后再用于价格判断。</span>
</span>
<button
type="button"
onClick={() => setRecalculationOpen(true)}
className="shrink-0 rounded-[4px] px-1.5 py-1 text-[10px] font-bold text-[#664b28] outline-none transition hover:bg-[#f0e6d2] focus-visible:ring-2 focus-visible:ring-[#8c6738]"
>
重新核算
</button>
</div>
</section>
) : null}
<div className="mx-auto grid w-full max-w-[1500px] gap-5 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:px-5">
<div className="contents">
<section
@ -1673,18 +1759,22 @@ function ResultCanvas({
<section className="mt-5 bg-[#2d3335] px-4 py-4 text-white">
<div className="flex items-center gap-1">
<p className="text-[10px] font-semibold text-white/70">预计出厂成本</p>
<p className="text-[10px] font-semibold text-white/70">
{requiresRecalculation ? '历史成本留档' : '预计出厂成本'}
</p>
<BomInfoHint
inverse
label="查看预计出厂成本说明"
>
{selectedSku.estimatedCost === null
{requiresRecalculation
? '当前留档由旧规则生成,不能用于价格判断;重新核算后会保留新的版本。'
: selectedSku.estimatedCost === null
? '当前旧结果未形成完整估价;按现有事实重新核算即可,无需重新分析图片。'
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。'}
</BomInfoHint>
</div>
<div className="mt-1 text-[30px] font-black leading-none tabular-nums">
{selectedSku.estimatedCost === null
{requiresRecalculation || selectedSku.estimatedCost === null
? '待重算'
: `约 ${formatMoney(selectedSku.estimatedCost)}`}
</div>
@ -1964,7 +2054,7 @@ function ResultCanvas({
</span>
</span>
<span className="shrink-0 text-[10px] font-black">
{formatMoney(sku.estimatedCost)}
{requiresRecalculation ? '待重算' : formatMoney(sku.estimatedCost)}
</span>
</button>
)
@ -2497,6 +2587,7 @@ export default function BomCostingWorkbenchClient({
{visibleCases.map((item) => {
const active = item.id === selectedCaseId
const processing = ['QUEUED', 'ANALYZING'].includes(item.status)
const historyStatusLabel = getHistoryStatusLabel(item)
return (
<button
key={item.id}
@ -2524,20 +2615,18 @@ export default function BomCostingWorkbenchClient({
) : (
<span
role="img"
aria-label={STATUS_LABELS[item.status]}
title={STATUS_LABELS[item.status]}
aria-label={historyStatusLabel}
title={historyStatusLabel}
className={cn(
'mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-2 ring-white',
STATUS_DOT_CLASSES[item.status],
getHistoryStatusDotClass(item),
)}
/>
)}
</div>
<div className="mt-2 flex items-center justify-between text-[10px]">
<span className="text-[#72787a]">
{processing
? `${item.progress}% · ${item.storedImageCount} 图`
: `${item.skuCount} SKU · ${item.storedImageCount} 图`}
{getHistoryMetrics(item)}
</span>
{item.estimatedCostRange ? (
<span className="font-semibold">

View File

@ -15,6 +15,10 @@ export interface BomCostingCaseSummary {
status: BomCostingCaseStatusValue
progress: number
statusMessage: string
sourceSkuCount?: number
requiresRecalculation?: boolean
hasFailedRecalculation?: boolean
needsPriceReview?: boolean
sourceCacheHit: boolean
storedImageCount: number
failedImageCount: number
@ -149,6 +153,9 @@ export interface BomCostingCaseDetail {
status: BomCostingCaseStatusValue
progress: number
statusMessage: string
requiresRecalculation?: boolean
hasFailedRecalculation?: boolean
needsPriceReview?: boolean
targetErrorPercent: number
currency: string
createdById: string

View File

@ -1,6 +1,11 @@
import { z } from 'zod'
export const BOM_COSTING_ANALYSIS_SCHEMA_VERSION = 'competitor-analysis/v1' as const
// This is deliberately separate from the AI response schema. It identifies
// the end-to-end business rules that produced a persisted cost result so an
// older result can be kept as an audit record without being presented as a
// current calculation.
export const BOM_COSTING_RESULT_RULESET_VERSION = 'bom-costing-rules/v2' as const
export const BOM_COSTING_CONCEPT_MODEL_SCHEMA_VERSION = 'concept-model/v1' as const
export const BOM_COSTING_TARGET_ERROR_PERCENT = 10

View File

@ -178,6 +178,7 @@ export interface CreateQueuedBomCostingCaseInput {
title: string
providerCode: string
providerTaskId: string
analysisSchemaVersion: string
statusMessage: string
targetErrorPercent: number
createdById: string
@ -343,11 +344,20 @@ export class BomCostingRepository {
sourceSnapshot: {
select: {
status: true,
skuCount: true,
imageCount: true,
storedImageCount: true,
failedImageCount: true,
},
},
analysisRuns: {
orderBy: { revision: 'desc' },
take: 1,
select: {
revision: true,
status: true,
},
},
skus: {
orderBy: { sortOrder: 'asc' },
select: {
@ -358,6 +368,7 @@ export class BomCostingRepository {
orderBy: { sortOrder: 'asc' },
select: {
category: true,
matchStatus: true,
matchedLibraryType: true,
subtotal: true,
},
@ -666,6 +677,7 @@ export class BomCostingRepository {
title: input.title,
providerCode: input.providerCode,
providerTaskId: input.providerTaskId,
analysisSchemaVersion: input.analysisSchemaVersion,
status: BomCostingCaseStatus.QUEUED,
progress: input.sourceCacheHit ? 42 : 2,
statusMessage: input.statusMessage,
@ -1545,6 +1557,7 @@ export class BomCostingRepository {
promptTokens: number | null
completionTokens: number | null
durationMs: number
analysisSchemaVersion: string
}) {
return prisma.$transaction(async (tx) => {
await tx.bomCostingSku.deleteMany({
@ -1615,6 +1628,7 @@ export class BomCostingRepository {
where: { id: input.caseId },
data: {
title: input.title,
analysisSchemaVersion: input.analysisSchemaVersion,
status: BomCostingCaseStatus.READY,
progress: 100,
statusMessage: `核算完成,共 ${input.skus.length} 个 SKU`,

View File

@ -10,7 +10,10 @@ import {
BomCostingSourceStatus,
Prisma,
} from '@prisma/client'
import { competitorAnalysisResultV1Schema } from '@/lib/bom-costing/contracts'
import {
BOM_COSTING_RESULT_RULESET_VERSION,
competitorAnalysisResultV1Schema,
} from '@/lib/bom-costing/contracts'
import {
BOM_COSTING_FACT_ANALYSIS_STATUS,
BOM_COSTING_FACT_SCHEMA_VERSION,
@ -28,6 +31,7 @@ import {
type BomCostingArchiveProgress,
} from './bom-costing-source-archive.service'
import { prepareBomCostingAnalysis } from './bom-costing-result-preparer'
import { assertBomCostingResultQuality } from './bom-costing-result-quality'
import { getCompetitorAnalysisProvider } from './bom-costing-provider'
import {
readBomCostingSynthesisCheckpoint,
@ -150,39 +154,6 @@ function buildResultStats(
}
}
function assertPreparedCostEstimate(
skus: ReturnType<typeof prepareBomCostingAnalysis>,
) {
const incompleteSkus = skus.filter(
(sku) => sku.estimatedCost === null || sku.estimatedCost <= 0,
)
if (incompleteSkus.length > 0) {
throw new Error(
`成本核算结果不完整:${incompleteSkus.length} 个 SKU 未形成有效预估成本`,
)
}
const implausibleSkus = skus.filter((sku) => {
if (sku.estimatedCost === null) return false
if (!sku.attributes || typeof sku.attributes !== 'object' || Array.isArray(sku.attributes)) {
return false
}
const attributes = sku.attributes as Record<string, unknown>
const rawPrice = attributes.marketPriceRaw ?? attributes.marketOriginalPriceRaw
const price = typeof rawPrice === 'number'
? rawPrice
: typeof rawPrice === 'string'
? Number(rawPrice.replace(/[,,\s]/g, ''))
: NaN
return Number.isFinite(price) && price > 0 && sku.estimatedCost > price + 0.01
})
if (implausibleSkus.length > 0) {
throw new Error(
`成本可信度校验未通过:${implausibleSkus.length} 个 SKU 的预估成本高于竞品平台售价,系统未将错误成本标记为完成`,
)
}
}
function replaceAnalysisImageUrlsWithStableArchive(
result: Awaited<
ReturnType<
@ -1004,7 +975,11 @@ export class BomCostingProcessingService {
})
const candidates = await bomCostingRepository.listPriceCandidates()
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
assertPreparedCostEstimate(preparedSkus)
assertBomCostingResultQuality({
analysis: persistedResult,
sourceSnapshot: marketSnapshot,
preparedSkus,
})
const stats = buildResultStats(preparedSkus)
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'PRICE_MATCH_COMPLETED',
@ -1031,6 +1006,7 @@ export class BomCostingProcessingService {
promptTokens: output.trace.promptTokens,
completionTokens: output.trace.completionTokens,
durationMs: output.trace.durationMs,
analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,
})
} catch (error) {
const message = readErrorMessage(error)

View File

@ -0,0 +1,159 @@
/** @jest-environment node */
import type { CompetitorAnalysisResultV1 } from '@/lib/bom-costing/contracts'
import type { BomCostingPreparedSku } from '@/server/repositories/bom-costing.repository'
import type { MarketDetailSnapshot } from './market-detail-client'
import { assertBomCostingResultQuality } from './bom-costing-result-quality'
function buildSourceSnapshot(
skus: MarketDetailSnapshot['skus'],
): MarketDetailSnapshot {
return {
itemId: 'item-1',
channel: 'taobao',
title: '测试衣帽架',
shopName: null,
detailUrl: 'https://item.taobao.com/item.htm?id=1',
mainImages: [],
detailImages: [],
skuImages: [],
skus,
requestId: 'request-1',
}
}
function buildAnalysis(
skus: Array<Pick<CompetitorAnalysisResultV1['skus'][number], 'externalSkuId' | 'name'>>,
): CompetitorAnalysisResultV1 {
return {
schemaVersion: 'competitor-analysis/v1',
sourceUrl: 'https://item.taobao.com/item.htm?id=1',
product: {
title: '测试衣帽架',
imageUrls: [],
},
skus: skus.map((sku) => ({
...sku,
structureGroupKey: 'same-structure',
attributes: {},
dimensions: {},
bomLines: [{
externalLineId: 'board-top',
category: 'BOARD',
partName: '顶板',
materialName: '橡木',
lengthMm: 800,
widthMm: 400,
quantity: 1,
unit: '㎡',
estimatedUnitPrice: 80,
evidence: [],
confidence: 0.8,
}],
})),
analyzedAt: new Date().toISOString(),
}
}
function buildPreparedSku(
externalSkuId: string,
estimatedCost = 120,
attributes: Record<string, unknown> = {},
): BomCostingPreparedSku {
return {
externalSkuId,
name: externalSkuId,
structureGroupKey: 'same-structure',
imageUrl: null,
attributes,
lengthMm: null,
widthMm: null,
heightMm: null,
weightKg: null,
packageLengthMm: null,
packageWidthMm: null,
packageHeightMm: null,
packageWeightKg: null,
estimatedCost,
costCalculatedAt: new Date(),
sortOrder: 0,
bomLines: [],
}
}
describe('BOM 核算结果质量保护', () => {
it('来源 SKU 的尺寸、材质或是否含镜不同却生成相同 BOM 时拒绝保存', () => {
const sourceSnapshot = buildSourceSnapshot([
{
externalSkuId: 'solid-64-no-mirror',
name: '实木移动衣帽架【胡桃色 64CM】不带镜子',
size: '64CM',
imageUrl: null,
attributes: { 颜色分类: '实木 胡桃色 64CM 不带镜子' },
priceRaw: '617.10',
originalPriceRaw: null,
quantity: null,
},
{
externalSkuId: 'fas-84-with-mirror',
name: 'FAS级橡木移动衣帽架【原木色 84CM】带镜',
size: '84CM',
imageUrl: null,
attributes: { 颜色分类: 'FAS级橡木 原木色 84CM 带镜' },
priceRaw: '855.00',
originalPriceRaw: null,
quantity: null,
},
])
const analysis = buildAnalysis(sourceSnapshot.skus)
expect(() => assertBomCostingResultQuality({
analysis,
sourceSnapshot,
preparedSkus: sourceSnapshot.skus.map((sku) => buildPreparedSku(sku.externalSkuId)),
})).toThrow('SKU 结构可信度校验未通过')
})
it('仅颜色不同的 SKU 可以复用相同 BOM', () => {
const sourceSnapshot = buildSourceSnapshot([
{
externalSkuId: 'walnut',
name: '实木移动衣帽架【胡桃色 84CM】带镜',
size: '84CM',
imageUrl: null,
attributes: { 颜色分类: '实木 胡桃色 84CM 带镜' },
priceRaw: '617.10',
originalPriceRaw: null,
quantity: null,
},
{
externalSkuId: 'natural',
name: '实木移动衣帽架【原木色 84CM】带镜',
size: '84CM',
imageUrl: null,
attributes: { 颜色分类: '实木 原木色 84CM 带镜' },
priceRaw: '617.10',
originalPriceRaw: null,
quantity: null,
},
])
expect(() => assertBomCostingResultQuality({
analysis: buildAnalysis(sourceSnapshot.skus),
sourceSnapshot,
preparedSkus: sourceSnapshot.skus.map((sku) => buildPreparedSku(sku.externalSkuId)),
})).not.toThrow()
})
it('能识别带货币符号和“起”字的售价,阻止高于售价的成本', () => {
const analysis = buildAnalysis([{ externalSkuId: 'sku-1', name: '测试 SKU' }])
expect(() => assertBomCostingResultQuality({
analysis,
sourceSnapshot: null,
preparedSkus: [buildPreparedSku('sku-1', 618, {
marketPriceRaw: '¥617.10起',
})],
})).toThrow('预估成本高于竞品平台售价')
})
})

View File

@ -0,0 +1,255 @@
import type { CompetitorAnalysisResultV1, CompetitorSkuV1 } from '@/lib/bom-costing/contracts'
import type { BomCostingPreparedSku } from '@/server/repositories/bom-costing.repository'
import type {
MarketDetailSnapshot,
MarketSkuSnapshot,
} from './market-detail-client'
type MirrorState = 'WITH_MIRROR' | 'WITHOUT_MIRROR' | null
interface SourceSkuStructureSignature {
dimensions: string[]
materials: string[]
mirror: MirrorState
}
const MATERIAL_MARKERS: Array<{ key: string; pattern: RegExp }> = [
{ key: 'FAS', pattern: /FAS(?:\s*级)?/i },
{ key: '橡木', pattern: /橡木/ },
{ key: '橡胶木', pattern: /橡胶木/ },
{ key: '实木', pattern: /实木/ },
{ key: '胡桃木', pattern: /胡桃木/ },
{ key: '松木', pattern: /松木/ },
{ key: '榉木', pattern: /榉木/ },
{ key: '桦木', pattern: /桦木/ },
{ key: '不锈钢', pattern: /不锈钢/ },
{ key: '碳钢', pattern: /碳钢/ },
{ key: '铝合金', pattern: /铝合金/ },
{ key: '玻璃', pattern: /玻璃/ },
{ key: '皮革', pattern: /皮革|真皮|PU皮/i },
]
function normalizeText(value: string) {
return value.trim().replace(/\s+/g, ' ').toLowerCase()
}
function sortUnique(values: Iterable<string>) {
return Array.from(new Set(values)).sort()
}
function sourceSkuText(sku: MarketSkuSnapshot) {
return [
sku.name,
sku.size ?? '',
...Object.entries(sku.attributes).flatMap(([name, value]) => [name, value]),
].join(' ')
}
function extractDimensionSignals(value: string) {
const signals = new Set<string>()
for (const match of value.matchAll(/(\d+(?:\.\d+)?)\s*(毫米|mm|厘米|cm|公分)/gi)) {
const numericValue = Number(match[1])
const unit = match[2]?.toLowerCase()
if (!Number.isFinite(numericValue) || !unit) continue
const millimeters = ['厘米', 'cm', '公分'].includes(unit)
? numericValue * 10
: numericValue
signals.add(`${Math.round(millimeters * 100) / 100}mm`)
}
for (const match of value.matchAll(/\d+(?:\.\d+)?(?:\s*[x×*]\s*\d+(?:\.\d+)?){1,2}/gi)) {
const values = match[0]
.split(/[x×*]/)
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0)
if (values.length >= 2) signals.add(`组合尺寸:${values.join('×')}`)
}
return sortUnique(signals)
}
function detectMirrorState(value: string): MirrorState {
const normalized = normalizeText(value)
if (/不带镜|无镜|不含镜|去镜/.test(normalized)) return 'WITHOUT_MIRROR'
if (/带镜|含镜|镜架|穿衣镜|全身镜/.test(normalized)) return 'WITH_MIRROR'
return null
}
function buildSourceSkuStructureSignature(
sku: MarketSkuSnapshot,
): SourceSkuStructureSignature {
const text = sourceSkuText(sku)
return {
dimensions: extractDimensionSignals(text),
materials: sortUnique(
MATERIAL_MARKERS
.filter((marker) => marker.pattern.test(text))
.map((marker) => marker.key),
),
mirror: detectMirrorState(text),
}
}
function hasSameValues(left: string[], right: string[]) {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function describeSourceSkuDifferences(
left: SourceSkuStructureSignature,
right: SourceSkuStructureSignature,
) {
const differences: string[] = []
if (
left.dimensions.length > 0 &&
right.dimensions.length > 0 &&
!hasSameValues(left.dimensions, right.dimensions)
) {
differences.push('尺寸')
}
if (
left.materials.length > 0 &&
right.materials.length > 0 &&
!hasSameValues(left.materials, right.materials)
) {
differences.push('材质')
}
if (left.mirror && right.mirror && left.mirror !== right.mirror) {
differences.push('是否含镜')
}
return differences
}
function normalizeBomValue(value: unknown) {
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''
return typeof value === 'string' ? normalizeText(value) : ''
}
function buildBomFingerprint(sku: CompetitorSkuV1) {
return sku.bomLines
.map((line) => [
line.category,
normalizeBomValue(line.partName),
normalizeBomValue(line.materialCode),
normalizeBomValue(line.materialName),
normalizeBomValue(line.materialSpec),
normalizeBomValue(line.processName),
normalizeBomValue(line.packagingKind),
normalizeBomValue(line.lengthMm),
normalizeBomValue(line.widthMm),
normalizeBomValue(line.heightMm),
normalizeBomValue(line.quantity),
normalizeBomValue(line.unit),
normalizeBomValue(line.weightKg),
].join('\u001e'))
.sort()
.join('\u001f')
}
function assertDistinctSourceSkuStructures(
analysis: CompetitorAnalysisResultV1,
source: MarketDetailSnapshot,
) {
const analyzedById = new Map(
analysis.skus.map((sku) => [sku.externalSkuId, sku] as const),
)
const sourceSkus = source.skus.map((sku) => ({
sku,
signature: buildSourceSkuStructureSignature(sku),
}))
const violations: Array<{ left: string; right: string; differences: string[] }> = []
for (let leftIndex = 0; leftIndex < sourceSkus.length; leftIndex += 1) {
const left = sourceSkus[leftIndex]
if (!left) continue
const leftAnalysis = analyzedById.get(left.sku.externalSkuId)
if (!leftAnalysis) continue
const leftFingerprint = buildBomFingerprint(leftAnalysis)
for (let rightIndex = leftIndex + 1; rightIndex < sourceSkus.length; rightIndex += 1) {
const right = sourceSkus[rightIndex]
if (!right) continue
const differences = describeSourceSkuDifferences(
left.signature,
right.signature,
)
if (differences.length === 0) continue
const rightAnalysis = analyzedById.get(right.sku.externalSkuId)
if (!rightAnalysis || leftFingerprint !== buildBomFingerprint(rightAnalysis)) {
continue
}
violations.push({
left: left.sku.externalSkuId,
right: right.sku.externalSkuId,
differences,
})
}
}
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})。请重新核算或补充复核图片`,
)
}
function readMarketPrice(value: unknown) {
if (typeof value === 'number') {
return Number.isFinite(value) && value > 0 ? value : null
}
if (typeof value !== 'string') return null
const match = value.replace(/[,,\s]/g, '').match(/\d+(?:\.\d+)?/)
if (!match) return null
const parsed = Number(match[0])
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function assertPreparedCostRange(skus: BomCostingPreparedSku[]) {
const incompleteSkus = skus.filter(
(sku) => sku.estimatedCost === null || sku.estimatedCost <= 0,
)
if (incompleteSkus.length > 0) {
throw new Error(
`成本核算结果不完整:${incompleteSkus.length} 个 SKU 未形成有效预估成本`,
)
}
const invalidLineCostCount = skus
.flatMap((sku) => sku.bomLines)
.filter((line) => (
line.subtotal !== null &&
(!Number.isFinite(line.subtotal) || line.subtotal <= 0)
))
.length
if (invalidLineCostCount > 0) {
throw new Error(
`成本可信度校验未通过:${invalidLineCostCount} 条 BOM 成本不是有效正数,系统未将错误成本标记为完成`,
)
}
const implausibleSkus = skus.filter((sku) => {
if (sku.estimatedCost === null || !sku.attributes || typeof sku.attributes !== 'object' || Array.isArray(sku.attributes)) {
return false
}
const attributes = sku.attributes as Record<string, unknown>
const price = readMarketPrice(
attributes.marketPriceRaw ?? attributes.marketOriginalPriceRaw,
)
return price !== null && sku.estimatedCost > price + 0.01
})
if (implausibleSkus.length > 0) {
throw new Error(
`成本可信度校验未通过:${implausibleSkus.length} 个 SKU 的预估成本高于竞品平台售价,系统未将错误成本标记为完成`,
)
}
}
export function assertBomCostingResultQuality(input: {
analysis: CompetitorAnalysisResultV1
sourceSnapshot: MarketDetailSnapshot | null
preparedSkus: BomCostingPreparedSku[]
}) {
assertPreparedCostRange(input.preparedSkus)
if (input.sourceSnapshot) {
assertDistinctSourceSkuStructures(input.analysis, input.sourceSnapshot)
}
}

View File

@ -418,8 +418,11 @@ describe('BomCostingService', () => {
progress: 100,
statusMessage: '已完成',
sourceCacheHit: false,
analysisSchemaVersion: 'competitor-analysis/v1',
analysisRuns: [],
sourceSnapshot: {
status: 'READY',
skuCount: 10,
imageCount: 1,
storedImageCount: 1,
failedImageCount: 0,
@ -461,6 +464,10 @@ describe('BomCostingService', () => {
const [summary] = await bomCostingService.listCases(operationsUser)
expect(summary.estimatedCostRange).toBeNull()
expect(summary).toMatchObject({
sourceSkuCount: 10,
requiresRecalculation: true,
estimatedCostRange: null,
})
})
})

View File

@ -11,6 +11,7 @@ import {
} from '@prisma/client'
import { nanoid } from 'nanoid'
import {
BOM_COSTING_RESULT_RULESET_VERSION,
BOM_COSTING_TARGET_ERROR_PERCENT,
type RecalculateBomCostingCaseInput,
type UpdateBomCostingLineInput,
@ -71,6 +72,42 @@ function isLegacyPackagingPriceSource(input: {
)
}
function getBomCostingResultQuality(input: {
status: BomCostingCaseStatus
analysisSchemaVersion: string
analysisRuns?: Array<{ revision: number; status: string }>
skus: Array<{
bomLines: Array<{
category: BomCostingLineCategory
matchStatus?: BomCostingMatchStatus
matchedLibraryType: string
}>
}>
}) {
const latestRun = input.analysisRuns?.[0] ?? null
const requiresRecalculation =
input.status === BomCostingCaseStatus.READY &&
input.analysisSchemaVersion !== BOM_COSTING_RESULT_RULESET_VERSION
const hasFailedRecalculation = Boolean(
input.status === BomCostingCaseStatus.READY &&
latestRun?.revision &&
latestRun.revision > 1 &&
latestRun.status === 'FAILED'
)
const needsPriceReview =
input.status === BomCostingCaseStatus.READY &&
input.skus.some((sku) => sku.bomLines.some((line) => (
isLegacyPackagingPriceSource(line) ||
line.matchStatus !== BomCostingMatchStatus.MATCHED
)))
return {
requiresRecalculation,
hasFailedRecalculation,
needsPriceReview,
}
}
function resolveManualPricingMode(priceUnit: string) {
const normalized = priceUnit.trim().toLowerCase()
if (['㎡', 'm²', 'm2', '平方米'].includes(normalized)) return 'AREA_SQM' as const
@ -168,6 +205,12 @@ function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLine
}
function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
const resultQuality = getBomCostingResultQuality({
status: record.status,
analysisSchemaVersion: record.analysisSchemaVersion,
analysisRuns: record.analysisRuns,
skus: record.skus,
})
return {
id: record.id,
sourceUrl: record.sourceUrl,
@ -179,6 +222,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
status: record.status,
progress: record.progress,
statusMessage: record.statusMessage,
...resultQuality,
targetErrorPercent: decimalToNumber(record.targetErrorPercent) ?? BOM_COSTING_TARGET_ERROR_PERCENT,
currency: record.currency,
createdById: record.createdById,
@ -328,7 +372,14 @@ export class BomCostingService {
createdById: bomCostingAccessService.canViewAllCases(user) ? undefined : user.id,
limit: 50,
})
return records.map((record) => ({
return records.map((record) => {
const resultQuality = getBomCostingResultQuality({
status: record.status,
analysisSchemaVersion: record.analysisSchemaVersion,
analysisRuns: record.analysisRuns,
skus: record.skus,
})
return {
id: record.id,
title: record.title,
sourceUrl: record.sourceUrl,
@ -336,13 +387,17 @@ export class BomCostingService {
status: record.status,
progress: record.progress,
statusMessage: record.statusMessage,
...resultQuality,
sourceCacheHit: record.sourceCacheHit,
sourceSkuCount: record.sourceSnapshot?.skuCount ?? 0,
storedImageCount: record.sourceSnapshot?.storedImageCount ?? 0,
failedImageCount: record.sourceSnapshot?.failedImageCount ?? 0,
createdById: record.createdById,
createdAt: record.createdAt.toISOString(),
skuCount: record.skus.length,
estimatedCostRange: record.skus.reduce<{ min: number; max: number } | null>(
estimatedCostRange: resultQuality.requiresRecalculation
? null
: record.skus.reduce<{ min: number; max: number } | null>(
(range, sku) => {
const summary = summarizeBomCosting(
sku.bomLines.map((line) => ({
@ -365,7 +420,8 @@ export class BomCostingService {
},
null,
),
}))
}
})
}
async getCase(caseId: string, user: AuthUser) {
@ -432,6 +488,7 @@ export class BomCostingService {
title: sourceSnapshot?.title || parsedUrl.hostname,
providerCode: provider.code,
providerTaskId,
analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,
statusMessage: sourceCacheHit
? '已找到归档资料,等待多模态分析'
: '核算任务已创建,等待读取竞品资料',