diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx index dad1c398..79ee2522 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -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进行复查' }), ) diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index b1999b42..3d333ccf 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -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 ( + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + + + {children} + + + ) +} + function formatMetric(value: number | null, suffix = '') { return value === null ? '--' : `${value.toLocaleString('zh-CN')} ${suffix}`.trim() } @@ -1606,52 +1672,57 @@ function ResultCanvas({
-

预计出厂成本

+
+

预计出厂成本

+ + {selectedSku.estimatedCost === null + ? '当前旧结果未形成完整估价;按现有事实重新核算即可,无需重新分析图片。' + : '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用。'} + +
{selectedSku.estimatedCost === null ? '待重算' : `约 ${formatMoney(selectedSku.estimatedCost)}`}
-

- {selectedSku.estimatedCost === null - ? '当前旧结果未形成完整估价;按现有事实重新核算即可,无需重新分析图片' - : '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用'} -

-
- {costSummaryRows.map((row) => ( -
- {row.label} - - {row.missing - ? '待重算' - : row.unresolved - ? '暂缺' - : formatMoney(row.value)} - -
- ))} +
+
+ {costSummaryRows.map((row) => ( +
+ {row.label} + + {row.missing + ? '待重算' + : row.unresolved + ? '暂缺' + : formatMoney(row.value)} + +
+ ))} +
+ + {incompleteCostMessage + ? `${incompleteCostMessage},按现有事实重新核算可补齐。` + : approximateCostLineCount > 0 + ? `${approximateCostLineCount} 项采用资料库近似价或模型估价,可选复核以提高精度。` + : `误差 ≤${detail.targetErrorPercent}% 是核算目标,需经真实采购成本回测验证。`} +
- {incompleteCostMessage ? ( -

- {incompleteCostMessage},按现有事实重新核算可补齐 -

- ) : approximateCostLineCount > 0 ? ( -

- {approximateCostLineCount} 项采用资料库近似价或模型估价,可选复核以提高精度 -

- ) : ( -

- 误差 ≤{detail.targetErrorPercent}% 是核算目标,需经真实采购成本回测验证 -

- )}
- 选择 BOM 行右侧的铅笔按钮,记录人工测算值后会显示在这里。 + 暂无人工测算留档。

)}
- + + + 重新核算 + + + + + + + 先选调用范围,再生成新版本。 + + {failedRecalculationRun ? (
@@ -1760,48 +1831,53 @@ function ResultCanvas({ {RECALCULATION_OPTIONS.map((option) => { const active = option.value === recalculationMode return ( - + + + {option.description} + + {option.scope} + + +
) })} @@ -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" > - - 点击左侧缩略图或下方详情图选择 - + + 点击左侧缩略图或下方详情图选择。 + 已选 {selectedReanalysisAssetIds.length} 张 diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts index 773802a6..bdf114d2 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts @@ -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) + }) }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts index f6a33ea4..55339687 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts @@ -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() @@ -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), }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts index d23e4306..1b5f8961 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts @@ -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, ) { @@ -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) : [], } }) diff --git a/furniture-3d-system/src/server/services/bom-costing/market-detail-client.test.ts b/furniture-3d-system/src/server/services/bom-costing/market-detail-client.test.ts index 11ac74be..907f24d7 100644 --- a/furniture-3d-system/src/server/services/bom-costing/market-detail-client.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/market-detail-client.test.ts @@ -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' diff --git a/furniture-3d-system/src/server/services/bom-costing/market-detail-client.ts b/furniture-3d-system/src/server/services/bom-costing/market-detail-client.ts index 5190a562..db29c508 100644 --- a/furniture-3d-system/src/server/services/bom-costing/market-detail-client.ts +++ b/furniture-3d-system/src/server/services/bom-costing/market-detail-client.ts @@ -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) { + 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[], ): 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,