merge: release BOM SKU intake repair

This commit is contained in:
WuYongJie 2026-08-04 17:49:56 +08:00
commit ab2b64c0d4
7 changed files with 362 additions and 129 deletions

View File

@ -1227,13 +1227,29 @@ describe('BomCostingWorkbenchClient', () => {
'flex-1',
'overflow-y-auto',
)
const costExplanation = screen.getByText(
'含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。',
)
expect(costExplanation.closest('[role="tooltip"]')).toHaveClass('sr-only')
fireEvent.click(
screen.getByRole('button', { name: '查看预计出厂成本说明' }),
)
expect(costExplanation.closest('[role="tooltip"]')).not.toHaveClass('sr-only')
expect(
screen.getByRole('button', { name: '开始重新核算' }),
).toBeEnabled()
expect(
screen.getByText('按现有事实重算').closest('[data-motion="reveal"]'),
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /补充分析图片/ }))
const reuseFactsExplanation = screen.getByText(
'保留逐图分析结果,重新汇总结构、BOM 与资料库价格。',
)
expect(reuseFactsExplanation.closest('[role="tooltip"]')).toHaveClass('sr-only')
fireEvent.click(
screen.getByRole('button', { name: '查看按现有事实重算说明' }),
)
expect(reuseFactsExplanation.closest('[role="tooltip"]')).not.toHaveClass('sr-only')
fireEvent.click(screen.getByRole('button', { name: '补充分析图片' }))
fireEvent.click(
screen.getByRole('button', { name: '选择商品详情图 1进行复查' }),
)

View File

