merge: release BOM SKU intake repair
This commit is contained in:
commit
ab2b64c0d4
@ -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进行复查' }),
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user