fix(bom): guard historical and invalid costing results
This commit is contained in:
parent
f1dc44376a
commit
3d18a89e55
@ -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)}
|
||||
|
||||
@ -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: '继续处理' }))
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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('预估成本高于竞品平台售价')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,44 +372,56 @@ export class BomCostingService {
|
||||
createdById: bomCostingAccessService.canViewAllCases(user) ? undefined : user.id,
|
||||
limit: 50,
|
||||
})
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
sourceUrl: record.sourceUrl,
|
||||
sourceHost: record.sourceHost,
|
||||
status: record.status,
|
||||
progress: record.progress,
|
||||
statusMessage: record.statusMessage,
|
||||
sourceCacheHit: record.sourceCacheHit,
|
||||
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>(
|
||||
(range, sku) => {
|
||||
const summary = summarizeBomCosting(
|
||||
sku.bomLines.map((line) => ({
|
||||
category: line.category,
|
||||
subtotal: isLegacyPackagingPriceSource(line)
|
||||
? null
|
||||
: decimalToNumber(line.subtotal),
|
||||
})),
|
||||
)
|
||||
if (
|
||||
summary.unresolvedLineCount > 0 ||
|
||||
summary.missingCostGroups.length > 0
|
||||
) {
|
||||
return range
|
||||
}
|
||||
const value = summary.lossIncludedTotal
|
||||
return range
|
||||
? { min: Math.min(range.min, value), max: Math.max(range.max, value) }
|
||||
: { min: value, max: value }
|
||||
},
|
||||
null,
|
||||
),
|
||||
}))
|
||||
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,
|
||||
sourceHost: record.sourceHost,
|
||||
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: resultQuality.requiresRecalculation
|
||||
? null
|
||||
: record.skus.reduce<{ min: number; max: number } | null>(
|
||||
(range, sku) => {
|
||||
const summary = summarizeBomCosting(
|
||||
sku.bomLines.map((line) => ({
|
||||
category: line.category,
|
||||
subtotal: isLegacyPackagingPriceSource(line)
|
||||
? null
|
||||
: decimalToNumber(line.subtotal),
|
||||
})),
|
||||
)
|
||||
if (
|
||||
summary.unresolvedLineCount > 0 ||
|
||||
summary.missingCostGroups.length > 0
|
||||
) {
|
||||
return range
|
||||
}
|
||||
const value = summary.lossIncludedTotal
|
||||
return range
|
||||
? { min: Math.min(range.min, value), max: Math.max(range.max, value) }
|
||||
: { min: value, max: value }
|
||||
},
|
||||
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
|
||||
? '已找到归档资料,等待多模态分析'
|
||||
: '核算任务已创建,等待读取竞品资料',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user