@ -5,9 +5,11 @@ import {
Fragment,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import {
Bot,
@ -219,6 +221,70 @@ function formatMoney(value: number | null | undefined) {
})}`
}
function BomInfoHint({
label,
children,
inverse = false,
align = 'start',
side = 'bottom',
className,
}: {
label: string
children: ReactNode
inverse?: boolean
align?: 'start' | 'end'
side?: 'top' | 'bottom'
className?: string
}) {
const [open, setOpen] = useState(false)
const tooltipId = useId()
return (
<span
className={cn('relative inline-flex shrink-0', className)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<button
type="button"
aria-label={label}
aria-describedby={tooltipId}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setOpen((current) => !current)
}}
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
className={cn(
'inline-flex h-4 w-4 items-center justify-center rounded-full outline-none transition focus-visible:ring-2 focus-visible:ring-offset-1',
inverse
? 'text-white/70 hover:bg-white/[0.12] hover:text-white focus-visible:ring-white/75 focus-visible:ring-offset-[#2d3335]'
: 'text-[#778184] hover:bg-[#edf1f1] hover:text-[#40585d] focus-visible:ring-[#40585d] focus-visible:ring-offset-white',
)}
>
<CircleAlert size={12} strokeWidth={2.2} aria-hidden="true" />
</button>
<span
id={tooltipId}
role="tooltip"
className={cn(
open
? 'absolute z-50 w-64 max-w-[calc(100vw-40px)] rounded-[6px] border border-black/[0.12] bg-[#273134] px-2.5 py-2 text-[10px] leading-4 text-white shadow-[0_12px_28px_rgba(45,51,53,0.18)]'
: 'sr-only',
open && (align === 'end' ? 'right-0' : 'left-0'),
open && (side === 'top' ? 'bottom-[calc(100%+6px)]' : 'top-[calc(100%+6px)]'),
)}
>
{children}
</span>
</span>
)
}
function formatMetric(value: number | null, suffix = '') {
return value === null ? '--' : `${value.toLocaleString('zh-CN')} ${suffix}`.trim()
}
@ -1606,52 +1672,57 @@ function ResultCanvas({
</div>
<section className="mt-5 bg-[#2d3335] px-4 py-4 text-white">
<p className="text-[10px] font-semibold text-white/70">预计出厂成本</p>
<div className="flex items-center gap-1">
<p className="text-[10px] font-semibold text-white/70">预计出厂成本</p>
<BomInfoHint
inverse
label="查看预计出厂成本说明"
>
{selectedSku.estimatedCost === null
? '当前旧结果未形成完整估价;按现有事实重新核算即可,无需重新分析图片。'
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。'}
</BomInfoHint>
</div>
<div className="mt-1 text-[30px] font-black leading-none tabular-nums">
{selectedSku.estimatedCost === null
? '待重算'
: `约 ${formatMoney(selectedSku.estimatedCost)}`}
</div>
<p className="mt-2 text-[10px] leading-4 text-white/68">
{selectedSku.estimatedCost === null
? '当前旧结果未形成完整估价;按现有事实重新核算即可,无需重新分析图片'
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用'}
</p>
</section>
<section className="border-b border-black/[0.07] py-4">
<div className="grid grid-cols-2 gap-x-5 gap-y-2 text-[10px]">
{costSummaryRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-2">
<span className="text-[#747c7e]">{row.label}</span>
<span
className={cn(
'font-bold',
row.unresolved && 'text-[#8a5f3f]',
)}
>
{row.missing
? '待重算'
: row.unresolved
? '暂缺'
: formatMoney(row.value)}
</span>
</div>
))}
<div className="relative pr-5">
<div className="grid grid-cols-2 gap-x-5 gap-y-2 text-[10px]">
{costSummaryRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-2">
<span className="text-[#747c7e]">{row.label}</span>
<span
className={cn(
'font-bold',
row.unresolved && 'text-[#8a5f3f]',
)}
>
{row.missing
? '待重算'
: row.unresolved
? '暂缺'
: formatMoney(row.value)}
</span>
</div>
))}
</div>
<BomInfoHint
label="查看核算口径与精度说明"
align="end"
className="absolute right-0 top-0"
>
{incompleteCostMessage
? `${incompleteCostMessage},按现有事实重新核算可补齐。`
: approximateCostLineCount > 0
? `${approximateCostLineCount} 项采用资料库近似价或模型估价,可选复核以提高精度。`
: `误差 ≤${detail.targetErrorPercent}% 是核算目标,需经真实采购成本回测验证。`}
</BomInfoHint>
</div>
{incompleteCostMessage ? (
<p className="mt-3 text-[10px] font-semibold text-[#9a6941]">
{incompleteCostMessage},按现有事实重新核算可补齐
</p>
) : approximateCostLineCount > 0 ? (
<p className="mt-3 text-[9px] leading-4 text-[#687779]">
{approximateCostLineCount} 项采用资料库近似价或模型估价,可选复核以提高精度
</p>
) : (
<p className="mt-3 text-[9px] leading-4 text-[#7a8385]">
误差 ≤{detail.targetErrorPercent}% 是核算目标,需经真实采购成本回测验证
</p>
)}
</section>
<details
@ -1703,36 +1774,36 @@ function ResultCanvas({
))
) : (
<p className="text-[10px] leading-4 text-[#858e90]">
选择 BOM 行右侧的铅笔按钮,记录人工测算值后会显示在这里。
暂无人工测算留档。
</p>
)}
</div>
</details>
<section className="border-b border-black/[0.07] py-4">
<button
type="button"
onClick={() => setRecalculationOpen((open) => !open)}
aria-expanded={recalculationOpen}
className="flex w-full items-center justify-between gap-3 rounded-[5px] px-1 py-1 text-left outline-none transition hover:bg-[#f2f4f4] focus-visible:ring-2 focus-visible:ring-[#40585d]"
>
<span className="flex min-w-0 items-center gap-2">
<RefreshCw size={14} className="shrink-0 text-[#52676b]" />
<span>
<span className="block text-[11px] font-bold">重新核算</span>
<span className="mt-0.5 block text-[9px] text-[#798284]">
先选调用范围,再生成新版本
</span>
</span>
</span>
<BomGsapRotate
active={recalculationOpen}
degrees={180}
className="text-[#7c8587]"
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setRecalculationOpen((open) => !open)}
aria-expanded={recalculationOpen}
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-[5px] px-1 py-1 text-left outline-none transition hover:bg-[#f2f4f4] focus-visible:ring-2 focus-visible:ring-[#40585d]"
>
<ChevronDown size={13} />
</BomGsapRotate>
</button>
<span className="flex min-w-0 items-center gap-2">
<RefreshCw size={14} className="shrink-0 text-[#52676b]" />
<span className="block text-[11px] font-bold">重新核算</span>
</span>
<BomGsapRotate
active={recalculationOpen}
degrees={180}
className="text-[#7c8587]"
>
<ChevronDown size={13} />
</BomGsapRotate>
</button>
<BomInfoHint label="查看重新核算说明" align="end">
先选调用范围,再生成新版本。
</BomInfoHint>
</div>
{failedRecalculationRun ? (
<div className="mt-2 flex items-start gap-2 bg-[#f8eeee] px-2.5 py-2 text-[9px] leading-4 text-[#8e4c48]">
@ -1760,48 +1831,53 @@ function ResultCanvas({
{RECALCULATION_OPTIONS.map((option) => {
const active = option.value === recalculationMode
return (
<button
key={option.value}
type="button"
onClick={() => {
setRecalculationMode(option.value)
if (option.value !== 'REANALYZE_SELECTED') {
setSelectedReanalysisAssetIds([])
}
}}
aria-pressed={active}
className={cn(
'w-full rounded-[5px] border px-2.5 py-2 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-[#40585d]',
active
? 'border-[#526a70]/45 bg-[#eef3f2]'
: 'border-black/[0.07] hover:bg-[#f6f7f7]',
)}
>
<span className="flex items-start gap-2">
<span
className={cn(
'mt-0.5 flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border',
active
? 'border-[#40585d] bg-[#40585d]'
: 'border-black/25 bg-white',
)}
aria-hidden="true"
>
<span className="h-1.5 w-1.5 rounded-full bg-white" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-bold">
{option.label}
<div key={option.value} className="relative">
<button
type="button"
onClick={() => {
setRecalculationMode(option.value)
if (option.value !== 'REANALYZE_SELECTED') {
setSelectedReanalysisAssetIds([])
}
}}
aria-pressed={active}
className={cn(
'w-full rounded-[5px] border px-2.5 py-2 pr-8 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-[#40585d]',
active
? 'border-[#526a70]/45 bg-[#eef3f2]'
: 'border-black/[0.07] hover:bg-[#f6f7f7]',
)}
>
<span className="flex items-start gap-2">
<span
className={cn(
'mt-0.5 flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border',
active
? 'border-[#40585d] bg-[#40585d]'
: 'border-black/25 bg-white',
)}
aria-hidden="true"
>
<span className="h-1.5 w-1.5 rounded-full bg-white" />
</span>
<span className="mt-0.5 block text-[9px] leading-4 text-[#737d7f]">
{option.description}
</span>
<span className="mt-1 block text-[8px] font-semibold text-[#52676b]">
{option.scope}
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-bold">
{option.label}
</span>
</span>
</span>
</span>
</button>
</button>
<BomInfoHint
label={`查看${option.label}说明`}
align="end"
className="absolute right-2.5 top-2.5 z-10"
>
<span className="block">{option.description}</span>
<span className="mt-1 block font-semibold text-white/70">
{option.scope}
</span>
</BomInfoHint>
</div>
)
})}
</div>
@ -1811,9 +1887,9 @@ function ResultCanvas({
className="flex items-center justify-between gap-3 bg-[#f4f6f5] px-2.5 py-2 text-[9px]"
role="status"
>
<span className="text-[#667174]">
点击左侧缩略图或下方详情图选择
</span>
<BomInfoHint label="查看图片选择方法">
点击左侧缩略图或下方详情图选择。
</BomInfoHint>
<span className="shrink-0 font-black text-[#40585d]">
已选 {selectedReanalysisAssetIds.length} 张
</span>

View File

@ -733,4 +733,23 @@ describe('BOM 逐图事实提取', () => {
expect(output.snapshot.imageFacts[0]?.externalSkuIds).toHaveLength(552)
expect(() => bomCostingSourceFactSnapshotSchema.parse(output.snapshot)).not.toThrow()
})
it('图片展示标签始终受长度约束,避免异常上游编号阻断逐图分析', () => {
const oversizedSkuId = `sku-${'x'.repeat(300)}`
const images = buildBomCostingMultimodalImages({
...snapshot,
mainImages: [],
detailImages: [],
skuImages: [],
skus: [{
...snapshot.skus[0]!,
externalSkuId: oversizedSkuId,
imageUrl: 'https://img.example.com/oversized-sku.jpg',
}],
})
expect(images).toHaveLength(1)
expect(images[0]?.label).toHaveLength(240)
expect(images[0]?.label.endsWith('…')).toBe(true)
})
})

View File

@ -137,6 +137,13 @@ function uniqueStrings(values: string[]) {
const MAX_EXPLICIT_SKU_IDS_PER_IMAGE = 20
const MAX_BATCH_SKU_TRUTH_ROWS = 80
const MAX_IMAGE_LABEL_LENGTH = 240
function compactImageLabel(value: string) {
const normalized = value.trim().replace(/\s+/g, ' ')
if (normalized.length <= MAX_IMAGE_LABEL_LENGTH) return normalized
return `${normalized.slice(0, MAX_IMAGE_LABEL_LENGTH - 1)}…`
}
function buildSkuIdsByImage(skus: MarketSkuSnapshot[]) {
const result = new Map<string, string[]>()
@ -150,7 +157,7 @@ function buildSkuIdsByImage(skus: MarketSkuSnapshot[]) {
}
function buildSkuImageLabel(skuIds: string[], index?: number) {
if (skuIds.length === 1) return `SKU 图:${skuIds[0]}`
if (skuIds.length === 1) return compactImageLabel(`SKU 图:${skuIds[0]}`)
if (skuIds.length > 1) {
return `SKU 图(关联 ${skuIds.length} 个平台规格组合)`
}
@ -185,7 +192,7 @@ export function buildBomCostingMultimodalImages(
}
entries.set(url, {
kind,
label,
label: compactImageLabel(label),
url,
externalSkuIds: uniqueStrings(externalSkuIds),
})

View File

@ -30,6 +30,7 @@ import { getCompetitorAnalysisProvider } from './bom-costing-provider'
import {
isLegacyMarketSkuSnapshotPossiblyTruncated,
marketDetailClient,
normalizeMarketSkuExternalId,
type MarketDetailSnapshot,
} from './market-detail-client'
@ -112,6 +113,16 @@ function readStringArray(value: Prisma.JsonValue) {
: []
}
function normalizeArchivedSkuIds(value: Prisma.JsonValue) {
return Array.from(
new Set(
readStringArray(value)
.map(normalizeMarketSkuExternalId)
.filter(Boolean),
),
)
}
function buildResultStats(
skus: ReturnType<typeof prepareBomCostingAnalysis>,
) {
@ -169,14 +180,9 @@ function replaceAnalysisImageUrlsWithStableArchive(
if (asset.storageKey) {
stableUrlByStorageKey.set(asset.storageKey, asset.storedUrl)
}
if (!Array.isArray(asset.externalSkuIds)) continue
for (const value of asset.externalSkuIds) {
if (
typeof value === 'string' &&
value &&
!stableSkuImageById.has(value)
) {
stableSkuImageById.set(value, asset.storedUrl)
for (const externalSkuId of normalizeArchivedSkuIds(asset.externalSkuIds)) {
if (!stableSkuImageById.has(externalSkuId)) {
stableSkuImageById.set(externalSkuId, asset.storedUrl)
}
}
}
@ -228,10 +234,8 @@ function buildAnalysisImagePlan(
assetId: asset?.id ?? null,
kind: image.kind,
label: image.label,
externalSkuIds: Array.isArray(asset?.externalSkuIds)
? asset.externalSkuIds.filter(
(value): value is string => typeof value === 'string',
)
externalSkuIds: asset
? normalizeArchivedSkuIds(asset.externalSkuIds)
: [],
}
})

View File

@ -4,6 +4,7 @@ import { AppError, ErrorCode } from '@/server/core/errors'
import {
isLegacyMarketSkuSnapshotPossiblyTruncated,
MarketDetailClient,
marketDetailSnapshotSchema,
parseMarketProductIdentity,
} from './market-detail-client'
@ -145,6 +146,77 @@ describe('市场商品详情客户端', () => {
expect(endpoint.searchParams.get('sku_limit')).toBe('1000')
})
it('上游把长描述误放进 SKU 编号时改用短键并压缩展示名称', async () => {
process.env.BOM_COSTING_MARKET_API_BASE_URL = 'https://market.example.com'
process.env.BOM_COSTING_MARKET_API_KEY = 'test-key'
const oversizedSkuId = `sku-${'x'.repeat(300)}`
jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({
success: true,
data: {
itemId: '902511696710',
channel: 'taobao',
title: '测试商品',
detailUrl: 'https://item.taobao.com/item.htm?id=902511696710',
mainImages: ['https://img.example.com/main.jpg'],
detailImages: [],
skuImages: [],
sku: {
totalRows: 1,
returnedRows: 1,
truncated: false,
rows: [{
skuId: oversizedSkuId,
skuKey: 'sku-key-1',
skuName: oversizedSkuId,
}],
},
},
}), { status: 200 }),
)
const result = await new MarketDetailClient().readProduct(
'https://item.taobao.com/item.htm?id=902511696710',
)
expect(result.skus[0]).toMatchObject({ externalSkuId: 'sku-key-1' })
expect(result.skus[0]?.name).toHaveLength(240)
expect(result.skus[0]?.name.endsWith('…')).toBe(true)
})
it('读取历史快照时为超长 SKU 编号生成稳定的短标识', () => {
const oversizedSkuId = `sku-${'x'.repeat(300)}`
const legacySnapshot = {
itemId: '902511696710',
channel: 'taobao',
title: '测试商品',
shopName: null,
detailUrl: 'https://item.taobao.com/item.htm?id=902511696710',
mainImages: ['https://img.example.com/main.jpg'],
detailImages: [],
skuImages: [],
skus: [{
externalSkuId: oversizedSkuId,
name: oversizedSkuId,
size: null,
imageUrl: null,
attributes: {},
priceRaw: null,
originalPriceRaw: null,
quantity: null,
}],
requestId: 'legacy-request',
}
const first = marketDetailSnapshotSchema.parse(legacySnapshot)
const second = marketDetailSnapshotSchema.parse(legacySnapshot)
expect(first.skus[0]?.externalSkuId).toMatch(/^upstream-sku-[a-f0-9]{32}$/)
expect(first.skus[0]?.externalSkuId).toBe(second.skus[0]?.externalSkuId)
expect(first.skus[0]?.externalSkuId.length).toBeLessThanOrEqual(160)
expect(first.skus[0]?.name).toHaveLength(240)
})
it('上游明确返回截断 SKU 时停止核算,不把部分数据当成完整真相', async () => {
process.env.BOM_COSTING_MARKET_API_BASE_URL = 'https://market.example.com'
process.env.BOM_COSTING_MARKET_API_KEY = 'test-key'

View File

@ -1,5 +1,6 @@
import 'server-only'
import { createHash } from 'node:crypto'
import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
@ -37,6 +38,42 @@ export interface MarketProductIdentity {
channel: MarketChannel
}
const MAX_MARKET_SKU_ID_LENGTH = 160
const MAX_MARKET_SKU_NAME_LENGTH = 240
function compactMarketText(value: string, maximum: number) {
const normalized = value.trim().replace(/\s+/g, ' ')
if (normalized.length <= maximum) return normalized
return `${normalized.slice(0, Math.max(0, maximum - 1))}…`
}
export function normalizeMarketSkuExternalId(value: string) {
const normalized = value.trim()
if (normalized.length <= MAX_MARKET_SKU_ID_LENGTH) return normalized
return `upstream-sku-${createHash('sha256')
.update(normalized)
.digest('hex')
.slice(0, 32)}`
}
const marketSkuSnapshotSchema = z.object({
externalSkuId: z.string().trim().min(1),
name: z.string(),
size: z.string().nullable(),
imageUrl: z.string().url().nullable(),
attributes: z.record(z.string(), z.string()),
priceRaw: z.string().nullable(),
originalPriceRaw: z.string().nullable(),
quantity: z.number().nullable(),
}).transform((sku) => {
const externalSkuId = normalizeMarketSkuExternalId(sku.externalSkuId)
return {
...sku,
externalSkuId,
name: compactMarketText(sku.name || externalSkuId, MAX_MARKET_SKU_NAME_LENGTH),
}
})
export const marketDetailSnapshotSchema = z.object({
itemId: z.string().trim().min(1),
channel: z.enum(['taobao', '1688', 'douyin', 'jd']),
@ -46,16 +83,7 @@ export const marketDetailSnapshotSchema = z.object({
mainImages: z.array(z.string().url()),
detailImages: z.array(z.string().url()),
skuImages: z.array(z.string().url()),
skus: z.array(z.object({
externalSkuId: z.string().trim().min(1),
name: z.string(),
size: z.string().nullable(),
imageUrl: z.string().url().nullable(),
attributes: z.record(z.string(), z.string()),
priceRaw: z.string().nullable(),
originalPriceRaw: z.string().nullable(),
quantity: z.number().nullable(),
})).min(1),
skus: z.array(marketSkuSnapshotSchema).min(1),
skuTotalRows: z.number().int().nonnegative().nullable().optional().default(null),
skuReturnedRows: z.number().int().nonnegative().nullable().optional().default(null),
skuTruncated: z.boolean().nullable().optional().default(null),
@ -218,6 +246,17 @@ function toAttributes(
return result
}
function readExternalSkuId(row: z.infer<typeof upstreamSkuRowSchema>) {
const candidates = [row.skuId, row.skuKey]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value))
const usableId = candidates.find(
(value) => value.length <= MAX_MARKET_SKU_ID_LENGTH,
)
if (usableId) return usableId
return candidates[0] ? normalizeMarketSkuExternalId(candidates[0]) : null
}
function normalizeSkuRows(
rows: z.infer<typeof upstreamSkuRowSchema>[],
): MarketSkuSnapshot[] {
@ -225,7 +264,7 @@ function normalizeSkuRows(
const result: MarketSkuSnapshot[] = []
for (const row of rows) {
const externalSkuId = row.skuId?.trim() || row.skuKey?.trim()
const externalSkuId = readExternalSkuId(row)
if (!externalSkuId || seen.has(externalSkuId)) continue
seen.add(externalSkuId)
@ -237,7 +276,7 @@ function normalizeSkuRows(
result.push({
externalSkuId,
name,
name: compactMarketText(name, MAX_MARKET_SKU_NAME_LENGTH),
size: row.skuSize?.trim() || null,
imageUrl: normalizeHttpUrl(row.skuImageUrl),
attributes,