Merge latest shared mainline before supply-chain timing release

This commit is contained in:
jingchuan 2026-08-18 14:59:34 +08:00
commit df3a8a7894
24 changed files with 2438 additions and 1765 deletions

View File

@ -135,6 +135,20 @@ BOM_COSTING_AI_MAX_TOKENS=12000
BOM_COSTING_AI_REASONING_EFFORT=low
BOM_COSTING_MODELING_PROVIDER=
# BOM 核算实时同款采购预估(1688 官方图搜)。
# 网关固定为 https://skills-gateway.1688.com/api/find_product/1.0.0。
# AK 只放服务器私密环境变量,服务端会按 x-csk-* 协议签名;实时同款价 × 采购系数
# 是整件采购预估,历史 BOM 仅保留为结构参考。不要把 AK 写入 Git 或 NEXT_PUBLIC_* 变量。
ALIBABA_1688_IMAGE_SEARCH_AK=replace-with-1688-image-search-ak
# 仅在官方网关地址调整时设置;系统会拒绝非官方 HTTPS 地址。
ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL=https://skills-gateway.1688.com/api/find_product/1.0.0
ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS=45000
ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY=0.78
ALIBABA_1688_PROCUREMENT_RATE=0.90
ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE=10
ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT=1
ALIBABA_1688_IMAGE_SEARCH_TAGS=4306497
# 素材库白底图回传
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL=https://example.com

View File

@ -0,0 +1,5 @@
-- Retain the auditable realtime 1688 search result with every scenario
-- revision. It is a whole-product procurement signal, not a line-level BOM
-- price, therefore it stays as a single JSON snapshot on the scenario.
ALTER TABLE "BomCostingScenario"
ADD COLUMN "realtimeMarketQuote" JSONB;

View File

@ -0,0 +1,5 @@
-- Keeps the server-only audit for whole-SKU agent estimates. Public BOM DTOs
-- deliberately omit this field, so it cannot reveal private model context or
-- internal reference records to browsers or exports.
ALTER TABLE "BomCostingScenario"
ADD COLUMN "wholeProductEstimateAudit" JSONB;

View File

@ -1400,6 +1400,8 @@ model BomCostingScenario {
lossCost Decimal? @db.Decimal(18, 2)
competitorPrice Decimal? @db.Decimal(18, 2)
priceDelta Decimal? @db.Decimal(18, 2)
realtimeMarketQuote Json?
wholeProductEstimateAudit Json?
confidence Decimal? @db.Decimal(5, 4)
warning String @default("") @db.Text
unresolvedCount Int @default(0)

View File

@ -5,8 +5,7 @@ export {}
const mockHandleRequest = jest.fn()
const mockJsonError = jest.fn()
const mockJsonSuccess = jest.fn()
const mockAssertCanModifyCurrentCosting = jest.fn()
const mockCalculate = jest.fn()
const mockGetCase = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
@ -16,15 +15,12 @@ jest.mock('@/server/core/response', () => ({
jest.mock('@/server/services/bom-costing/bom-costing.service', () => ({
bomCostingService: {
assertCanModifyCurrentCosting: (...args: unknown[]) =>
mockAssertCanModifyCurrentCosting(...args),
getCase: (...args: unknown[]) => mockGetCase(...args),
},
}))
jest.mock('@/server/services/bom-costing/bom-costing-scenario.service', () => ({
bomCostingScenarioService: {
calculate: (...args: unknown[]) => mockCalculate(...args),
},
bomCostingScenarioService: {},
}))
const { AppError, ErrorCode } = require('@/server/core/errors')
@ -56,9 +52,8 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => {
}))
})
it('只在当前核算可修改时生成材质替换场景', async () => {
mockAssertCanModifyCurrentCosting.mockResolvedValue({ id: 'case-ready' })
mockCalculate.mockResolvedValue({ scenario: { id: 'scenario-1' } })
it('关闭换材质计算,但仍验证当前用户可以访问目标案例', async () => {
mockGetCase.mockResolvedValue({ id: 'case-ready' })
const request = {
json: async () => ({
skuId: 'sku-1',
@ -70,20 +65,19 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => {
params: Promise.resolve({ id: 'case-ready' }),
})
expect(mockAssertCanModifyCurrentCosting).toHaveBeenCalledWith(
expect(mockGetCase).toHaveBeenCalledWith(
'case-ready',
user,
)
expect(mockCalculate).toHaveBeenCalledWith({
caseId: 'case-ready',
skuId: 'sku-1',
targetMaterialId: 'material-1',
expect(response).toMatchObject({
success: false,
status: 409,
error: { code: ErrorCode.VALIDATION_FAILED, message: expect.stringContaining('换材质预估已暂时关闭') },
})
expect(response).toMatchObject({ success: true, status: 200 })
})
it('旧版或处理中的结果被守卫拒绝时不调用成本引擎', async () => {
mockAssertCanModifyCurrentCosting.mockRejectedValue(
it('没有案例读取权限时维持原有鉴权错误', async () => {
mockGetCase.mockRejectedValue(
new AppError(
ErrorCode.VALIDATION_FAILED,
'当前核算尚未完成,请等待本次处理结束后再调整成本',
@ -105,6 +99,6 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => {
status: 422,
error: { code: ErrorCode.VALIDATION_FAILED },
})
expect(mockCalculate).not.toHaveBeenCalled()
expect(mockGetCase).toHaveBeenCalledWith('case-processing', user)
})
})

View File

@ -1,5 +1,4 @@
import { NextRequest } from 'next/server'
import { bomCostingScenarioCalculationSchema } from '@/lib/bom-costing/contracts'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service'
@ -38,15 +37,14 @@ export async function POST(request: NextRequest, context: RouteContext) {
async (ctx) => {
const { id } = await context.params
const caseId = decodeURIComponent(id)
const body = await request.json().catch(() => null)
const parsed = bomCostingScenarioCalculationSchema.safeParse(body)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_FAILED, '目标材质参数不正确', 400, ctx.requestId)
}
try {
await bomCostingService.assertCanModifyCurrentCosting(caseId, ctx.user)
const result = await bomCostingScenarioService.calculate({ caseId, ...parsed.data })
return jsonSuccess(result, 200, ctx.requestId)
await bomCostingService.getCase(caseId, ctx.user)
return jsonError(
ErrorCode.VALIDATION_FAILED,
'换材质预估已暂时关闭,请使用重新核算更新当前预估结果',
409,
ctx.requestId,
)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)

View File

@ -1,13 +1,37 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import BomCostingWorkbenchClient, {
formatBomCategorySkillVersionHint,
getHistoryStatusDotClass,
getHistoryStatusLabel,
} from './BomCostingWorkbenchClient'
import type { BomCostingCaseSummary } from '@/lib/bom-costing/client-types'
jest.mock('@/components/platform/AssetWorkbenchEmptyBackdrop', () => ({
AssetWorkbenchEmptyBackdrop: () => <div data-testid="empty-backdrop" />,
}))
describe('BomCostingWorkbenchClient', () => {
it('整件预估完成但结构待复核时,历史状态点仍表示完成', () => {
const item: BomCostingCaseSummary = {
id: 'case-structure-review',
title: '结构待复核但预估完成的案例',
status: 'READY',
progress: 100,
statusMessage: '整件预估已生成,结构差异待复核',
needsPriceReview: true,
sourceCacheHit: true,
storedImageCount: 79,
failedImageCount: 0,
createdById: 'user-1',
createdAt: '2026-08-18T00:00:00.000Z',
skuCount: 15,
estimatedCostRange: { min: 432.9, max: 930.38 },
}
expect(getHistoryStatusLabel(item)).toBe('预估完成(结构待复核)')
expect(getHistoryStatusDotClass(item)).toBe('bg-[#3f8a68]')
})
it('旧结果 Skill 与当前版本不同时分别标明,且不把旧结果冒充为新版本', () => {
expect(formatBomCategorySkillVersionHint({
resultVersion: 'bom-category-skill/cabinet-bookshelf/v1',
@ -511,6 +535,100 @@ describe('BomCostingWorkbenchClient', () => {
expect(
screen.getByRole('button', { name: 'SKU 图 2,联合分析中' }),
).toBeInTheDocument()
expect(global.fetch).toHaveBeenCalledTimes(1)
window.dispatchEvent(new Event('focus'))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(2)
})
} finally {
global.fetch = originalFetch
}
})
it('处理时长优先展示整轮核算耗时,而不是模型合并的毫秒耗时', async () => {
const originalFetch = global.fetch
const processingFinishedAt = new Date().toISOString()
const processingStartedAt = new Date(
new Date(processingFinishedAt).getTime() - 86_000,
).toISOString()
const detail = {
id: 'case-duration',
sourceUrl: 'https://item.example.com/item?id=duration',
sourceHost: 'item.example.com',
title: '核算耗时案例',
providerCode: 'market-detail-multimodal',
providerTaskId: 'bom-task-duration',
sourceCacheHit: true,
status: 'ANALYZING',
progress: 97,
statusMessage: 'SKU 结构已就绪,正在生成分层成本预估',
targetErrorPercent: 10,
currency: 'CNY',
createdById: 'user-1',
processingStartedAt,
processingFinishedAt,
createdAt: processingStartedAt,
updatedAt: processingFinishedAt,
sourceSnapshot: null,
skus: [],
analysisRuns: [],
taskEvents: [{
id: 'event-model-started',
eventType: 'MODEL_ANALYSIS_STARTED',
status: 'ANALYZING',
progress: 78,
message: '准备生成结构化 BOM',
providerTaskId: 'bom-task-duration',
payload: { model: 'gpt-5.6-terra', imageCount: 79 },
createdAt: processingStartedAt,
}, {
id: 'event-model-completed',
eventType: 'MODEL_ANALYSIS_COMPLETED',
status: 'ANALYZING',
progress: 97,
message: 'SKU 结构已就绪,正在生成分层成本预估',
providerTaskId: 'bom-task-duration',
payload: { model: 'gpt-5.6-terra', imageCount: 79, durationMs: 10 },
createdAt: processingFinishedAt,
}],
}
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: { case: detail } }),
})) as jest.Mock
try {
render(
<BomCostingWorkbenchClient
initialCapabilities={{
enabled: true,
analysisConfigured: true,
modelingConfigured: false,
analysisProviderCode: 'market-detail-multimodal',
targetErrorPercent: 10,
fixtureAllowed: false,
}}
initialCases={[{
id: detail.id,
title: detail.title,
sourceUrl: detail.sourceUrl,
sourceHost: detail.sourceHost,
status: 'ANALYZING',
progress: 97,
statusMessage: detail.statusMessage,
sourceCacheHit: true,
storedImageCount: 79,
failedImageCount: 0,
createdById: 'user-1',
createdAt: processingStartedAt,
skuCount: 15,
estimatedCostRange: null,
}]}
/>,
)
expect(await screen.findByText('1 分 26 秒')).toBeInTheDocument()
expect(screen.queryByText('0 秒')).not.toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
@ -1371,6 +1489,7 @@ describe('BomCostingWorkbenchClient', () => {
costedSkuCount: 1,
pendingSkuCount: 1,
failedSkuCount: 1,
qualityWarningCount: 1,
succeededSkuIds: ['sku-recalculate', 'sku-pending'],
costedSkuIds: ['sku-recalculate'],
pendingSkuIds: ['sku-pending'],
@ -1576,30 +1695,30 @@ describe('BomCostingWorkbenchClient', () => {
expect(await screen.findByText('预计出厂成本')).toBeInTheDocument()
const dynamicCostNotice = screen.getByTestId('bom-dynamic-cost-partial-notice')
expect(dynamicCostNotice).toHaveTextContent(
'1/3 个 SKU 的原材质预估已完成',
'1/3 个 SKU 的预估已完成',
)
expect(dynamicCostNotice).toHaveTextContent(
'这是原材质批次结果,不代表当前材质替换方案已经完成',
'失败规格 · 当前资料不足,暂无法预估',
)
expect(dynamicCostNotice).toHaveTextContent(
'失败规格 · 事务启动超时',
'结构拆解差异已标记待复核,当前整件预估已保留',
)
expect(screen.getByRole('alert', {
name: '原材质批次部分完成,包含成本生成失败',
name: '本轮批次部分完成,部分 SKU 暂无法预估',
})).toBe(dynamicCostNotice)
const failedSkuButton = screen.getByRole('button', { name: /失败规格/ })
expect(within(failedSkuButton).getByText('生成失败')).toBeInTheDocument()
expect(within(failedSkuButton).getByText('资料不足')).toBeInTheDocument()
fireEvent.click(failedSkuButton)
expect(screen.getAllByText('成本生成失败').length).toBeGreaterThan(0)
expect(screen.getAllByText('资料不足,暂无法预估').length).toBeGreaterThan(0)
expect(screen.getByText(
'本轮该 SKU 成本生成失败:事务启动超时,两次重试仍失败。已保留结构事实,可按现有事实重新核算。',
'该规格暂未形成预估;当前结构与资料已保留。',
)).toBeInTheDocument()
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
expect(screen.queryByText('约 ¥999.00')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /待补价格规格/ }))
expect(screen.getAllByText('待补成本').length).toBeGreaterThan(0)
expect(screen.getByText(
'本轮该 SKU 待补成本:结构事实已保存,但公司价格资料尚未闭合,补齐后再生成完整预估。',
'该规格仍在整理预估依据;当前结构与资料已保留。',
)).toBeInTheDocument()
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
expect(screen.queryByText('约 ¥888.00')).not.toBeInTheDocument()
@ -1675,7 +1794,7 @@ describe('BomCostingWorkbenchClient', () => {
screen.getByText('按现有事实重算').closest('[data-motion="reveal"]'),
).toBeInTheDocument()
const reuseFactsExplanation = screen.getByText(
'只重新汇总结构、BOM 与资料库价格,复用已保存的逐图结果。',
'只重新汇总结构、BOM 与预估结果,复用已保存的逐图结果。',
)
expect(reuseFactsExplanation.closest('[role="tooltip"]')).toHaveClass('sr-only')
fireEvent.click(screen.getByRole('button', { name: '查看按现有事实重算说明' }))
@ -1721,7 +1840,7 @@ describe('BomCostingWorkbenchClient', () => {
}
})
it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => {
it('未匹配结构项不显示零元,并保留中性的预估待补状态', async () => {
const originalFetch = global.fetch
const now = new Date().toISOString()
const unresolvedLine = {
@ -1803,69 +1922,7 @@ describe('BomCostingWorkbenchClient', () => {
}],
taskEvents: [],
}
const resolvedDetail = {
...detail,
skus: [{
...detail.skus[0],
estimatedCost: null,
costCalculatedAt: null,
costSummary: {
material: 51.57,
processing: 83,
labor: 35,
packaging: 0,
lossIncludedTotal: 169.57,
unresolvedLineCount: 0,
missingCostGroups: ['PACKAGING'],
},
bomLines: [{
...unresolvedLine,
heightMm: 18,
matchStatus: 'MATCHED',
matchedLibraryType: 'SUBSTRATE',
matchedLibraryRecordId: 'rubberwood-18',
matchedLibraryName: '18ENF级橡胶木',
matchedLibrarySpec: '18mm · ENF',
resolvedUnitPrice: 80.63,
subtotal: 51.57,
}],
}],
}
global.fetch = jest.fn(async (input, init) => {
const url = String(input)
if (url.endsWith('/lines/line-rubberwood') && init?.method === 'PATCH') {
const patchBody =
typeof init.body === 'string' ? JSON.parse(init.body) : {}
return {
ok: true,
json: async () => ({
success: true,
data: { case: patchBody.review ? detail : resolvedDetail },
}),
} as Response
}
if (url.endsWith('/lines/line-rubberwood')) {
return {
ok: true,
json: async () => ({
success: true,
data: {
candidates: [{
recordId: 'rubberwood-18',
libraryType: 'SUBSTRATE',
code: 'RW-18',
name: '18ENF级橡胶木',
spec: '18mm · ENF',
unit: '㎡',
unitPrice: 80.63,
thicknessMm: 18,
recommended: true,
}],
},
}),
} as Response
}
global.fetch = jest.fn(async () => {
return {
ok: true,
json: async () => ({
@ -1907,83 +1964,20 @@ describe('BomCostingWorkbenchClient', () => {
expect(await screen.findByText('橡胶木桌面板')).toBeInTheDocument()
expect(screen.getByText('SKU 结构已就绪')).toBeInTheDocument()
expect(screen.getAllByText('核算未完成').length).toBeGreaterThan(0)
expect(screen.getAllByText('资料不足,暂无法预估').length).toBeGreaterThan(0)
expect(screen.getByText('缺板厚')).toBeInTheDocument()
expect(screen.getByText('暂缺')).toBeInTheDocument()
expect(screen.getByText('预估待补')).toBeInTheDocument()
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '记录橡胶木桌面板人工复核值' }),
)
expect(screen.getByText('材质与规格')).toBeInTheDocument()
expect(screen.getByText('部件尺寸')).toBeInTheDocument()
expect(screen.getByText('计价参数')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('材质名称'), {
target: { value: '橡胶木实木板' },
})
fireEvent.change(screen.getByLabelText('单价'), {
target: { value: '80' },
})
fireEvent.change(screen.getByLabelText('价格单位'), {
target: { value: '㎡' },
})
fireEvent.click(screen.getByRole('button', { name: '保存并留档' }))
await waitFor(() => {
const reviewPatchCall = (global.fetch as jest.Mock).mock.calls.find(
([url, init]) => {
if (
!String(url).endsWith('/lines/line-rubberwood') ||
init?.method !== 'PATCH' ||
typeof init.body !== 'string'
) {
return false
}
return Boolean(JSON.parse(init.body).review)
},
)
expect(reviewPatchCall).toBeDefined()
expect(JSON.parse(reviewPatchCall?.[1]?.body)).toEqual({
review: expect.objectContaining({
materialName: '橡胶木实木板',
unitPrice: 80,
priceUnit: '㎡',
}),
})
})
fireEvent.click(screen.getByRole('button', { name: /确认价格/ }))
fireEvent.click(
await screen.findByRole('button', { name: /18ENF级橡胶木/ }),
)
await waitFor(() => {
const patchCall = (global.fetch as jest.Mock).mock.calls.find(
([url, init]) => {
if (
!String(url).endsWith('/lines/line-rubberwood') ||
init?.method !== 'PATCH' ||
typeof init.body !== 'string'
) {
return false
}
return JSON.parse(init.body).matchedLibraryRecordId === 'rubberwood-18'
},
)
expect(patchCall).toBeDefined()
expect(JSON.parse(patchCall?.[1]?.body)).toEqual({
matchedLibraryRecordId: 'rubberwood-18',
heightMm: 18,
})
})
expect((await screen.findAllByText('¥51.57')).length).toBeGreaterThan(0)
expect(screen.getByText('18ENF级橡胶木')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /人工复核|确认价格|保存并留档/ })).not.toBeInTheDocument()
expect((global.fetch as jest.Mock).mock.calls.some(([url]) => (
String(url).endsWith('/lines/line-rubberwood')
))).toBe(false)
} finally {
global.fetch = originalFetch
}
})
it('动态成本优先展示,并提交目标材质生成双场景且保留超售价金额', async () => {
it('统一展示同材质预估,单项待补不影响整件完成且不展示换材质入口', async () => {
const originalFetch = global.fetch
const now = new Date().toISOString()
const scenario = (input: {
@ -2002,6 +1996,24 @@ describe('BomCostingWorkbenchClient', () => {
secondarySubtotal?: number
aiSubtotal?: number
rulesetVersion?: string
realtimeMarketQuote?: {
schemaVersion: 'bom-costing-1688-realtime/v1'
source: '1688_IMAGE_SEARCH'
status: 'AVAILABLE'
sourceImageUrl: string
searchedAt: string
procurementRate: number
marketPrice: number
procurementCost: number
estimateRange: { min: number; max: number }
confidence: number
comparableOfferCount: number
returnedOfferCount: number
warnings: string[]
offers: []
structuralCost: number
structuralEstimateRange: { min: number; max: number }
}
}) => ({
id: input.id,
skuId: 'sku-dynamic',
@ -2034,6 +2046,7 @@ describe('BomCostingWorkbenchClient', () => {
syncStatus: 'SUCCEEDED',
syncCompletedAt: now,
rulesetVersion: input.rulesetVersion ?? 'dynamic-bom-cost/v1',
realtimeMarketQuote: input.realtimeMarketQuote ?? null,
createdAt: now,
boardCutting: [{
sourceLineId: 'line-board',
@ -2163,14 +2176,17 @@ describe('BomCostingWorkbenchClient', () => {
kind: 'SAME_MATERIAL',
targetMaterialId: null,
targetMaterialName: '',
status: 'INSUFFICIENT_DATA',
totalCost: null,
knownCost: 500,
status: 'PROVISIONAL',
totalCost: 1_350,
knownCost: 1_350,
unresolvedCount: 1,
competitorPrice: 600,
priceDelta: null,
unitPrice: null,
subtotal: null,
priceDelta: 750,
unitPrice: 1_600,
subtotal: 800,
secondarySubtotal: 550,
aiSubtotal: 18.5,
rulesetVersion: 'dynamic-bom-cost/v6',
}),
scenario({
id: 'scenario-substitution-old',
@ -2207,6 +2223,24 @@ describe('BomCostingWorkbenchClient', () => {
subtotal: 800,
secondarySubtotal: 550,
rulesetVersion: 'dynamic-bom-cost/v6',
realtimeMarketQuote: {
schemaVersion: 'bom-costing-1688-realtime/v1',
source: '1688_IMAGE_SEARCH',
status: 'AVAILABLE',
sourceImageUrl: 'https://images.example.com/desk.jpg',
searchedAt: now,
procurementRate: 0.9,
marketPrice: 1_500,
procurementCost: 1_350,
estimateRange: { min: 1_215, max: 1_485 },
confidence: 0.84,
comparableOfferCount: 3,
returnedOfferCount: 3,
warnings: [],
offers: [],
structuralCost: 1_350,
structuralEstimateRange: { min: 1_080, max: 1_620 },
},
}),
scenario({
id: 'scenario-substitution-updated',
@ -2423,13 +2457,11 @@ describe('BomCostingWorkbenchClient', () => {
/>,
)
expect((await screen.findAllByText('旧版结果待重新核算')).length).toBeGreaterThan(0)
expect(screen.getAllByText('已计部分 ¥500.00(不是整件成本)').length).toBeGreaterThan(0)
expect(screen.getAllByText('旧版结果待重新核算').length).toBeGreaterThan(0)
expect(screen.getByText('1 行没有可验证价格来源')).toBeInTheDocument()
expect(screen.getByText(/最近同步未成功:上游连接超时/)).toBeInTheDocument()
expect((await screen.findAllByText('约 ¥1,350.00')).length).toBeGreaterThan(0)
expect(screen.getAllByText('预计出厂成本').length).toBeGreaterThan(0)
expect(screen.getByText('1 行预估待补,不影响已形成的整件预估')).toBeInTheDocument()
const skuButton = screen.getByRole('button', { name: /黑胡桃色 120cm/ })
expect(within(skuButton).getByText('旧版结果待重新核算')).toBeInTheDocument()
expect(within(skuButton).getByText('约 ¥1,350.00')).toBeInTheDocument()
expect(screen.getByText('旧规则留档:¥1,300.00')).toBeInTheDocument()
const structureSummary = screen.getByText('结构与推导依据')
fireEvent.click(structureSummary)
@ -2439,31 +2471,8 @@ describe('BomCostingWorkbenchClient', () => {
expect(within(structureDetails as HTMLElement).getByText(/结构初判 1 轮 · 定向复核 2 轮/)).toBeInTheDocument()
expect(within(structureDetails as HTMLElement).getByText('待复核')).toBeInTheDocument()
expect(within(structureDetails as HTMLElement).getAllByText(/装配关系推导/).length).toBeGreaterThan(0)
expect(within(structureDetails as HTMLElement).getByRole('button', { name: '复核' })).toBeInTheDocument()
expect(within(structureDetails as HTMLElement).queryByRole('button', { name: '复核' })).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByLabelText('选择替换目标材质')).toHaveValue(
'material-rubberwood',
)
})
fireEvent.click(screen.getByRole('button', { name: '替换为 橡胶木' }))
expect(screen.getAllByText('约 ¥550.00').length).toBeGreaterThan(0)
const calculateButton = screen.getByRole('button', { name: '生成替换方案' })
await waitFor(() => expect(calculateButton).toBeEnabled())
fireEvent.click(calculateButton)
await waitFor(() => {
const postCall = (global.fetch as jest.Mock).mock.calls.find(
([url, init]) => String(url).endsWith('/cases/case-dynamic/scenarios') && init?.method === 'POST',
)
expect(postCall).toBeDefined()
expect(JSON.parse(postCall?.[1]?.body)).toEqual({
skuId: 'sku-dynamic',
targetMaterialId: 'material-rubberwood',
})
})
expect(await screen.findByText('预计出厂成本 · 橡胶木')).toBeInTheDocument()
const dynamicCostingHeading = screen.getByRole('heading', {
name: '分层成本预估',
level: 2,
@ -2489,39 +2498,22 @@ describe('BomCostingWorkbenchClient', () => {
)
expect(screen.queryByText('暂无图片')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '选择 SKU', level: 3 })).toBeInTheDocument()
expect(screen.getAllByText('约 ¥1,300.00').length).toBeGreaterThan(0)
expect(screen.getAllByText('约 ¥1,350.00').length).toBeGreaterThan(0)
const costInfoButton = screen.getAllByRole('button', {
name: '查看预计出厂成本说明',
})[0]
fireEvent.click(costInfoButton)
expect(screen.getAllByText(/板材按 8 件稳定批量排版/).length).toBeGreaterThan(0)
expect(screen.getAllByText(/批量利用率 82%/).length).toBeGreaterThan(0)
expect(screen.getAllByText(/单件整板承担上限 ¥1,000.00/).length).toBeGreaterThan(0)
expect(screen.getAllByText('预估 / 待复核').length).toBeGreaterThan(0)
expect(screen.getByText(/成本高于竞品售价,金额已保留/)).toBeInTheDocument()
expect(screen.getAllByText('约 ¥1,270.00').length).toBeGreaterThan(0)
expect(screen.getAllByText('公司历史 BOM 中位单价').length).toBeGreaterThan(0)
expect(screen.getAllByText('AI 参考预估 · 非正式采购价').length).toBeGreaterThan(0)
expect(screen.getAllByText('多模态模型参考公司知识预估').length).toBeGreaterThan(0)
expect(screen.getByText('1 行由多模态模型参考公司知识补充预估,整单已形成区间并等待复核')).toBeInTheDocument()
expect(screen.getAllByText(/参考 SKU:REF-BOARD/).length).toBeGreaterThan(0)
expect(screen.getAllByText(/估算区间 ¥1,080.00–¥1,620.00/).length).toBeGreaterThan(0)
expect(screen.getAllByText(/结构明细预估待补/).length).toBeGreaterThan(0)
expect(screen.getAllByText('预估完成').length).toBeGreaterThan(0)
expect(screen.getByText('成本贡献明细')).toBeInTheDocument()
expect(screen.getAllByText('最大贡献').length).toBeGreaterThan(0)
expect(screen.getAllByText(/占(?:已知成本 )?62%/).length).toBeGreaterThan(0)
expect(screen.getAllByText('较同材质').length).toBeGreaterThan(0)
expect(screen.getAllByText('节省 ¥50.00').length).toBeGreaterThan(0)
expect(screen.queryByText('-¥50.00')).not.toBeInTheDocument()
const costSummaryAside = screen.getByText('预计出厂成本 · 橡胶木').closest('aside')
expect(costSummaryAside).not.toBeNull()
expect(within(costSummaryAside as HTMLElement).getByText('竞品售价 ¥600.00')).toBeInTheDocument()
expect(within(costSummaryAside as HTMLElement).getByText(/高于竞品售价 ¥700.00/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '同材质基准' }))
expect(screen.queryByText('较同材质')).not.toBeInTheDocument()
expect(screen.queryByText('替换后预计')).not.toBeInTheDocument()
expect(screen.queryByText('节省 ¥50.00')).not.toBeInTheDocument()
expect(screen.getAllByText('历史价').length).toBeGreaterThan(0)
expect(screen.getAllByText(/占(?:已知成本 )?59%/).length).toBeGreaterThan(0)
expect(screen.queryByLabelText('选择替换目标材质')).not.toBeInTheDocument()
expect(screen.queryByText('生成替换方案')).not.toBeInTheDocument()
expect(screen.queryByText('竞品店铺')).not.toBeInTheDocument()
expect(screen.queryByText('item.example.com')).not.toBeInTheDocument()
expect(screen.queryByText(/1688|实时同款|实时采购|公司单价|公司历史 BOM|参考 SKU|竞品售价|非正式采购价/iu)).not.toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
@ -2712,10 +2704,8 @@ describe('BomCostingWorkbenchClient', () => {
/>,
)
expect(await screen.findByText('待确认品类商品')).toBeInTheDocument()
expect(screen.getAllByText('品类待确认').length).toBeGreaterThan(0)
expect(await screen.findByText('结构草稿')).toBeInTheDocument()
expect(screen.getByText('请先确认品类')).toBeInTheDocument()
expect(screen.getAllByText('暂不估价').length).toBeGreaterThan(0)
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: '分层成本预估' })).not.toBeInTheDocument()

View File

@ -14,8 +14,6 @@ export type BomCostingCaseStatusValue =
export interface BomCostingCaseSummary {
id: string
title: string
sourceUrl: string
sourceHost: string
status: BomCostingCaseStatusValue
progress: number
statusMessage: string
@ -61,10 +59,7 @@ export interface BomCostingSourceAssetDetail {
export interface BomCostingSourceSnapshotDetail {
id: string
status: BomCostingSourceStatusValue
channel: string
externalItemId: string
title: string
shopName: string
skuCount: number
imageCount: number
storedImageCount: number
@ -95,7 +90,6 @@ export interface BomCostingLineDetail {
lossFactor: number
sourceConfidence: number | null
evidence: Array<{
sourceUrl?: string
imageUrl?: string
basis?: 'IMAGE' | 'SOURCE_TEXT' | 'SKU_METADATA' | 'STRUCTURAL_INFERENCE'
note?: string
@ -104,30 +98,11 @@ export interface BomCostingLineDetail {
reviewReason?: string
confidence: number
}>
matchStatus: 'UNMATCHED' | 'MATCHED' | 'NEEDS_CONFIRMATION'
matchedLibraryType: string
matchedLibraryRecordId: string
matchedLibraryName: string
matchedLibrarySpec: string
resolvedUnitPrice: number | null
resolvedPriceUnit?: string
calculatedQuantity: number | null
estimateStatus: 'ESTIMATED' | 'PENDING'
subtotal: number | null
updatedAt: string
}
export interface BomCostingPriceCandidateDetail {
recordId: string
libraryType: string
code: string
name: string
spec: string
unit: string
unitPrice: number
thicknessMm: number | null
recommended: boolean
}
export interface BomCostingSkuDetail {
id: string
externalSkuId: string
@ -160,11 +135,7 @@ export interface BomCostingSkuDetail {
export interface BomCostingCaseDetail {
id: string
sourceUrl: string
sourceHost: string
title: string
providerCode: string
providerTaskId: string
sourceCacheHit: boolean
status: BomCostingCaseStatusValue
progress: number
@ -209,30 +180,6 @@ export interface BomCostingCaseDetail {
} | null
sourceSnapshot: BomCostingSourceSnapshotDetail | null
skus: BomCostingSkuDetail[]
reviewInputs?: Array<{
id: string
skuId: string | null
lineId: string | null
skuName: string
partName: string
source: 'MANUAL' | 'LIBRARY_MATCH'
materialCode: string
materialName: string
materialSpec: string
lengthMm: number | null
widthMm: number | null
heightMm: number | null
quantity: number | null
unitPrice: number | null
priceUnit: string
currency: string
lossFactor: number | null
subtotal: number | null
note: string
createdById: string | null
createdByName: string
createdAt: string
}>
analysisRuns: Array<{
id: string
revision: number
@ -269,24 +216,12 @@ export interface BomCostingCaseDetail {
dynamicCosting?: BomCostingDynamicCostingDetail
}
export type BomCostingDynamicScenarioKindValue =
| 'SAME_MATERIAL'
| 'MATERIAL_SUBSTITUTION'
export type BomCostingDynamicStatusValue =
| 'FINAL'
| 'PROVISIONAL'
| 'INSUFFICIENT_DATA'
| 'STALE'
export type BomCostingEstimateBasisValue =
| 'MASTER_DATA'
| 'DIRECT_HISTORY'
| 'SKU_FAMILY'
| 'SIMILAR_HISTORY'
| 'CATEGORY_COHORT'
| 'AI_REFERENCE_ESTIMATE'
export interface BomCostingEstimateRangeValue {
min: number
max: number
@ -294,8 +229,6 @@ export interface BomCostingEstimateRangeValue {
export interface BomCostingScenarioLineDetail {
id: string
sourceLineId: string | null
sourceRowId: string
category: string
partName: string
originalMaterial: string
@ -303,40 +236,22 @@ export interface BomCostingScenarioLineDetail {
materialSpec: string
unit: string
quantity: number | null
coefficient: number | null
pricingQuantity: number | null
unitPrice: number | null
subtotal: number | null
estimateRange: BomCostingEstimateRangeValue | null
estimateBasis: BomCostingEstimateBasisValue | null
sampleCount: number
priceSource: string
sourceSku: string
referenceSkus: string[]
referenceRowIds: string[]
sourceFingerprint: string
sourceCreatedAt: string | null
sourceSyncedAt: string | null
calculationRule: string
confidence: number | null
status: BomCostingDynamicStatusValue
warning: string
rawSnapshot: unknown
}
export interface BomCostingScenarioDetail {
id: string
skuId: string
kind: BomCostingDynamicScenarioKindValue
targetMaterialId: string | null
targetMaterialName: string
kind: 'SAME_MATERIAL'
revision: number
isCurrent: boolean
status: BomCostingDynamicStatusValue
totalCost: number | null
estimateRange: BomCostingEstimateRangeValue | null
estimateBasis: BomCostingEstimateBasisValue | null
sampleCount: number
categoryEstimateRanges: {
MATERIAL: BomCostingEstimateRangeValue | null
PROCESSING: BomCostingEstimateRangeValue | null
@ -344,59 +259,20 @@ export interface BomCostingScenarioDetail {
PACKAGING: BomCostingEstimateRangeValue | null
LOSS: BomCostingEstimateRangeValue | null
}
estimatedLineCount: number
knownCost: number | null
materialCost: number | null
processingCost: number | null
laborCost: number | null
packagingCost: number | null
lossCost: number | null
competitorPrice: number | null
priceDelta: number | null
marketPriceAlert?: 'LOW' | 'HIGH' | null
confidence: number | null
warning: string
unresolvedCount: number
dataAsOf: string | null
syncRunId: string | null
syncStatus: string | null
syncCompletedAt: string | null
rulesetVersion: string
createdAt: string
boardCutting: Array<{
sourceLineId: string | null
materialName: string
rulesetVersion: string
selectedBatchSize: number
batchSheetCount: number
perUnitSheetCost: number
utilization: number
singleUnitSheetCount: number
singleUnitSheetCost: number
}>
lines: BomCostingScenarioLineDetail[]
}
export interface BomCostingDynamicCostingDetail {
materials: Array<{
id: string
code: string
name: string
category: string
aliases: string[]
}>
scenarios: BomCostingScenarioDetail[]
sync: {
state: 'EMPTY' | 'READY' | 'MIXED' | 'STALE'
fresh: boolean
dataAsOf: string | null
sourceAsOf: string | null
lastSuccessfulAt: string | null
lastSuccessfulBatchId: string | null
lastFailedAt: string | null
lastFailedMessage: string
maxAgeDays: number
}
}
export type {

View File

@ -57,12 +57,55 @@ export type DynamicBomEstimateBasis =
| 'SIMILAR_HISTORY'
| 'CATEGORY_COHORT'
| 'AI_REFERENCE_ESTIMATE'
| 'REALTIME_1688_MARKET'
export interface DynamicBomEstimateRange {
min: number
max: number
}
/**
* A whole-product realtime market signal. It is deliberately separate from
* line-level BOM prices: an image-search offer can anchor procurement cost for
* the finished SKU, but cannot truthfully become a board or hardware quote.
*/
export interface DynamicBomRealtimeMarketOffer {
itemId: string
skuId: string | null
title: string
skuTitle: string | null
detailUrl: string | null
imageUrl: string | null
supplier: string | null
currentPrice: number
similarityScore: number
qualityIndex: number | null
quantityBegin: number | null
unit: string | null
soldCount: number | null
stockAmount: number | null
}
export interface DynamicBomRealtimeMarketQuote {
schemaVersion: 'bom-costing-1688-realtime/v1'
source: '1688_IMAGE_SEARCH'
status: 'AVAILABLE' | 'NO_COMPARABLE_OFFER' | 'NOT_CONFIGURED' | 'IMAGE_UNAVAILABLE' | 'FAILED'
sourceImageUrl: string | null
searchedAt: string | null
procurementRate: number
marketPrice: number | null
procurementCost: number | null
estimateRange: DynamicBomEstimateRange | null
confidence: number | null
comparableOfferCount: number
returnedOfferCount: number
warnings: string[]
offers: DynamicBomRealtimeMarketOffer[]
/** Structural BOM estimate before the current market anchor is selected. */
structuralCost?: number | null
structuralEstimateRange?: DynamicBomEstimateRange | null
}
export interface DynamicBomSourceRow {
[key: string]: unknown
Id?: unknown
@ -335,6 +378,8 @@ export interface DynamicBomScenarioResult {
unresolvedCount: number
warning: string
dataAsOf: string | null
/** Attached by the service after a realtime 1688 image search. */
realtimeMarketQuote?: DynamicBomRealtimeMarketQuote | null
}
export interface DynamicBomScenarioCalculationInput {

View File

@ -222,6 +222,8 @@ type ScenarioCostResultInput = {
categoryTotals: Record<string, number | null>
unresolvedCount: number
warning: string
realtimeMarketQuote?: unknown
wholeProductEstimateAudit?: unknown
lines: Array<Record<string, unknown>>
}
@ -269,6 +271,12 @@ async function createScenarioRecord(
lossCost: input.result.lossCost,
competitorPrice: input.competitorPrice,
priceDelta,
realtimeMarketQuote: input.result.realtimeMarketQuote === null || input.result.realtimeMarketQuote === undefined
? undefined
: input.result.realtimeMarketQuote as Prisma.InputJsonValue,
wholeProductEstimateAudit: input.result.wholeProductEstimateAudit === null || input.result.wholeProductEstimateAudit === undefined
? undefined
: input.result.wholeProductEstimateAudit as Prisma.InputJsonValue,
warning: input.result.warning,
unresolvedCount: input.result.unresolvedCount,
confidence: input.result.confidence,

View File

@ -151,21 +151,6 @@ const caseDetailInclude = Prisma.validator<Prisma.BomCostingCaseInclude>()({
orderBy: { revision: 'desc' },
take: 20,
},
reviewInputs: {
orderBy: { createdAt: 'desc' },
take: 100,
include: {
creator: {
select: { id: true, name: true },
},
sku: {
select: { name: true },
},
line: {
select: { partName: true },
},
},
},
})
export type BomCostingCaseDetailRecord = Prisma.BomCostingCaseGetPayload<{

View File

@ -0,0 +1,270 @@
import {
BomCosting1688ImageSearchService,
buildAlibaba1688SignedHeaders,
buildBomCosting1688RealtimeMarketQuote,
canonicalAlibaba1688Resource,
extractAlibaba1688AkKeys,
getBomCosting1688ImageSearchConfig,
resolveBomCostingImageSearchSourceUrl,
type BomCosting1688ImageSearchConfig,
} from './bom-costing-1688-image-search'
const testAk = Buffer.from('0123456789abcdef0123456789abcdeftest-ak-id').toString('base64url')
const config: BomCosting1688ImageSearchConfig = {
endpoint: new URL('https://skills-gateway.1688.com/api/find_product/1.0.0'),
ak: testAk,
timeoutMs: 10_000,
maxImageBytes: 5 * 1024 * 1024,
minSimilarityScore: 0.78,
procurementRate: 0.9,
pageSize: 10,
purchaseAmount: 1,
tags: '4306497',
}
describe('Alibaba 1688 CSK signing', () => {
it('decodes the URL-safe AK into an AK id and a 32-character signing secret', () => {
expect(extractAlibaba1688AkKeys(`1688apikey:${testAk}`)).toEqual({
secret: '0123456789abcdef0123456789abcdef',
akId: 'test-ak-id',
})
expect(extractAlibaba1688AkKeys('bad')).toBeNull()
})
it('uses the official gateway and server-only ALIBABA environment variables', () => {
const previousAk = process.env.ALIBABA_1688_IMAGE_SEARCH_AK
const previousEndpoint = process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL
process.env.ALIBABA_1688_IMAGE_SEARCH_AK = testAk
process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL = 'https://skills-gateway.1688.com/api/find_product/1.0.0'
try {
expect(getBomCosting1688ImageSearchConfig()).toEqual(expect.objectContaining({
ak: testAk,
endpoint: new URL('https://skills-gateway.1688.com/api/find_product/1.0.0'),
pageSize: 10,
}))
} finally {
if (previousAk === undefined) delete process.env.ALIBABA_1688_IMAGE_SEARCH_AK
else process.env.ALIBABA_1688_IMAGE_SEARCH_AK = previousAk
if (previousEndpoint === undefined) delete process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL
else process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL = previousEndpoint
}
})
it('turns only the public same-origin storage route into an absolute image URL', () => {
expect(resolveBomCostingImageSearchSourceUrl(
'/api/storage/object?key=prod%2Fsku.webp',
(key) => `https://objects.example.com/${key}`,
)).toEqual(
new URL('https://objects.example.com/prod/sku.webp'),
)
expect(() => resolveBomCostingImageSearchSourceUrl('/api/auth/me')).toThrow('竞品链接格式不正确')
})
it('sorts canonical query pairs and signs exactly the body sent to the gateway', () => {
expect(canonicalAlibaba1688Resource('https://skills-gateway.1688.com/api/find_product/1.0.0?z=2&a=b&a=a')).toBe(
'/api/find_product/1.0.0?a=a&a=b&z=2',
)
const body = '{"imgBase64":"aW1hZ2U=","pageSize":10}'
expect(buildAlibaba1688SignedHeaders({
uri: 'https://skills-gateway.1688.com/api/find_product/1.0.0',
body,
ak: testAk,
timestamp: '1786903200',
nonce: '01020304',
})).toEqual({
'Content-Type': 'application/json',
'x-csk-ak': 'test-ak-id',
'x-csk-time': '1786903200',
'x-csk-nonce': '01020304',
'x-csk-content-md5': 'x1mlqy1bkZMZMp7OFnTphA==',
'x-csk-version': '1.7.0',
'x-csk-sign': 'IYDSV+EEOCylcqpmITFvMyIlicUU6XxUVl81cbogwhE=',
})
})
})
describe('buildBomCosting1688RealtimeMarketQuote', () => {
it('uses the robust realtime price centre and the configured procurement rate', () => {
const quote = buildBomCosting1688RealtimeMarketQuote({
sourceImageUrl: 'https://images.example.com/sku.jpg',
searchedAt: new Date('2026-08-15T10:00:00.000Z'),
procurementRate: 0.9,
minSimilarityScore: 0.78,
returnedOffers: [
{ itemId: 'one', title: '同款 A', score: 0.99, currentPrice: 100 },
{ itemId: 'two', title: '同款 B', score: 0.95, currentPrice: 110 },
{ itemId: 'three', title: '同款 C', score: 0.9, currentPrice: 120 },
{ itemId: 'ignored', title: '低相关', score: 0.6, currentPrice: 1 },
],
})
expect(quote.status).toBe('AVAILABLE')
expect(quote.marketPrice).toBe(110)
expect(quote.procurementCost).toBe(99)
expect(quote.estimateRange).toEqual({ min: 93.6, max: 104.4 })
expect(quote.comparableOfferCount).toBe(3)
expect(quote.offers).toHaveLength(3)
})
it('does not fabricate a current price from weak or invalid offers', () => {
const quote = buildBomCosting1688RealtimeMarketQuote({
sourceImageUrl: 'https://images.example.com/sku.jpg',
searchedAt: new Date('2026-08-15T10:00:00.000Z'),
procurementRate: 0.9,
minSimilarityScore: 0.9,
returnedOffers: [
{ itemId: 'weak', title: '弱相似', score: 0.89, currentPrice: 100 },
{ itemId: 'invalid', title: '无价', score: 0.99, currentPrice: null },
],
})
expect(quote.status).toBe('NO_COMPARABLE_OFFER')
expect(quote.procurementCost).toBeNull()
})
})
describe('BomCosting1688ImageSearchService', () => {
it('signs the pure Base64 request body and accepts official model/item aliases', async () => {
const imageResponse = {
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'image/jpeg' }),
arrayBuffer: async () => Buffer.from('image-bytes'),
} as unknown as Response
const apiResponse = {
ok: true,
status: 200,
text: async () => JSON.stringify({
success: true,
model: {
count: 1,
data: [{
item_id: 1,
subject: '同款',
score: 0.99,
price: 200,
quantity_begin: 2,
store_amount: 99,
companyName: '测试工厂',
}],
},
}),
} as unknown as Response
const fetch = jest.fn()
.mockResolvedValueOnce(imageResponse)
.mockResolvedValueOnce(apiResponse)
const service = new BomCosting1688ImageSearchService({
getConfig: () => config,
fetch,
now: () => new Date('2026-08-16T10:00:00.000Z'),
randomBytes: () => Buffer.from([1, 2, 3, 4]),
createStoredObjectReadUrl: (key) => `https://objects.example.com/${key}`,
})
const quote = await service.findRealtimeQuote({
imageUrl: 'https://images.example.com/sku.jpg',
})
expect(quote.procurementCost).toBe(180)
expect(quote.offers[0]).toEqual(expect.objectContaining({
itemId: '1',
supplier: '测试工厂',
quantityBegin: 2,
stockAmount: 99,
detailUrl: 'https://detail.1688.com/offer/1.html',
}))
expect(fetch).toHaveBeenCalledTimes(2)
const request = fetch.mock.calls[1]?.[1]
const expectedBody = JSON.stringify({
imgBase64: Buffer.from('image-bytes').toString('base64'),
pageSize: 10,
purchaseAmount: 1,
sortType: 'yx_desc',
scoreLevel: 'high',
tags: '4306497',
icTags: '',
})
expect(request?.body).toBe(expectedBody)
expect(request?.headers).toEqual(expect.objectContaining({
'Content-Type': 'application/json',
'x-csk-ak': 'test-ak-id',
'x-csk-time': '1786874400',
'x-csk-nonce': '01020304',
'x-csk-content-md5': 'o38VnblUKGb4d8GTwvYg7Q==',
'x-csk-version': '1.7.0',
}))
expect(JSON.parse(request?.body ?? '{}')).not.toHaveProperty('imageUrl')
})
it('fetches a stored same-origin product image through its backing object storage URL', async () => {
const imageResponse = {
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'image/jpeg' }),
arrayBuffer: async () => Buffer.from('image-bytes'),
} as unknown as Response
const apiResponse = {
ok: true,
status: 200,
text: async () => JSON.stringify({ success: true, model: { count: 0, data: [] } }),
} as unknown as Response
const fetch = jest.fn()
.mockResolvedValueOnce(imageResponse)
.mockResolvedValueOnce(apiResponse)
const service = new BomCosting1688ImageSearchService({
getConfig: () => config,
fetch,
now: () => new Date('2026-08-16T10:00:00.000Z'),
randomBytes: () => Buffer.from([1, 2, 3, 4]),
createStoredObjectReadUrl: (key) => `https://objects.example.com/${key}`,
})
await expect(service.findRealtimeQuote({
imageUrl: '/api/storage/object?key=prod%2Fbom-costing%2Fsku.webp',
})).resolves.toMatchObject({ status: 'NO_COMPARABLE_OFFER' })
expect(fetch.mock.calls[0]?.[0]).toEqual(
new URL('https://objects.example.com/prod/bom-costing/sku.webp'),
)
})
it('degrades without a hidden browser fallback when the service is not configured', async () => {
const fetch = jest.fn()
const service = new BomCosting1688ImageSearchService({
getConfig: () => null,
fetch,
now: () => new Date('2026-08-15T10:00:00.000Z'),
randomBytes: () => Buffer.from([1, 2, 3, 4]),
})
await expect(service.findRealtimeQuote({
imageUrl: 'https://images.example.com/sku.jpg',
})).resolves.toMatchObject({ status: 'NOT_CONFIGURED', procurementCost: null })
expect(fetch).not.toHaveBeenCalled()
})
it('keeps the structural estimate when the gateway returns a business error', async () => {
const imageResponse = {
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'image/jpeg' }),
arrayBuffer: async () => Buffer.from('image-bytes'),
} as unknown as Response
const apiResponse = {
ok: true,
status: 200,
text: async () => JSON.stringify({ success: false, code: 'INVALID_AK', msgInfo: '无效 AK' }),
} as unknown as Response
const service = new BomCosting1688ImageSearchService({
getConfig: () => config,
fetch: jest.fn().mockResolvedValueOnce(imageResponse).mockResolvedValueOnce(apiResponse),
now: () => new Date('2026-08-15T10:00:00.000Z'),
randomBytes: () => Buffer.from([1, 2, 3, 4]),
})
await expect(service.findRealtimeQuote({
imageUrl: 'https://images.example.com/sku.jpg',
})).resolves.toMatchObject({ status: 'FAILED', procurementCost: null })
})
})

View File

@ -0,0 +1,603 @@
import 'server-only'
import { createHash, createHmac, randomBytes } from 'node:crypto'
import { z } from 'zod'
import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
import { createObjectReadUrl, resolveObjectStorageUrlForServerAccess } from '@/lib/storage'
import type {
DynamicBomRealtimeMarketOffer,
DynamicBomRealtimeMarketQuote,
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
const OFFICIAL_IMAGE_SEARCH_ENDPOINT = 'https://skills-gateway.1688.com/api/find_product/1.0.0'
const IMAGE_SEARCH_PATH = '/api/find_product/1.0.0'
const CSK_SIGNATURE_VERSION = '1.7.0'
const DEFAULT_IMAGE_MAX_BYTES = 5 * 1024 * 1024
const DEFAULT_TIMEOUT_MS = 45_000
const DEFAULT_MINIMUM_SCORE = 0.78
const DEFAULT_PROCUREMENT_RATE = 0.9
const finiteNumber = z.union([z.number(), z.string()]).transform((value, context) => {
const parsed = typeof value === 'number' ? value : Number(value.trim())
if (!Number.isFinite(parsed)) {
context.addIssue({ code: 'custom', message: '必须是有限数字' })
return z.NEVER
}
return parsed
})
const nullableFiniteNumber = finiteNumber.nullish().transform((value) => value ?? null)
const imageSearchItemSchema = z.object({
itemId: z.union([z.string(), z.number()]).transform(String).pipe(z.string().trim().min(1)),
title: z.string().trim().max(500).optional().default(''),
imageUrl: z.string().url().optional().nullable().default(null),
detailUrl: z.string().url().optional().nullable().default(null),
score: nullableFiniteNumber,
currentPrice: nullableFiniteNumber,
skuId: z.union([z.string(), z.number()]).optional().nullable().transform((value) => (
value === null || value === undefined ? null : String(value)
)),
skuTitle: z.string().trim().max(500).optional().nullable().default(null),
yxIndex: nullableFiniteNumber,
quantityBegin: nullableFiniteNumber,
unit: z.string().trim().max(80).optional().nullable().default(null),
company: z.string().trim().max(300).optional().nullable().default(null),
stockAmount: nullableFiniteNumber,
}).passthrough()
export interface BomCosting1688ImageSearchConfig {
endpoint: URL
/** URL-safe Base64 AK. It is never exposed to browser clients or logs. */
ak: string
timeoutMs: number
maxImageBytes: number
minSimilarityScore: number
procurementRate: number
pageSize: number
purchaseAmount: number
tags: string
}
export type BomCosting1688ComparableOffer = DynamicBomRealtimeMarketOffer
export type BomCosting1688RealtimeMarketQuote = DynamicBomRealtimeMarketQuote
export interface BomCosting1688ImageSearchGateway {
getConfig(): BomCosting1688ImageSearchConfig | null
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
now(): Date
randomBytes(size: number): Buffer
createStoredObjectReadUrl?: (key: string) => string
}
function boundedPositiveInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum
? parsed
: fallback
}
function boundedDecimal(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum
? parsed
: fallback
}
export function getBomCosting1688ImageSearchConfig(): BomCosting1688ImageSearchConfig | null {
const explicitEndpoint = (
process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL ??
process.env.BOM_COSTING_1688_IMAGE_SEARCH_ENDPOINT
)?.trim()
const ak = (
process.env.ALIBABA_1688_IMAGE_SEARCH_AK ??
process.env.BOM_COSTING_1688_IMAGE_SEARCH_API_KEY
)?.trim()
if (!ak || !extractAlibaba1688AkKeys(ak)) return null
let endpoint: URL
try {
endpoint = new URL(explicitEndpoint || OFFICIAL_IMAGE_SEARCH_ENDPOINT)
} catch {
return null
}
if (
endpoint.protocol !== 'https:' ||
endpoint.hostname !== 'skills-gateway.1688.com' ||
endpoint.pathname !== IMAGE_SEARCH_PATH
) return null
return {
endpoint,
ak,
timeoutMs: boundedPositiveInteger(
process.env.ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS ??
process.env.BOM_COSTING_1688_IMAGE_SEARCH_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS,
5_000,
120_000,
),
maxImageBytes: boundedPositiveInteger(
process.env.ALIBABA_1688_IMAGE_SEARCH_MAX_IMAGE_BYTES ??
process.env.BOM_COSTING_1688_IMAGE_SEARCH_MAX_IMAGE_BYTES,
DEFAULT_IMAGE_MAX_BYTES,
1_024,
DEFAULT_IMAGE_MAX_BYTES,
),
minSimilarityScore: boundedDecimal(
process.env.ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY ??
process.env.BOM_COSTING_1688_IMAGE_SEARCH_MIN_SIMILARITY,
DEFAULT_MINIMUM_SCORE,
0.5,
0.9999,
),
procurementRate: boundedDecimal(
process.env.ALIBABA_1688_PROCUREMENT_RATE ??
process.env.BOM_COSTING_1688_PROCUREMENT_RATE,
DEFAULT_PROCUREMENT_RATE,
0.5,
1,
),
pageSize: boundedPositiveInteger(
process.env.ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE ??
process.env.BOM_COSTING_1688_IMAGE_SEARCH_PAGE_SIZE,
10,
10,
50,
),
purchaseAmount: boundedPositiveInteger(
process.env.ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT ??
process.env.BOM_COSTING_1688_IMAGE_SEARCH_PURCHASE_AMOUNT,
1,
1,
100_000,
),
tags: (
process.env.ALIBABA_1688_IMAGE_SEARCH_TAGS ??
process.env.BOM_COSTING_1688_IMAGE_SEARCH_TAGS
)?.trim() || '4306497',
}
}
/**
* Source snapshots keep object-storage links as same-origin relative paths.
* The server-side image-search request must read the backing object directly:
* the browser proxy correctly requires a login cookie and therefore cannot be
* used by a background pricing job. Only its storage-object route is eligible
* for this conversion.
*/
export function resolveBomCostingImageSearchSourceUrl(
rawValue: string,
buildStoredObjectReadUrl: (key: string) => string = createObjectReadUrl,
): URL {
const trimmed = rawValue.trim()
if (trimmed.startsWith('/api/storage/object?')) {
const parsed = new URL(trimmed, 'https://bom-costing.invalid')
const objectKey = parsed.searchParams.get('key')?.trim()
if (!objectKey) throw new Error('SKU 主图缺少对象存储 key')
return parseSafeCompetitorUrl(buildStoredObjectReadUrl(objectKey))
}
return parseSafeCompetitorUrl(resolveObjectStorageUrlForServerAccess(trimmed))
}
const defaultGateway: BomCosting1688ImageSearchGateway = {
getConfig: getBomCosting1688ImageSearchConfig,
fetch: (...args) => fetch(...args),
now: () => new Date(),
randomBytes: (size) => randomBytes(size),
createStoredObjectReadUrl: createObjectReadUrl,
}
interface Alibaba1688AkKeys {
akId: string
secret: string
}
/**
* The official AK is a URL-safe Base64 text. Its decoded first 32 characters
* are the signing secret; the remainder is the public AK id.
*/
export function extractAlibaba1688AkKeys(rawAk: string | null | undefined): Alibaba1688AkKeys | null {
const labeled = String(rawAk ?? '').trim()
const raw = labeled.replace(/^1688apikey\s*:/i, '').trim()
if (!raw) return null
let decoded = ''
try {
decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
} catch {
decoded = raw
}
if (decoded.length <= 32) decoded = raw
const secret = decoded.slice(0, 32)
const akId = decoded.slice(32).trim()
return secret.length === 32 && akId ? { secret, akId } : null
}
export function canonicalAlibaba1688Resource(uri: string | URL): string {
const parsed = new URL(uri, OFFICIAL_IMAGE_SEARCH_ENDPOINT)
const entries = Array.from(parsed.searchParams.entries()).sort(([leftKey, leftValue], [rightKey, rightValue]) => (
leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)
))
const query = entries
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
return `${parsed.pathname || '/'}${query ? `?${query}` : ''}`
}
export function buildAlibaba1688SignedHeaders(input: {
method?: string
uri: string | URL
body: string
ak: string
timestamp?: string
nonce?: string
}): Record<string, string> {
const keys = extractAlibaba1688AkKeys(input.ak)
if (!keys) throw new Error('1688 AK 格式无效')
const contentType = 'application/json'
const timestamp = input.timestamp ?? String(Math.trunc(Date.now() / 1000))
const nonce = input.nonce ?? randomBytes(4).toString('hex')
if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{8}$/i.test(nonce)) {
throw new Error('1688 签名时间或随机数格式无效')
}
const contentMd5 = input.body
? createHash('md5').update(input.body, 'utf8').digest('base64')
: ''
const cskHeaders: Record<string, string> = {
'x-csk-ak': keys.akId,
'x-csk-time': timestamp,
'x-csk-nonce': nonce,
'x-csk-content-md5': contentMd5,
'x-csk-version': CSK_SIGNATURE_VERSION,
}
const canonicalHeaders = Object.keys(cskHeaders)
.sort()
.map((key) => `${key}:${cskHeaders[key]!.trim()}\n`)
.join('')
const stringToSign = `${(input.method ?? 'POST').toUpperCase()}\n${contentMd5}\n${contentType}\n${timestamp}\n${canonicalHeaders}${canonicalAlibaba1688Resource(input.uri)}`
return {
'Content-Type': contentType,
'x-csk-sign': createHmac('sha256', keys.secret).update(stringToSign, 'utf8').digest('base64'),
...cskHeaders,
}
}
function roundMoney(value: number) {
return Number(value.toFixed(2))
}
type ImageSearchItem = z.infer<typeof imageSearchItemSchema>
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function firstDefined(record: Record<string, unknown>, keys: readonly string[]): unknown {
for (const key of keys) {
if (record[key] !== undefined && record[key] !== null) return record[key]
}
return null
}
function textValue(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null
const normalized = String(value).trim()
return normalized || null
}
function finiteValue(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null
if (typeof value !== 'string') return null
const parsed = Number(value.trim())
return Number.isFinite(parsed) ? parsed : null
}
function httpUrlValue(value: unknown): string | null {
const text = textValue(value)
if (!text) return null
try {
const parsed = new URL(text)
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.toString() : null
} catch {
return null
}
}
function normalizeResponseItem(value: unknown): ImageSearchItem | null {
const raw = asRecord(value)
if (!raw) return null
const itemId = textValue(firstDefined(raw, ['itemId', 'item_id', 'offerId', 'offer_id']))
if (!itemId) return null
const parsed = imageSearchItemSchema.safeParse({
itemId,
title: textValue(firstDefined(raw, ['title', 'subject', 'name'])) ?? '',
imageUrl: httpUrlValue(firstDefined(raw, ['imageUrl', 'image_url', 'image', 'imgUrl', 'img_url'])),
detailUrl: httpUrlValue(firstDefined(raw, ['detailUrl', 'detail_url', 'url'])) ?? `https://detail.1688.com/offer/${encodeURIComponent(itemId)}.html`,
score: finiteValue(firstDefined(raw, ['score', 'similarityScore', 'similarity_score'])),
currentPrice: finiteValue(firstDefined(raw, ['currentPrice', 'current_price', 'price'])),
skuId: textValue(firstDefined(raw, ['skuId', 'sku_id'])),
skuTitle: textValue(firstDefined(raw, ['skuTitle', 'sku_title'])),
yxIndex: finiteValue(firstDefined(raw, ['yxIndex', 'yx_index'])),
quantityBegin: finiteValue(firstDefined(raw, ['quantityBegin', 'quantity_begin'])),
unit: textValue(firstDefined(raw, ['unit'])),
company: textValue(firstDefined(raw, ['company', 'companyName', 'supplierName', 'supplier_name'])),
stockAmount: finiteValue(firstDefined(raw, ['storeAmount', 'store_amount', 'stockAmount', 'stock_amount'])),
raw,
})
return parsed.success ? parsed.data : null
}
function responseFailureMessage(envelope: Record<string, unknown>): string | null {
const success = envelope.success
const httpCode = finiteValue(envelope.httpCode)
if (success !== false && (httpCode === null || httpCode < 400)) return null
const code = textValue(firstDefined(envelope, ['code', 'msgCode', 'errorCode']))
const message = textValue(firstDefined(envelope, ['msgInfo', 'message', 'error', 'errorMessage']))
return `1688 同款服务业务失败${code ? `(${code})` : ''}${message ? `:${message}` : ''}`
}
function readImageSearchResponse(payload: unknown): ImageSearchItem[] {
const envelope = asRecord(payload)
if (!envelope) throw new Error('1688 同款服务返回格式不完整')
const businessFailure = responseFailureMessage(envelope)
if (businessFailure) throw new Error(businessFailure)
const bodies = [envelope.model, envelope.data, envelope.result, envelope]
for (const body of bodies) {
const rows = Array.isArray(body)
? body
: (() => {
const record = asRecord(body)
if (!record) return null
if (Array.isArray(record.data)) return record.data
if (Array.isArray(record.items)) return record.items
return null
})()
if (rows) return rows.map(normalizeResponseItem).filter((item): item is ImageSearchItem => item !== null)
}
throw new Error('1688 同款服务未返回商品列表')
}
function percentile(values: readonly number[], ratio: number) {
if (values.length === 0) return null
const ordered = [...values].sort((left, right) => left - right)
const position = Math.max(0, Math.min(ordered.length - 1, (ordered.length - 1) * ratio))
const lower = Math.floor(position)
const upper = Math.ceil(position)
const lowerValue = ordered[lower] ?? 0
const upperValue = ordered[upper] ?? lowerValue
return lowerValue + (upperValue - lowerValue) * (position - lower)
}
function readBoundedImage(response: Response, maxBytes: number) {
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error('1688 同款检索图片超过 5MB 限制')
}
return response.arrayBuffer().then((buffer) => {
const bytes = Buffer.from(buffer)
if (bytes.length > maxBytes) throw new Error('1688 同款检索图片超过 5MB 限制')
return bytes
})
}
function emptyQuote(input: Pick<
BomCosting1688RealtimeMarketQuote,
'status' | 'sourceImageUrl' | 'procurementRate' | 'warnings'
> & Partial<Pick<BomCosting1688RealtimeMarketQuote, 'searchedAt' | 'returnedOfferCount' | 'offers'>>): BomCosting1688RealtimeMarketQuote {
return {
schemaVersion: 'bom-costing-1688-realtime/v1',
source: '1688_IMAGE_SEARCH',
status: input.status,
sourceImageUrl: input.sourceImageUrl,
searchedAt: input.searchedAt ?? null,
procurementRate: input.procurementRate,
marketPrice: null,
procurementCost: null,
estimateRange: null,
confidence: null,
comparableOfferCount: 0,
returnedOfferCount: input.returnedOfferCount ?? 0,
warnings: input.warnings,
offers: input.offers ?? [],
}
}
export function buildBomCosting1688RealtimeMarketQuote(input: {
sourceImageUrl: string
searchedAt: Date
procurementRate: number
minSimilarityScore: number
returnedOffers: ImageSearchItem[]
}): BomCosting1688RealtimeMarketQuote {
const offers = input.returnedOffers.flatMap((item) => {
const price = item.currentPrice
const score = item.score
if (
price === null || price <= 0 ||
score === null || score < input.minSimilarityScore
) return []
return [{
itemId: item.itemId,
skuId: item.skuId,
title: item.title,
skuTitle: item.skuTitle,
detailUrl: item.detailUrl,
imageUrl: item.imageUrl,
supplier: item.company,
currentPrice: roundMoney(price),
similarityScore: Number(score.toFixed(4)),
qualityIndex: item.yxIndex == null ? null : Number(item.yxIndex.toFixed(4)),
quantityBegin: item.quantityBegin == null ? null : Math.max(1, Math.floor(item.quantityBegin)),
unit: item.unit,
soldCount: null,
stockAmount: item.stockAmount == null ? null : Math.max(0, Math.floor(item.stockAmount)),
} satisfies BomCosting1688ComparableOffer]
}).sort((left, right) => (
right.similarityScore - left.similarityScore ||
(right.qualityIndex ?? -1) - (left.qualityIndex ?? -1) ||
left.currentPrice - right.currentPrice ||
left.itemId.localeCompare(right.itemId)
)).slice(0, 8)
if (offers.length === 0) {
return emptyQuote({
status: 'NO_COMPARABLE_OFFER',
sourceImageUrl: input.sourceImageUrl,
searchedAt: input.searchedAt.toISOString(),
procurementRate: input.procurementRate,
returnedOfferCount: input.returnedOffers.length,
warnings: ['1688 未返回相似度足够且价格有效的同款,未覆盖结构拆解预估'],
})
}
const prices = offers.map((offer) => offer.currentPrice)
const marketPrice = percentile(prices, 0.5) ?? prices[0] ?? 0
const lowerMarketPrice = percentile(prices, 0.2) ?? marketPrice
const upperMarketPrice = percentile(prices, 0.8) ?? marketPrice
const procurementCost = roundMoney(marketPrice * input.procurementRate)
const lower = roundMoney(Math.min(lowerMarketPrice, marketPrice) * input.procurementRate)
const upper = roundMoney(Math.max(upperMarketPrice, marketPrice) * input.procurementRate)
const topScore = offers[0]?.similarityScore ?? input.minSimilarityScore
const confidence = Math.min(0.9, Number((
0.35 + topScore * 0.42 + Math.min(offers.length, 4) * 0.045
).toFixed(4)))
const warnings = [
`1688 实时同款价格中位数 ¥${roundMoney(marketPrice)},按采购系数 ${(input.procurementRate * 100).toFixed(0)}% 形成采购预估`,
offers.length < 3 ? '可比同款不足 3 条,区间已按有限样本保守处理' : '',
offers.some((offer) => (offer.quantityBegin ?? 1) > 1)
? '部分同款存在起批量,已按本次采购数量返回的实时价格估算,需下单前复核阶梯价' : '',
].filter(Boolean)
return {
schemaVersion: 'bom-costing-1688-realtime/v1',
source: '1688_IMAGE_SEARCH',
status: 'AVAILABLE',
sourceImageUrl: input.sourceImageUrl,
searchedAt: input.searchedAt.toISOString(),
procurementRate: input.procurementRate,
marketPrice: roundMoney(marketPrice),
procurementCost,
estimateRange: { min: lower, max: upper },
confidence,
comparableOfferCount: offers.length,
returnedOfferCount: input.returnedOffers.length,
warnings,
offers,
}
}
export class BomCosting1688ImageSearchService {
constructor(private readonly gateway: BomCosting1688ImageSearchGateway = defaultGateway) {}
async findRealtimeQuote(input: {
imageUrl: string | null | undefined
}): Promise<BomCosting1688RealtimeMarketQuote> {
const config = this.gateway.getConfig()
const sourceImageUrl = input.imageUrl?.trim() || null
if (!config) {
return emptyQuote({
status: 'NOT_CONFIGURED',
sourceImageUrl,
procurementRate: DEFAULT_PROCUREMENT_RATE,
warnings: ['1688 实时同款服务尚未配置,当前继续使用结构拆解预估'],
})
}
if (!sourceImageUrl) {
return emptyQuote({
status: 'IMAGE_UNAVAILABLE',
sourceImageUrl: null,
procurementRate: config.procurementRate,
warnings: ['该 SKU 没有可用于 1688 同款检索的主图,当前继续使用结构拆解预估'],
})
}
try {
const imageUrl = resolveBomCostingImageSearchSourceUrl(
sourceImageUrl,
this.gateway.createStoredObjectReadUrl,
)
const imageResponse = await this.gateway.fetch(imageUrl, {
method: 'GET',
headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' },
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(config.timeoutMs),
})
if (!imageResponse.ok) throw new Error(`图片读取失败(${imageResponse.status})`)
const contentType = imageResponse.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
if (!contentType.startsWith('image/')) throw new Error('SKU 主图不是可检索图片')
const image = await readBoundedImage(imageResponse, config.maxImageBytes)
const body = JSON.stringify({
imgBase64: image.toString('base64'),
pageSize: config.pageSize,
purchaseAmount: config.purchaseAmount,
sortType: 'yx_desc',
scoreLevel: 'high',
tags: config.tags,
icTags: '',
})
const searchedAt = this.gateway.now()
const response = await this.gateway.fetch(config.endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
...buildAlibaba1688SignedHeaders({
method: 'POST',
uri: config.endpoint,
body,
ak: config.ak,
timestamp: String(Math.trunc(searchedAt.getTime() / 1000)),
nonce: this.gateway.randomBytes(4).toString('hex'),
}),
},
cache: 'no-store',
signal: AbortSignal.timeout(config.timeoutMs),
// The body is signed. Never let a client re-serialize it after MD5.
body,
})
const rawBody = await response.text().catch(() => '')
let payload: unknown = null
try {
payload = rawBody ? JSON.parse(rawBody) : null
} catch {
payload = null
}
if (!response.ok) throw new Error(`1688 同款服务返回 ${response.status}`)
return buildBomCosting1688RealtimeMarketQuote({
sourceImageUrl,
searchedAt,
procurementRate: config.procurementRate,
minSimilarityScore: config.minSimilarityScore,
returnedOffers: readImageSearchResponse(payload),
})
} catch {
// The provider response may contain offer data or diagnostics. Keep it
// out of ordinary application logs; the calling estimate degrades to a
// source-neutral no-reference result instead.
console.warn('[BOM costing] realtime procurement reference unavailable')
return emptyQuote({
status: 'FAILED',
sourceImageUrl,
searchedAt: this.gateway.now().toISOString(),
procurementRate: config.procurementRate,
warnings: ['1688 实时同款检索本次未返回可用价格,当前继续使用结构拆解预估'],
})
}
}
}
export const bomCosting1688ImageSearchService = new BomCosting1688ImageSearchService()

View File

@ -3,6 +3,7 @@
import {
BomCostingAiCostEstimator,
parseBomCostingAiEstimatePayload,
parseBomCostingAiWholeProductEstimatePayload,
type BomCostingAiEstimateInput,
} from './bom-costing-ai-cost-estimator'
@ -178,4 +179,79 @@ describe('BomCostingAiCostEstimator', () => {
quotes: { SAME_MATERIAL: {}, MATERIAL_SUBSTITUTION: {} },
})
})
it('在明细资料不完整时仍将整件模型预估规范化为低可信区间', async () => {
const parsed = parseBomCostingAiWholeProductEstimatePayload({
estimatedTotal: 180,
estimateLow: 210,
estimateHigh: 150,
confidence: 0.73,
reason: '结合鞋柜尺寸、抽屉结构和相近产品成本关系形成整件预估',
referenceRowIds: ['row-1', 'invented-row', 'row-1'],
}, new Set(['row-1']))
expect(parsed).toEqual({
totalCost: 180,
estimateRange: { min: 150, max: 210 },
confidence: 0.5,
reason: '结合鞋柜尺寸、抽屉结构和相近产品成本关系形成整件预估',
referenceRowIds: ['row-1'],
})
})
it('产品资料存在时请求整件模型预估,不要求完整 BOM 明细', async () => {
let callInput: Record<string, unknown> | null = null
const estimator = new BomCostingAiCostEstimator({
getConfig: async () => ({
baseUrl: 'https://example.com/v1',
apiKey: 'test-key',
model: 'test-multimodal-model',
}),
call: async (input) => {
callInput = input as unknown as Record<string, unknown>
return {
id: 'whole-call-1',
model: 'test-multimodal-model',
content: '{}',
promptTokens: 180,
completionTokens: 70,
durationMs: 430,
finishReason: 'stop',
}
},
parse: () => ({
estimatedTotal: 188,
estimateLow: 145,
estimateHigh: 232,
confidence: 0.48,
reason: '结合鞋柜名称、外形尺寸和相近柜体制造成本关系形成预估',
referenceRowIds: ['row-1'],
}),
})
const result = await estimator.estimateWholeProductCost({
product: {
...estimateInput().product,
attributes: { color: '原木色', drawerCount: 3 },
},
knownLines: [],
companyReferences: estimateInput().companyReferences,
})
expect(result).toMatchObject({
totalCost: 188,
estimateRange: { min: 145, max: 232 },
confidence: 0.48,
callId: 'whole-call-1',
model: 'test-multimodal-model',
referenceRowIds: ['row-1'],
sourceAudit: expect.objectContaining({
calculationRule: 'multimodal-whole-product-estimate/v1',
companyReferenceCount: 1,
}),
})
const payload = JSON.parse(String(callInput?.userContent))
expect(payload.product.attributes).toContain('原木色')
expect(payload.constraints.mustAttemptEstimateWhenProductContextExists).toBe(true)
})
})

View File

@ -3,6 +3,7 @@ import 'server-only'
import { z } from 'zod'
import type {
DynamicBomPriceQuote,
DynamicBomRealtimeMarketQuote,
DynamicBomScenarioKind,
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
import {
@ -16,6 +17,8 @@ import {
const AI_COST_ESTIMATE_RULE = 'multimodal-reference-cost-estimate/v1'
const AI_COST_PROMPT_VERSION = 'bom-costing-ai-estimate/v1'
const AI_WHOLE_PRODUCT_ESTIMATE_RULE = 'multimodal-whole-product-estimate/v1'
const AI_WHOLE_PRODUCT_PROMPT_VERSION = 'bom-costing-whole-product-estimate/v1'
export interface BomCostingAiEstimateRequestLine {
requestId: string
@ -83,6 +86,27 @@ export interface BomCostingAiEstimateInput {
requests: BomCostingAiEstimateRequestLine[]
knownLines: BomCostingAiKnownLine[]
companyReferences: BomCostingAiCompanyReference[]
realtimeMarketReference?: DynamicBomRealtimeMarketQuote | null
}
export interface BomCostingAiWholeProductEstimateInput {
product: BomCostingAiEstimateInput['product'] & {
attributes: unknown
}
knownLines: BomCostingAiKnownLine[]
companyReferences: BomCostingAiCompanyReference[]
}
export interface BomCostingAiWholeProductEstimateResult {
totalCost: number | null
estimateRange: { min: number; max: number } | null
confidence: number | null
reason: string | null
referenceRowIds: string[]
warning: string | null
callId: string | null
model: string | null
sourceAudit: Record<string, unknown> | null
}
export interface BomCostingAiEstimateResult {
@ -120,6 +144,15 @@ const aiEstimateLineSchema = z.object({
referenceRowIds: z.array(z.string().trim().min(1).max(200)).max(30).default([]),
})
const wholeProductEstimateSchema = z.object({
estimatedTotal: z.coerce.number().finite().positive().max(1_000_000),
estimateLow: z.coerce.number().finite().positive().max(1_000_000),
estimateHigh: z.coerce.number().finite().positive().max(1_000_000),
confidence: z.coerce.number().finite().min(0.1).max(0.75),
reason: z.string().trim().min(4).max(500),
referenceRowIds: z.array(z.string().trim().min(1).max(200)).max(30).default([]),
})
export interface ParsedBomCostingAiEstimate {
requestId: string
estimatedSubtotal: number
@ -165,6 +198,27 @@ export function parseBomCostingAiEstimatePayload(
return estimates
}
export function parseBomCostingAiWholeProductEstimatePayload(
payload: unknown,
availableReferenceIds: ReadonlySet<string>,
) {
const parsed = wholeProductEstimateSchema.parse(payload)
const point = Number(parsed.estimatedTotal.toFixed(2))
const lower = Math.min(parsed.estimateLow, parsed.estimateHigh, point)
const upper = Math.max(parsed.estimateLow, parsed.estimateHigh, point)
return {
totalCost: point,
estimateRange: {
min: Number(lower.toFixed(2)),
max: Number(upper.toFixed(2)),
},
confidence: Math.min(0.5, parsed.confidence),
reason: parsed.reason,
referenceRowIds: Array.from(new Set(parsed.referenceRowIds
.filter((rowId) => availableReferenceIds.has(rowId)))),
}
}
function emptyResult(input: {
requestedCount: number
warning?: string | null
@ -198,6 +252,7 @@ function buildSystemPrompt() {
return [
'你是家具制造成本预估助手。任务是补齐缺少直接公司价格的 BOM 明细,不是生成正式采购报价。',
'公司历史 BOM、公司主数据和已核算明细都是参考证据,不是缺一不可的硬门槛。',
'若提供了 1688 实时同款采购预估,它是整件 SKU 的当前市场锚点:优先用于让缺价明细的总和保持合理,但不得把整件价格伪装成某一块板材或五金的精确单价。',
'当没有完全相同的公司记录时,结合部件结构、材质、尺寸、数量、同类公司记录和常见家具制造成本关系,给出合理的工厂成本区间。',
'不得因为缺少完全相同的价格来源而拒绝预估,也不得返回 0、null 或把竞品售价倒推成成本。',
'优先使用 companyReferences;证据较弱时降低 confidence、扩大区间并在 reason 中说明。',
@ -208,6 +263,37 @@ function buildSystemPrompt() {
].join('\n')
}
function buildWholeProductSystemPrompt() {
return [
'你是家具制造整件成本预估助手。任务是根据产品资料、已识别的结构、尺寸、材质和公司历史知识,为一个 SKU 给出可用于业务判断的整件成本预估。',
'这不是正式采购报价,也不是要求资料齐全后才能计算的精确核算。公司历史 BOM 和已核算明细只是参考证据,不是硬门槛。',
'即使有部分部件没有直接价格,也必须在产品名称、尺寸、结构或已知成本中存在任一有效信息时给出非零整件预估;通过降低 confidence 和扩大区间表达不确定性。',
'不能返回 0、null、或因为缺少完全相同的价格来源而拒绝预估。只有完全没有可识别产品资料时才可以不输出结果。',
'不得把市场售价、外部商品价格或链接当作正式采购价;输出仅代表结合现有资料得出的制造成本区间。',
'严格输出一个 JSON 对象:',
'{"estimatedTotal":123.45,"estimateLow":100,"estimateHigh":150,"confidence":0.4,"reason":"...","referenceRowIds":["..."]}',
'不要输出 Markdown,不要输出 JSON 以外的文字。',
].join('\n')
}
function emptyWholeProductResult(input?: {
warning?: string | null
callId?: string | null
model?: string | null
}): BomCostingAiWholeProductEstimateResult {
return {
totalCost: null,
estimateRange: null,
confidence: null,
reason: null,
referenceRowIds: [],
warning: input?.warning ?? null,
callId: input?.callId ?? null,
model: input?.model ?? null,
sourceAudit: null,
}
}
export class BomCostingAiCostEstimator {
constructor(private readonly gateway: BomCostingAiEstimatorGateway = defaultGateway) {}
@ -245,10 +331,20 @@ export class BomCostingAiCostEstimator {
})),
knownCostLines: input.knownLines.slice(0, 80),
companyReferences: referenceRows,
realtimeMarketReference: input.realtimeMarketReference?.status === 'AVAILABLE'
? {
procurementCost: input.realtimeMarketReference.procurementCost,
estimateRange: input.realtimeMarketReference.estimateRange,
confidence: input.realtimeMarketReference.confidence,
comparableOfferCount: input.realtimeMarketReference.comparableOfferCount,
procurementRate: input.realtimeMarketReference.procurementRate,
}
: null,
constraints: {
estimatesAreReferenceOnly: true,
officialPriceRequired: false,
competitorSalePriceProvided: false,
realtimeMarketReferenceProvided: input.realtimeMarketReference?.status === 'AVAILABLE',
preserveUncertainty: true,
},
}
@ -343,6 +439,114 @@ export class BomCostingAiCostEstimator {
})
}
}
async estimateWholeProductCost(
input: BomCostingAiWholeProductEstimateInput,
): Promise<BomCostingAiWholeProductEstimateResult> {
const hasUsableProductContext = Boolean(
input.product.name.trim() ||
input.product.structureGroupKey.trim() ||
input.product.costFamilyKey.trim() ||
input.product.lengthMm ||
input.product.widthMm ||
input.product.heightMm ||
input.knownLines.length > 0,
)
if (!hasUsableProductContext) {
return emptyWholeProductResult({
warning: '产品资料不足,暂无法形成整件预估',
})
}
let config: BomCostingMultimodalProviderConfig | null
try {
config = await this.gateway.getConfig()
} catch (error) {
console.warn('[BOM costing] whole-product AI estimator config unavailable', {
error: error instanceof Error ? error.message : String(error),
})
return emptyWholeProductResult({
warning: '多模态整件预估服务暂时不可用,可稍后重新核算',
})
}
if (!config) {
return emptyWholeProductResult({
warning: '尚未配置多模态整件预估服务,暂无法形成整件预估',
})
}
const referenceRows = input.companyReferences.slice(0, 80)
const userPayload = {
schemaVersion: AI_WHOLE_PRODUCT_PROMPT_VERSION,
currency: 'CNY',
product: {
...input.product,
attributes: compactEvidence(input.product.attributes),
},
knownCostLines: input.knownLines.slice(0, 80),
companyReferences: referenceRows,
constraints: {
estimatesAreReferenceOnly: true,
officialPriceRequired: false,
preserveUncertainty: true,
mustAttemptEstimateWhenProductContextExists: true,
},
}
try {
const call = await this.gateway.call({
config,
operation: 'SYNTHESIS',
systemPrompt: buildWholeProductSystemPrompt(),
userContent: JSON.stringify(userPayload),
maxTokens: readBomCostingBoundedInteger(
process.env.BOM_COSTING_AI_WHOLE_PRODUCT_MAX_TOKENS,
1_500,
500,
4_000,
),
timeoutMs: readBomCostingBoundedInteger(
process.env.BOM_COSTING_AI_WHOLE_PRODUCT_TIMEOUT_MS,
60_000,
10_000,
120_000,
),
retrySynthesisTimeout: false,
})
const estimate = parseBomCostingAiWholeProductEstimatePayload(
this.gateway.parse(call.content),
new Set(referenceRows.map((row) => row.sourceRowId)),
)
return {
...estimate,
warning: null,
callId: call.id,
model: call.model,
sourceAudit: {
promptVersion: AI_WHOLE_PRODUCT_PROMPT_VERSION,
calculationRule: AI_WHOLE_PRODUCT_ESTIMATE_RULE,
model: call.model,
callId: call.id,
reason: estimate.reason,
durationMs: call.durationMs,
promptTokens: call.promptTokens,
completionTokens: call.completionTokens,
companyReferenceCount: referenceRows.length,
knownCostLineCount: input.knownLines.length,
referenceRowIds: estimate.referenceRowIds,
},
}
} catch (error) {
console.warn('[BOM costing] whole-product AI estimate failed without blocking scenario persistence', {
model: config.model,
error: error instanceof Error ? error.message : String(error),
})
return emptyWholeProductResult({
model: config.model,
warning: '多模态整件预估本次未返回可用区间,可稍后重新核算',
})
}
}
}
export const bomCostingAiCostEstimator = new BomCostingAiCostEstimator()

View File

@ -1254,6 +1254,96 @@ describe('BomCostingProcessingService', () => {
)
})
it('平台 SKU 规格差异与相同结构拆解冲突时仍进入整件预估阶段', async () => {
const variantSnapshot: MarketDetailSnapshot = {
...snapshot,
skus: [
{
...snapshot.skus[0]!,
externalSkuId: 'sku-walnut-80',
name: '胡桃木 80cm 规格',
size: '80cm',
attributes: { 材质: '胡桃木', 尺寸: '80cm' },
},
{
...snapshot.skus[0]!,
externalSkuId: 'sku-oak-100',
name: '橡木 100cm 规格',
size: '100cm',
attributes: { 材质: '橡木', 尺寸: '100cm' },
},
],
skuTotalRows: 2,
skuReturnedRows: 2,
skuTruncated: false,
}
mockBuildStoredSnapshot.mockResolvedValue(variantSnapshot)
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY) as never)
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY) as never)
mockRunAnalysis.mockResolvedValue({
result: {
...analysisResult,
skus: variantSnapshot.skus.map((sku) => ({
...analysisResult.skus[0]!,
externalSkuId: sku.externalSkuId,
name: sku.name,
attributes: sku.attributes,
})),
},
trace: {
model: 'grok-4.5',
callId: 'call-structure-review',
imageCount: 2,
inputSkuCount: 2,
durationMs: 12_000,
promptTokens: 50,
completionTokens: 80,
},
})
jest.spyOn(bomCostingScenarioService, 'calculateSameMaterialForCase')
.mockResolvedValueOnce({
totalSkuCount: 2,
succeededSkuCount: 2,
costedSkuCount: 2,
pendingSkuCount: 0,
failedSkuCount: 0,
succeededSkuIds: ['sku-db-walnut-80', 'sku-db-oak-100'],
costedSkuIds: ['sku-db-walnut-80', 'sku-db-oak-100'],
pendingSkuIds: [],
failures: [],
reviewSkuCount: 0,
reviewSkuIds: [],
consistencyWarnings: [],
})
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
expect.objectContaining({
skus: expect.arrayContaining([
expect.objectContaining({ externalSkuId: 'sku-walnut-80' }),
expect.objectContaining({ externalSkuId: 'sku-oak-100' }),
]),
eventPayload: expect.objectContaining({ qualityWarningCount: 1 }),
}),
)
expect(bomCostingScenarioService.calculateSameMaterialForCase)
.toHaveBeenCalledWith('case-1')
expect(bomCostingRepository.finalizeCaseDynamicCosting).toHaveBeenCalledWith(
expect.objectContaining({
caseId: 'case-1',
message: expect.stringContaining('整件预估已保留'),
payload: expect.objectContaining({ qualityWarningCount: 1 }),
}),
)
})
it('紧凑成本阶段仅有个别 SKU 待补成本时仍完成整单并保留可继续核算状态', async () => {
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(

View File

@ -284,6 +284,7 @@ async function completeDynamicCostingPhase(input: {
caseId: string
analysisRunId: string
skus: Array<{ externalSkuId: string }>
qualityWarningCount?: number
}) {
let ownershipLost = false
let heartbeatInFlight: Promise<void> | null = null
@ -319,13 +320,15 @@ async function completeDynamicCostingPhase(input: {
}, DYNAMIC_COST_HEARTBEAT_INTERVAL_MS)
const skuCount = input.skus.length
let dynamicCosting: Awaited<ReturnType<
type DynamicCostingCompletion = Awaited<ReturnType<
typeof bomCostingScenarioService.calculateSameMaterialForCase
>>
>> & { qualityWarningCount: number }
let dynamicCosting: DynamicCostingCompletion
try {
dynamicCosting = await bomCostingScenarioService.calculateSameMaterialForCase(
input.caseId,
)
dynamicCosting = {
...await bomCostingScenarioService.calculateSameMaterialForCase(input.caseId),
qualityWarningCount: input.qualityWarningCount ?? 0,
}
} catch (error) {
const message = readErrorMessage(error)
dynamicCosting = {
@ -345,6 +348,7 @@ async function completeDynamicCostingPhase(input: {
reviewSkuCount: 0,
reviewSkuIds: [],
consistencyWarnings: [],
qualityWarningCount: input.qualityWarningCount ?? 0,
}
} finally {
clearInterval(heartbeatTimer)
@ -362,13 +366,16 @@ async function completeDynamicCostingPhase(input: {
? `所有 SKU 的成本生成均失败:${firstFailure}`
: '所有 SKU 的成本生成均失败,请按现有事实重新核算')
}
const finalMessage = dynamicCosting.failedSkuCount > 0
const finalMessageBase = dynamicCosting.failedSkuCount > 0
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.failedSkuCount} 个成本生成失败,可按现有事实重新核算`
: dynamicCosting.pendingSkuCount > 0
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.pendingSkuCount} 个仍缺公司价格资料`
: dynamicCosting.reviewSkuCount > 0
? `成本预估已生成,共 ${dynamicCosting.totalSkuCount} 个 SKU;${dynamicCosting.reviewSkuCount} 个尺寸与成本关系待复核`
: `核算完成,共 ${dynamicCosting.totalSkuCount} 个 SKU,已生成同材质成本预估`
const finalMessage = dynamicCosting.qualityWarningCount > 0
? `${finalMessageBase};结构拆解差异已标记待复核,整件预估已保留`
: finalMessageBase
await bomCostingRepository.finalizeCaseDynamicCosting({
caseId: input.caseId,
analysisRunId: input.analysisRunId,
@ -1594,16 +1601,20 @@ export class BomCostingProcessingService {
[BomCostingLineCategory.LABOR]: [],
}
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
if (canGenerateCost) {
assertBomCostingResultQuality({
const qualityWarnings = canGenerateCost
? assertBomCostingResultQuality({
analysis: persistedResult,
sourceSnapshot: marketSnapshot,
preparedSkus,
allowIncompleteCosting,
allowUnverifiedSkuStructures: isFactBasedStructure,
})
: []
const stats = {
...buildResultStats(preparedSkus),
qualityWarningCount: qualityWarnings.length,
qualityWarnings,
}
const stats = buildResultStats(preparedSkus)
const completedFailedFactImages = buildFailedFactImagePayload(
completedFactSnapshot,
analysisPlanByIdentity,
@ -1695,6 +1706,7 @@ export class BomCostingProcessingService {
caseId,
analysisRunId,
skus: preparedSkus,
qualityWarningCount: qualityWarnings.length,
})
} catch (error) {
if (error instanceof BomCostingTaskOwnershipLostError) {

View File

@ -194,7 +194,7 @@ function buildV3Analysis(skuIds: string[]): CompetitorAnalysisResultV3 {
}
describe('BOM 核算结果质量保护', () => {
it('来源 SKU 的尺寸、材质或是否含镜不同却生成相同 BOM 时拒绝保存', () => {
it('来源 SKU 的尺寸、材质或是否含镜不同却生成相同 BOM 时保留待复核并继续预估', () => {
const sourceSnapshot = buildSourceSnapshot([
{
externalSkuId: 'solid-64-no-mirror',
@ -219,11 +219,15 @@ describe('BOM 核算结果质量保护', () => {
])
const analysis = buildAnalysis(sourceSnapshot.skus)
expect(() => assertBomCostingResultQuality({
const warnings = assertBomCostingResultQuality({
analysis,
sourceSnapshot,
preparedSkus: sourceSnapshot.skus.map((sku) => buildPreparedSku(sku.externalSkuId)),
})).toThrow('SKU 结构可信度校验未通过')
})
expect(warnings).toEqual([
expect.stringContaining('已标记待复核;整件预估继续生成'),
])
})
it('仅颜色不同的 SKU 可以复用相同 BOM', () => {
@ -303,11 +307,11 @@ describe('BOM 核算结果质量保护', () => {
})).not.toThrow()
})
it('v2 蓝图要求每个兄弟 SKU 都保留必要部件', () => {
it('v2 蓝图缺少必要部件时标记待复核,不阻断整件预估', () => {
const analysis = buildV2Analysis(['sku-small', 'sku-large'])
analysis.skus[1]!.bomLines = []
expect(() => assertBomCostingResultQuality({
const warnings = assertBomCostingResultQuality({
analysis,
sourceSnapshot: null,
preparedSkus: [
@ -315,7 +319,11 @@ describe('BOM 核算结果质量保护', () => {
buildPreparedSku('sku-large', null),
],
allowIncompleteCosting: true,
})).toThrow('sku-large缺少board-top')
})
expect(warnings).toEqual([
expect.stringContaining('sku-large缺少board-top'),
])
})
it('v2 同结构组尺寸变大但板材面积异常时只标记当前 SKU 待复核', () => {
@ -343,11 +351,11 @@ describe('BOM 核算结果质量保护', () => {
expect(analysis.skus[1]?.bomLines[0]?.confidence).toBeLessThanOrEqual(0.45)
})
it('v3 每个 SKU 的结构组和成本族必须反向属于蓝图', () => {
it('v3 SKU 的结构组或成本族引用异常时保留待复核,不阻断整件预估', () => {
const analysis = buildV3Analysis(['sku-small', 'sku-large'])
analysis.skus[1]!.costFamilyKey = 'wardrobe:cost:不存在'
expect(() => assertBomCostingResultQuality({
const warnings = assertBomCostingResultQuality({
analysis,
sourceSnapshot: null,
preparedSkus: [
@ -355,7 +363,11 @@ describe('BOM 核算结果质量保护', () => {
buildPreparedSku('sku-large', null),
],
allowIncompleteCosting: true,
})).toThrow('sku-large的成本族引用无效')
})
expect(warnings).toEqual([
expect.stringContaining('sku-large的成本族引用无效'),
])
})
it('能识别米制尺寸和衣柜结构款式差异,避免相同 BOM 混组', () => {
@ -382,13 +394,17 @@ describe('BOM 核算结果质量保护', () => {
},
])
expect(() => assertBomCostingResultQuality({
const warnings = assertBomCostingResultQuality({
analysis: buildAnalysis(sourceSnapshot.skus),
sourceSnapshot,
preparedSkus: sourceSnapshot.skus.map((sku) => (
buildPreparedSku(sku.externalSkuId)
)),
})).toThrow('SKU 结构可信度校验未通过')
})
expect(warnings).toEqual([
expect.stringContaining('已标记待复核;整件预估继续生成'),
])
})
it('同成本族板材面积增加超过5%却无依据降本超过10%时标记待复核而不拒绝整批', () => {
@ -494,7 +510,7 @@ describe('BOM 核算结果质量保护', () => {
expect(boardOnly.attributes).not.toHaveProperty('costingConsistencyStatus')
})
it('显式无抽屉结构出现蓝图未启用的抽屉行时拒绝保存', () => {
it('显式无抽屉结构出现未启用的抽屉行时标记待复核,不阻断整件预估', () => {
const analysis = buildV3Analysis(['sku-no-drawer'])
const group = analysis.structureBlueprint.structureGroups[0]!
group.structureGroupKey = 'wardrobe:structure:door-4.hanging.no-drawer'
@ -510,11 +526,13 @@ describe('BOM 核算结果质量保护', () => {
confidence: 0.8,
})
expect(() => assertBomCostingResultQuality({
const warnings = assertBomCostingResultQuality({
analysis,
sourceSnapshot: null,
preparedSkus: [buildPreparedSku('sku-no-drawer', null)],
allowIncompleteCosting: true,
})).toThrow(/蓝图未启用的drawer-box|互斥结构禁止的drawer-box/)
})
expect(warnings.join(';')).toMatch(/蓝图未启用的drawer-box|互斥结构禁止的drawer-box/)
})
})

View File

@ -179,7 +179,7 @@ function buildBomFingerprint(sku: CompetitorSkuV1) {
.join('\u001f')
}
function assertDistinctSourceSkuStructures(
function collectDistinctSourceSkuStructureWarnings(
analysis: CompetitorAnalysisResult,
source: MarketDetailSnapshot,
) {
@ -220,14 +220,18 @@ function assertDistinctSourceSkuStructures(
}
}
if (violations.length === 0) return
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})。请重新核算或补充复核图片`,
)
// Source-side specification differences tell us that this BOM needs review;
// they do not mean the product, its images, or the available cost knowledge
// disappeared. Keep the signal for audit, but let the whole-product estimate
// continue instead of discarding every SKU in the run.
return [
`检测到 ${violations.length} 组 SKU 规格差异与当前结构拆解不完全一致,已标记待复核;整件预估继续生成(${examples})`,
]
}
function assertPreparedCostRange(skus: BomCostingPreparedSku[]) {
@ -450,12 +454,12 @@ function horizontalSizeMm(sku: CompetitorSkuV1) {
return values.length > 0 ? Math.max(...values) : null
}
function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult) {
if (analysis.schemaVersion === 'competitor-analysis/v1') return
function collectStructureBlueprintWarnings(analysis: CompetitorAnalysisResult) {
if (analysis.schemaVersion === 'competitor-analysis/v1') return []
if (
analysis.schemaVersion === 'competitor-analysis/v3' &&
analysis.structureBlueprint.supportStatus !== 'SUPPORTED'
) return
) return []
const analyzedById = new Map(
analysis.skus.map((sku) => [sku.externalSkuId, sku] as const),
)
@ -466,7 +470,7 @@ function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult)
blueprintSkuIds.length !== analysis.skus.length ||
analysis.skus.some((sku) => !blueprintSkuIds.includes(sku.externalSkuId))
) {
throw new Error('产品结构蓝图与真实 SKU 数量不一致,系统未接受该结果')
return ['产品结构蓝图与真实 SKU 的对应关系不完整,已保留可识别 SKU 的整件预估并标记待复核']
}
const violations: string[] = []
@ -574,11 +578,9 @@ function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult)
}
}
}
if (violations.length > 0) {
throw new Error(
`结构蓝图一致性校验未通过:${violations.slice(0, 5).join(';')}`,
)
}
return violations.length > 0
? [`结构拆解存在 ${violations.length} 项待复核:${violations.slice(0, 5).join(';')}`]
: []
}
export function assertBomCostingResultQuality(input: {
@ -600,7 +602,7 @@ export function assertBomCostingResultQuality(input: {
*/
allowUnverifiedSkuStructures?: boolean
}) {
assertStructureBlueprintConsistency(input.analysis)
const warnings = collectStructureBlueprintWarnings(input.analysis)
if (!input.allowIncompleteCosting) {
assertPreparedCostRange(input.preparedSkus)
}
@ -608,6 +610,10 @@ export function assertBomCostingResultQuality(input: {
const allowUnverifiedSkuStructures =
input.allowUnverifiedSkuStructures ?? input.allowIncompleteCosting ?? false
if (input.sourceSnapshot && !allowUnverifiedSkuStructures) {
assertDistinctSourceSkuStructures(input.analysis, input.sourceSnapshot)
warnings.push(...collectDistinctSourceSkuStructureWarnings(
input.analysis,
input.sourceSnapshot,
))
}
return Array.from(new Set(warnings))
}

View File

@ -13,6 +13,7 @@ import {
buildDynamicCostBreakdown,
} from './bom-costing-scenario.service'
import { bomCostingAiCostEstimator } from './bom-costing-ai-cost-estimator'
import { bomCosting1688ImageSearchService } from './bom-costing-1688-image-search'
const now = new Date()
@ -158,6 +159,22 @@ function mockBase(input: {
describe('BomCostingScenarioService', () => {
beforeEach(() => {
jest.spyOn(bomCosting1688ImageSearchService, 'findRealtimeQuote').mockResolvedValue({
schemaVersion: 'bom-costing-1688-realtime/v1',
source: '1688_IMAGE_SEARCH',
status: 'NOT_CONFIGURED',
sourceImageUrl: null,
searchedAt: null,
procurementRate: 0.9,
marketPrice: null,
procurementCost: null,
estimateRange: null,
confidence: null,
comparableOfferCount: 0,
returnedOfferCount: 0,
warnings: [],
offers: [],
})
jest.spyOn(bomCostingAiCostEstimator, 'estimateMissingCosts').mockResolvedValue({
quotes: { SAME_MATERIAL: {}, MATERIAL_SUBSTITUTION: {} },
requestedCount: 0,
@ -166,6 +183,17 @@ describe('BomCostingScenarioService', () => {
callId: null,
model: null,
})
jest.spyOn(bomCostingAiCostEstimator, 'estimateWholeProductCost').mockResolvedValue({
totalCost: null,
estimateRange: null,
confidence: null,
reason: null,
referenceRowIds: [],
warning: null,
callId: null,
model: null,
sourceAudit: null,
})
jest.spyOn(bomCostingScenarioRepository, 'invalidateCurrentScenarios')
.mockResolvedValue({ count: 0 })
jest.spyOn(bomCostingScenarioRepository, 'listCaseDynamicConsistencyRows')
@ -178,6 +206,61 @@ describe('BomCostingScenarioService', () => {
jest.restoreAllMocks()
})
it('uses a live market reference for the whole-SKU estimate while retaining structural detail for audit', async () => {
mockBase({
bomLines: [line({
id: 'drawer-hardware',
category: 'ACCESSORY',
partName: '抽屉盒五金',
materialName: '橡胶木',
unit: '套',
})],
})
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([
candidate({
id: 'history-hardware',
category: 'ACCESSORY',
partName: '抽屉盒五金',
materialName: '橡胶木',
unitPrice: 100,
}),
])
jest.mocked(bomCosting1688ImageSearchService.findRealtimeQuote).mockResolvedValue({
schemaVersion: 'bom-costing-1688-realtime/v1',
source: '1688_IMAGE_SEARCH',
status: 'AVAILABLE',
sourceImageUrl: 'https://images.example.com/sku.jpg',
searchedAt: '2026-08-15T10:00:00.000Z',
procurementRate: 0.9,
marketPrice: 120,
procurementCost: 108,
estimateRange: { min: 99, max: 117 },
confidence: 0.84,
comparableOfferCount: 3,
returnedOfferCount: 3,
warnings: [],
offers: [],
})
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
})
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]?.[0]
expect(saved?.sameMaterial).toEqual(expect.objectContaining({
total: 108,
estimateBasis: 'AI_REFERENCE_ESTIMATE',
status: 'PROVISIONAL',
realtimeMarketQuote: expect.objectContaining({
procurementCost: 108,
structuralCost: 100,
structuralEstimateRange: { min: 78, max: 122 },
}),
}))
expect(bomCostingAiCostEstimator.estimateMissingCosts).not.toHaveBeenCalled()
})
it('recalculates both scenarios from company prices and substitutes only the target category', async () => {
const bomLines = [
line({
@ -386,6 +469,34 @@ describe('BomCostingScenarioService', () => {
expect(saved.sameMaterial.lines[0]?.warning).toContain('不是公司正式采购价格')
})
it('实时参考与模型补齐都没有金额时保留资料不足,而不伪造零元成本', async () => {
mockBase({ bomLines: [line({
id: 'unpriced-accessory',
category: 'ACCESSORY',
partName: '未知配件',
unit: '件',
quantity: 1,
})] })
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
})
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]?.[0]
expect(saved?.sameMaterial).toMatchObject({
total: null,
estimateRange: null,
status: 'INSUFFICIENT_DATA',
unresolvedCount: 1,
})
expect(saved?.sameMaterial.lines[0]).toMatchObject({
subtotal: null,
status: 'INSUFFICIENT_DATA',
})
})
it('替换材质没有直接公司价格时也形成低可信替换预估,不阻断整套方案', async () => {
const bomLines = [line({
id: 'drawer-box',
@ -2887,18 +2998,132 @@ describe('BomCostingScenarioService', () => {
})
})
it('only reports structure insufficiency when the SKU has no BOM lines at all', async () => {
it('uses a whole-product model estimate when the SKU has no BOM lines yet', async () => {
mockBase({ bomLines: [] })
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
jest.mocked(bomCostingAiCostEstimator.estimateWholeProductCost).mockResolvedValue({
totalCost: 188,
estimateRange: { min: 145, max: 232 },
confidence: 0.42,
reason: '结合鞋柜名称、外形尺寸和相近柜体制造成本关系形成预估',
referenceRowIds: ['history-row-1'],
warning: null,
callId: 'whole-call-1',
model: 'multimodal-cost-model',
sourceAudit: {
calculationRule: 'multimodal-whole-product-estimate/v1',
callId: 'whole-call-1',
},
})
const service = new BomCostingScenarioService()
await service.calculateSameMaterial({ caseId: 'case-1', skuId: 'sku-1' })
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
expect(saved.sameMaterial).toMatchObject({
total: 188,
estimateRange: { min: 145, max: 232 },
status: 'PROVISIONAL',
estimateBasis: 'AI_REFERENCE_ESTIMATE',
unresolvedCount: 0,
wholeProductEstimateAudit: expect.objectContaining({
calculationRule: 'multimodal-whole-product-estimate/v1',
}),
})
expect(saved.sameMaterial.lines).toEqual([])
})
it('uses an available whole-SKU reference even before structure lines exist', async () => {
mockBase({ bomLines: [] })
jest.mocked(bomCosting1688ImageSearchService.findRealtimeQuote).mockResolvedValue({
schemaVersion: 'bom-costing-1688-realtime/v1',
source: '1688_IMAGE_SEARCH',
status: 'AVAILABLE',
sourceImageUrl: 'https://images.example.com/sku.jpg',
searchedAt: '2026-08-17T04:00:00.000Z',
procurementRate: 0.9,
marketPrice: 200,
procurementCost: 180,
estimateRange: { min: 162, max: 198 },
confidence: 0.7,
comparableOfferCount: 5,
returnedOfferCount: 6,
warnings: [],
offers: [],
})
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
})
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
expect(saved.sameMaterial).toMatchObject({
total: 180,
estimateRange: { min: 162, max: 198 },
status: 'PROVISIONAL',
unresolvedCount: 0,
wholeProductEstimateAudit: null,
})
expect(bomCostingAiCostEstimator.estimateWholeProductCost).not.toHaveBeenCalled()
})
it('keeps unresolved component detail as pending while still saving a whole-product estimate', async () => {
mockBase({ bomLines: [line({
id: 'drawer-box',
category: 'BOARD',
partName: '抽屉盒板件',
materialName: '橡胶木',
unit: '件',
quantity: 1,
})] })
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
jest.mocked(bomCostingAiCostEstimator.estimateWholeProductCost).mockResolvedValue({
totalCost: 180,
estimateRange: { min: 135, max: 228 },
confidence: 0.4,
reason: '结合抽屉柜结构、产品尺寸和公司同类柜体知识形成整件预估',
referenceRowIds: [],
warning: null,
callId: 'whole-call-2',
model: 'multimodal-cost-model',
sourceAudit: { callId: 'whole-call-2' },
})
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
})
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
expect(saved.sameMaterial).toMatchObject({
total: 180,
estimateRange: { min: 135, max: 228 },
status: 'PROVISIONAL',
unresolvedCount: 1,
estimateBasis: 'AI_REFERENCE_ESTIMATE',
})
expect(saved.sameMaterial.warning).toContain('未覆盖明细已标记为待补')
expect(saved.sameMaterial.lines[0]).toMatchObject({
subtotal: null,
status: 'INSUFFICIENT_DATA',
})
})
it('only reports insufficient information when neither a whole-SKU reference nor agent estimate exists', async () => {
mockBase({ bomLines: [] })
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
})
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
expect(saved.sameMaterial).toMatchObject({
total: null,
status: 'INSUFFICIENT_DATA',
warning: expect.stringContaining('没有识别出可用于成本预估的产品结构'),
warning: expect.stringContaining('资料不足,暂无法预估'),
})
})
@ -3030,6 +3255,29 @@ describe('BomCostingScenarioService', () => {
})
})
it('keeps every component category pending when only a whole-SKU estimate exists', () => {
const result = {
lines: [],
} as DynamicBomScenarioResult
expect(buildDynamicCostBreakdown(result)).toEqual({
categoryTotals: {
MATERIAL: null,
PROCESSING: null,
LABOR: null,
PACKAGING: null,
},
categoryEstimateRanges: {
MATERIAL: null,
PROCESSING: null,
LABOR: null,
PACKAGING: null,
},
lossCost: null,
lossEstimateRange: null,
})
})
it('keeps a line unresolved when the company price unit cannot be converted', async () => {
const bomLines = [line({
id: 'board',
@ -3070,7 +3318,7 @@ describe('BomCostingScenarioService', () => {
})
})
it('serializes loss and price provenance fields for the workbench', async () => {
it('serializes a unified public estimate without price-source provenance', async () => {
jest.spyOn(bomCostingScenarioRepository, 'listCurrentScenarios').mockResolvedValue([{
id: 'scenario-1',
skuId: 'sku-1',
@ -3095,6 +3343,15 @@ describe('BomCostingScenarioService', () => {
dataAsOf: now,
syncRunId: 'run-success',
rulesetVersion: 'dynamic-bom-cost/v1',
estimateBasis: 'REALTIME_1688_MARKET',
realtimeMarketQuote: {
source: '1688_IMAGE_SEARCH',
offers: [{ supplier: 'private', detailUrl: 'https://private.example.com' }],
},
wholeProductEstimateAudit: {
model: 'private-model',
referenceRowIds: ['private-row'],
},
createdAt: now,
syncRun: {
status: 'SUCCEEDED',
@ -3146,28 +3403,59 @@ describe('BomCostingScenarioService', () => {
expect(scenario).toMatchObject({
totalCost: 152,
lossCost: 5,
competitorPrice: 100,
priceDelta: 52,
syncRunId: 'run-success',
syncStatus: 'SUCCEEDED',
boardCutting: [{
sourceLineId: 'board',
materialName: '黑胡桃木',
rulesetVersion: 'board-sheet-maxrects/v2-batch',
selectedBatchSize: 16,
batchSheetCount: 2,
perUnitSheetCost: 25,
utilization: 0.9675,
singleUnitSheetCount: 1,
singleUnitSheetCost: 200,
}],
estimateRange: null,
})
expect(scenario?.lines[0]).toMatchObject({
sourceRowId: 'reference-row-1',
sourceCreatedAt: now.toISOString(),
sourceSyncedAt: now.toISOString(),
calculationRule: 'board-sheet-maxrects/v2-batch',
partName: '桌面板',
subtotal: 55,
})
for (const key of [
'estimateBasis',
'sampleCount',
'estimatedLineCount',
'knownCost',
'competitorPrice',
'priceDelta',
'marketPriceAlert',
'realtimeMarketQuote',
'wholeProductEstimateAudit',
'syncRunId',
'syncStatus',
'syncCompletedAt',
'dataAsOf',
'rulesetVersion',
'boardCutting',
]) {
expect(scenario).not.toHaveProperty(key)
}
for (const key of [
'sourceLineId',
'sourceRowId',
'coefficient',
'pricingQuantity',
'unitPrice',
'priceSource',
'sourceSku',
'referenceSkus',
'referenceRowIds',
'sourceFingerprint',
'sourceCreatedAt',
'sourceSyncedAt',
'calculationRule',
'rawSnapshot',
]) {
expect(scenario?.lines[0]).not.toHaveProperty(key)
}
expect(JSON.stringify(scenario)).not.toMatch(/1688|private\.example\.com|supplier|公司历史|参考 SKU|竞品售价/iu)
})
it('hides historical material-substitution scenarios from the regular BOM contract', async () => {
jest.spyOn(bomCostingScenarioRepository, 'listCurrentScenarios').mockResolvedValue([{
kind: 'MATERIAL_SUBSTITUTION',
}] as never)
await expect(new BomCostingScenarioService().listScenarios({ caseId: 'case-1' }))
.resolves.toEqual([])
})
it('reports mirror completion as data freshness while exposing the upstream timestamp separately', async () => {

View File

@ -10,6 +10,7 @@ import {
type DynamicBomLineCostResult,
type DynamicBomEstimateBasis,
type DynamicBomPriceQuote,
type DynamicBomRealtimeMarketQuote,
type DynamicBomScenarioResult,
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
import {
@ -27,7 +28,11 @@ import {
type BomCostingAiCompanyReference,
type BomCostingAiEstimateRequestLine,
type BomCostingAiKnownLine,
type BomCostingAiWholeProductEstimateResult,
} from './bom-costing-ai-cost-estimator'
import {
bomCosting1688ImageSearchService,
} from './bom-costing-1688-image-search'
const CATEGORY_LABELS: Record<string, string> = {
BOARD: '板材',
@ -2247,6 +2252,117 @@ function toAiKnownLine(
}
}
async function loadWholeProductAiReferences(input: {
syncRunId: string | null
skuName: string
lines: BomCostingLine[]
}) {
if (!input.syncRunId) return []
const referenceLines = input.lines.length > 0
? input.lines
: [{
partName: input.skuName,
materialName: '',
materialSpec: '',
processName: '',
unit: '',
}]
const categories = Array.from(new Set(input.lines.map((line) => line.category)))
const referenceCategories = categories.length > 0
? categories
: ['BOARD', 'STEEL', 'ACCESSORY', 'PROCESSING', 'LABOR', 'PACKAGING']
const groups = await Promise.all(referenceCategories.map(async (category) => {
const candidates = await bomCostingScenarioRepository.findCategoryCohortCandidates({
syncRunId: input.syncRunId!,
category,
limit: 40,
})
return selectAiCompanyReferences({
candidates,
lines: referenceLines,
limit: 10,
})
}))
return Array.from(new Map(groups.flat().map((reference) => [
reference.sourceRowId,
reference,
])).values()).slice(0, 60)
}
function applyWholeProductAiEstimate(
result: DynamicBomScenarioResult,
estimate: BomCostingAiWholeProductEstimateResult,
) {
if (
result.scenario !== 'SAME_MATERIAL' ||
estimate.totalCost === null ||
estimate.estimateRange === null ||
estimate.confidence === null
) return null
result.total = estimate.totalCost
result.estimateRange = estimate.estimateRange
result.estimateBasis = 'AI_REFERENCE_ESTIMATE'
result.sampleCount = Math.max(result.sampleCount, estimate.referenceRowIds.length)
result.estimatedLineCount = Math.max(result.estimatedLineCount, 1)
result.confidence = Math.min(result.confidence ?? estimate.confidence, estimate.confidence)
if (result.status !== 'STALE') result.status = 'PROVISIONAL'
result.warnings.push(
'已依据产品资料、结构事实与知识参考形成整件预估;未覆盖明细已标记为待补',
)
result.warning = Array.from(new Set(result.warnings)).join(';')
return estimate.sourceAudit
}
/**
* A 1688 image-search result is a live price for a finished product, not an
* exact quote for every BOM component. We therefore keep the structural BOM
* result in the attached audit object and use the live procurement estimate as
* the SKU-level headline only for the original-material scenario.
*/
function applyRealtime1688ProcurementAnchor(
result: DynamicBomScenarioResult,
quote: DynamicBomRealtimeMarketQuote,
) {
const structuralCost = result.total
const structuralEstimateRange = result.estimateRange
result.realtimeMarketQuote = {
...quote,
structuralCost,
structuralEstimateRange,
}
if (
result.scenario !== 'SAME_MATERIAL' ||
quote.status !== 'AVAILABLE' ||
quote.procurementCost === null ||
quote.estimateRange === null
) return
result.total = quote.procurementCost
result.estimateRange = quote.estimateRange
// The private quote remains attached for recalculation and diagnostics. The
// regular BOM contract exposes this as a source-neutral comprehensive
// estimate, consistent with all other non-final estimate inputs.
result.estimateBasis = 'AI_REFERENCE_ESTIMATE'
result.sampleCount = quote.comparableOfferCount
result.confidence = quote.confidence
// A current image-search result is still an estimate, never a formal
// supplier quotation. It can however avoid presenting the whole SKU as
// unpriced merely because one historical BOM line is absent.
result.status = 'PROVISIONAL'
result.warnings.push('已形成整件预估,结构拆解用于解释构成与偏差复核')
if (structuralCost !== null) {
result.warnings.push(
'结构拆解预估已保留,仅用于解释构成与偏差复核',
)
} else {
result.warnings.push(
'结构拆解仍有待补明细;当前整件预估已形成,明细可继续补充事实或由模型预估',
)
}
result.warning = Array.from(new Set(result.warnings)).join(';')
}
function withRequiredEstimateCategories(
lines: BomCostingLine[],
skuId: string,
@ -2366,6 +2482,24 @@ function buildScenarioRawSnapshot(
export function buildDynamicCostBreakdown(result: DynamicBomScenarioResult) {
const zero = new Prisma.Decimal(0)
if (result.lines.length === 0) {
return {
categoryTotals: {
MATERIAL: null,
PROCESSING: null,
LABOR: null,
PACKAGING: null,
},
categoryEstimateRanges: {
MATERIAL: null,
PROCESSING: null,
LABOR: null,
PACKAGING: null,
},
lossCost: null,
lossEstimateRange: null,
}
}
const netByCategory: Record<string, Prisma.Decimal> = {
BOARD: zero,
STEEL: zero,
@ -2464,83 +2598,24 @@ function readMaterialAliases(value: unknown) {
)))
}
function readBoardCuttingSummary(
lines: BomCostingScenarioRecord['lines'],
) {
const summaries = new Map<string, {
sourceLineId: string | null
materialName: string
rulesetVersion: string
selectedBatchSize: number
batchSheetCount: number
perUnitSheetCost: number
utilization: number
singleUnitSheetCount: number
singleUnitSheetCost: number
}>()
for (const line of lines) {
if (line.category !== 'BOARD' || line.calculationRule !== 'board-sheet-maxrects/v2-batch') {
continue
}
if (typeof line.rawSnapshot !== 'object' || line.rawSnapshot === null || Array.isArray(line.rawSnapshot)) {
continue
}
const calculation = (line.rawSnapshot as Record<string, unknown>).calculation
if (typeof calculation !== 'object' || calculation === null || Array.isArray(calculation)) continue
const audit = (calculation as Record<string, unknown>).priceSourceAudit
if (typeof audit !== 'object' || audit === null || Array.isArray(audit)) continue
const source = audit as Record<string, unknown>
const singleUnit = typeof source.singleUnit === 'object' && source.singleUnit !== null && !Array.isArray(source.singleUnit)
? source.singleUnit as Record<string, unknown>
: null
const selectedBatchSize = Number(source.selectedBatchSize)
const batchSheetCount = Number(source.sheetCount)
const perUnitSheetCost = Number(source.perUnitSheetCost)
const utilization = Number(source.utilization)
const singleUnitSheetCount = Number(singleUnit?.sheetCount)
const singleUnitSheetCost = Number(singleUnit?.perUnitSheetCost)
if (![
selectedBatchSize,
batchSheetCount,
perUnitSheetCost,
utilization,
singleUnitSheetCount,
singleUnitSheetCost,
].every(Number.isFinite)) continue
const key = `${line.sourceFingerprint}:${line.materialSpec}:${selectedBatchSize}`
summaries.set(key, {
sourceLineId: line.sourceLineId,
materialName: line.appliedMaterial,
rulesetVersion: line.calculationRule,
selectedBatchSize,
batchSheetCount,
perUnitSheetCost,
utilization,
singleUnitSheetCount,
singleUnitSheetCost,
})
}
return Array.from(summaries.values())
function publicScenarioWarning(value: string) {
const sourceSpecificPattern = /(?:1688|实时同款|同款价|采购系数|市场价|竞品售价|公司(?:历史)?\s*BOM|参考\s*SKU|公司价格来源|基础资料库|正式采购|历史用量|镜像(?:同步|批次)?|供应商|店铺|商品链接)/iu
const genericWarning = '当前预估仍有待补信息,建议补充结构或尺寸后重新核算'
const warnings = value
.split(/;\s*/u)
.map((part) => part.trim())
.filter(Boolean)
.map((part) => (sourceSpecificPattern.test(part) ? genericWarning : part))
return Array.from(new Set(warnings)).join(';')
}
function serializeScenario(record: BomCostingScenarioRecord) {
const totalCost = record.totalCost === null ? null : Number(record.totalCost)
const storedCompetitorPrice = record.competitorPrice === null
? null
: Number(record.competitorPrice)
const competitorPrice =
record.rulesetVersion === 'dynamic-bom-cost/v3' &&
storedCompetitorPrice !== null &&
Number.isInteger(storedCompetitorPrice) &&
storedCompetitorPrice >= 1_000
? storedCompetitorPrice / 100
: storedCompetitorPrice
return {
id: record.id,
skuId: record.skuId,
kind: record.kind,
targetMaterialId: record.targetMaterialId,
targetMaterialName: record.targetMaterialName,
revision: record.revision,
isCurrent: record.isCurrent,
status: record.status,
@ -2590,40 +2665,17 @@ function serializeScenario(record: BomCostingScenarioRecord) {
: null
return result
})(),
estimateBasis: record.estimateBasis || null,
sampleCount: record.sampleCount ?? 0,
estimatedLineCount: record.estimatedLineCount ?? 0,
knownCost: record.knownCost === null ? null : Number(record.knownCost),
materialCost: record.materialCost === null ? null : Number(record.materialCost),
processingCost: record.processingCost === null ? null : Number(record.processingCost),
laborCost: record.laborCost === null ? null : Number(record.laborCost),
packagingCost: record.packagingCost === null ? null : Number(record.packagingCost),
lossCost: record.lossCost === null ? null : Number(record.lossCost),
competitorPrice,
priceDelta: totalCost === null || competitorPrice === null
? null
: Number((totalCost - competitorPrice).toFixed(2)),
marketPriceAlert: totalCost === null || competitorPrice === null
? null
: totalCost > competitorPrice + 0.01
? 'HIGH' as const
: totalCost < competitorPrice * 0.1
? 'LOW' as const
: null,
confidence: record.confidence === null ? null : Number(record.confidence),
warning: record.warning,
warning: publicScenarioWarning(record.warning),
unresolvedCount: record.unresolvedCount,
dataAsOf: record.dataAsOf?.toISOString() ?? null,
syncRunId: record.syncRunId,
syncStatus: record.syncRun?.status ?? null,
syncCompletedAt: record.syncRun?.completedAt?.toISOString() ?? null,
rulesetVersion: record.rulesetVersion,
createdAt: record.createdAt.toISOString(),
boardCutting: readBoardCuttingSummary(record.lines),
lines: record.lines.map((line) => ({
id: line.id,
sourceLineId: line.sourceLineId,
sourceRowId: line.sourceRowId,
category: line.category,
partName: line.partName,
originalMaterial: line.originalMaterial,
@ -2631,27 +2683,13 @@ function serializeScenario(record: BomCostingScenarioRecord) {
materialSpec: line.materialSpec,
unit: line.unit,
quantity: line.quantity === null ? null : Number(line.quantity),
coefficient: line.coefficient === null ? null : Number(line.coefficient),
pricingQuantity: line.pricingQuantity === null ? null : Number(line.pricingQuantity),
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
subtotal: line.subtotal === null ? null : Number(line.subtotal),
estimateRange: line.estimateLow == null || line.estimateHigh == null
? null
: { min: Number(line.estimateLow), max: Number(line.estimateHigh) },
estimateBasis: line.estimateBasis || null,
sampleCount: line.sampleCount ?? 0,
priceSource: line.priceSource,
sourceSku: line.sourceSku,
referenceSkus: Array.isArray(line.referenceSkus) ? line.referenceSkus : [],
referenceRowIds: Array.isArray(line.referenceRowIds) ? line.referenceRowIds : [],
sourceFingerprint: line.sourceFingerprint,
sourceCreatedAt: line.sourceCreatedAt?.toISOString() ?? null,
sourceSyncedAt: line.sourceSyncedAt?.toISOString() ?? null,
calculationRule: line.calculationRule,
confidence: line.confidence === null ? null : Number(line.confidence),
status: line.status,
warning: line.warning,
rawSnapshot: line.rawSnapshot,
warning: publicScenarioWarning(line.warning),
})),
}
}
@ -2800,7 +2838,9 @@ export class BomCostingScenarioService {
async listScenarios(input: { caseId: string; skuId?: string }) {
const records = await bomCostingScenarioRepository.listCurrentScenarios(input)
return records.map(serializeScenario)
return records
.filter((record) => record.kind === 'SAME_MATERIAL')
.map(serializeScenario)
}
async calculate(input: {
@ -2846,6 +2886,12 @@ export class BomCostingScenarioService {
}) {
const sku = await bomCostingScenarioRepository.findSkuForScenario(input)
if (!sku) throw new AppError(ErrorCode.BOM_COSTING_CASE_NOT_FOUND, 'BOM SKU 不存在或无权访问')
// Start the remote request immediately so it overlaps the local history
// and master-data reads below. A failed/unconfigured search resolves to a
// visible degradation object and never blocks a BOM calculation.
const realtimeMarketQuotePromise = bomCosting1688ImageSearchService.findRealtimeQuote({
imageUrl: sku.imageUrl,
})
const materials = await bomCostingScenarioRepository.listEnabledMaterials()
const target = input.targetMaterialId
? materials.find((material) => material.id === input.targetMaterialId) ?? null
@ -3147,6 +3193,7 @@ export class BomCostingScenarioService {
})
}
const hasProductStructure = sku.bomLines.length > 0
const realtimeMarketQuote = await realtimeMarketQuotePromise
let comparison = calculateDynamicBomScenarios({
rows,
sameMaterialPrices,
@ -3238,6 +3285,7 @@ export class BomCostingScenarioService {
requests: aiRequests,
knownLines,
companyReferences: referenceGroups.flat().slice(0, 80),
realtimeMarketReference: realtimeMarketQuote,
})
Object.assign(sameMaterialPrices, aiEstimate.quotes.SAME_MATERIAL)
Object.assign(substitutionPrices, aiEstimate.quotes.MATERIAL_SUBSTITUTION)
@ -3278,17 +3326,6 @@ export class BomCostingScenarioService {
// zero/negative price for the missing rows.
applyCategoryProfileConflicts(comparison.sameMaterial, categoryFallback.conflicts)
applyCategoryProfileFallbackWarnings(comparison.sameMaterial, categoryFallback.warnings)
if (!hasProductStructure) {
for (const result of [comparison.sameMaterial, comparison.materialSubstitution]) {
result.status = 'INSUFFICIENT_DATA'
result.total = null
result.estimateRange = null
result.marketPriceAlert = false
result.marketPriceAlertKind = null
result.warnings.push('没有识别出可用于成本预估的产品结构')
result.warning = Array.from(new Set(result.warnings)).join(';')
}
}
const scenarioResults = input.sameMaterialOnly
? [comparison.sameMaterial]
: [comparison.sameMaterial, comparison.materialSubstitution]
@ -3374,6 +3411,49 @@ export class BomCostingScenarioService {
result.warnings.push(failedWarning)
}
}
// A finished-product reference can form a useful whole-SKU estimate even
// before the structure extractor has identified every BOM line. It is not
// used as a component price, and never changes an internal line subtotal.
applyRealtime1688ProcurementAnchor(comparison.sameMaterial, realtimeMarketQuote)
comparison.materialSubstitution.realtimeMarketQuote = realtimeMarketQuote
let wholeProductEstimateAudit: unknown = null
if (comparison.sameMaterial.total === null) {
const companyReferences = await loadWholeProductAiReferences({
syncRunId: syncRun?.id ?? null,
skuName: sku.name,
lines: scenarioLines,
})
const wholeProductEstimate = await bomCostingAiCostEstimator.estimateWholeProductCost({
product: {
externalSkuId: sku.externalSkuId,
name: sku.name,
structureGroupKey: sku.structureGroupKey,
costFamilyKey: sku.costFamilyKey,
lengthMm: decimalNumber(sku.lengthMm),
widthMm: decimalNumber(sku.widthMm),
heightMm: decimalNumber(sku.heightMm),
attributes: sku.attributes,
},
knownLines: comparison.sameMaterial.lines.flatMap((line) => {
const known = toAiKnownLine(line, 'SAME_MATERIAL')
return known ? [known] : []
}),
companyReferences,
})
wholeProductEstimateAudit = applyWholeProductAiEstimate(
comparison.sameMaterial,
wholeProductEstimate,
)
if (comparison.sameMaterial.total === null) {
comparison.sameMaterial.status = 'INSUFFICIENT_DATA'
comparison.sameMaterial.estimateRange = null
comparison.sameMaterial.warnings.push('资料不足,暂无法预估')
comparison.sameMaterial.warning = Array.from(new Set(
comparison.sameMaterial.warnings,
)).join(';')
}
}
const warning = (result: DynamicBomScenarioResult) => [
...result.warnings,
result.marketPriceAlertKind === 'HIGH' ? '预估成本高于竞品售价,需复核主要驱动项' : '',
@ -3431,6 +3511,8 @@ export class BomCostingScenarioService {
categoryTotals: sameBreakdown.categoryTotals,
unresolvedCount: comparison.sameMaterial.unresolvedLineCount,
warning: warning(comparison.sameMaterial),
realtimeMarketQuote: comparison.sameMaterial.realtimeMarketQuote ?? null,
wholeProductEstimateAudit,
lines: comparison.sameMaterial.lines.map((line) => (
toSavedLine(line, line.materialName)
)),
@ -3482,6 +3564,7 @@ export class BomCostingScenarioService {
categoryTotals: substitutionBreakdown.categoryTotals,
unresolvedCount: comparison.materialSubstitution.unresolvedLineCount,
warning: warning(comparison.materialSubstitution),
realtimeMarketQuote: comparison.materialSubstitution.realtimeMarketQuote ?? null,
lines: comparison.materialSubstitution.lines.map((line) => (
toSavedLine(
line,
@ -3741,21 +3824,9 @@ export class BomCostingScenarioService {
}
async getCaseDynamicCosting(caseId: string) {
const [materials, scenarios, sync] = await Promise.all([
this.listMaterials(),
this.listScenarios({ caseId }),
this.getSyncFreshness(),
])
const scenarios = await this.listScenarios({ caseId })
return {
materials: materials.map((material) => ({
id: material.id,
code: material.code,
name: material.name,
category: material.category,
aliases: Array.isArray(material.aliases) ? material.aliases : [],
})),
scenarios,
sync,
}
}

View File

@ -86,6 +86,135 @@ describe('BomCostingService', () => {
).rejects.toThrow('当前账号不能访问 BOM 核算')
})
it('普通详情只返回结构与中性预估状态,不泄露人工或资料库价格留档', async () => {
const now = new Date('2026-08-17T12:00:00.000Z')
jest.spyOn(bomCostingRepository, 'findCaseById').mockResolvedValue({
id: 'case-public-v1',
sourceUrl: 'https://example.com/item/public-v1',
sourceHost: 'example.com',
title: '测试商品',
providerCode: 'fixture',
providerTaskId: 'task-public-v1',
sourceCacheHit: true,
status: 'READY',
progress: 100,
statusMessage: '已完成',
analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,
targetErrorPercent: new Prisma.Decimal(10),
currency: 'CNY',
createdById: operationsUser.id,
processingStartedAt: now,
processingFinishedAt: now,
createdAt: now,
updatedAt: now,
sourceSnapshot: {
id: 'snapshot-public-v1',
status: 'READY',
channel: 'private-market',
externalItemId: 'private-item-1',
title: '测试商品快照',
shopName: '私有店铺',
skuCount: 1,
imageCount: 1,
storedImageCount: 1,
failedImageCount: 0,
errorMessage: '',
fetchedAt: now,
completedAt: now,
assets: [],
},
analysisRuns: [],
taskEvents: [],
reviewInputs: [{
id: 'review-private-price',
source: 'MANUAL',
unitPrice: new Prisma.Decimal(88),
creator: { id: operationsUser.id, name: operationsUser.name },
}],
skus: [{
id: 'sku-public-v1',
externalSkuId: 'SKU-PUBLIC-V1',
name: '测试规格',
structureGroupKey: 'table',
costFamilyKey: 'table:oak',
imageUrl: null,
attributes: {},
lengthMm: new Prisma.Decimal(1200),
widthMm: new Prisma.Decimal(600),
heightMm: new Prisma.Decimal(750),
weightKg: null,
packageLengthMm: null,
packageWidthMm: null,
packageHeightMm: null,
packageWeightKg: null,
estimatedCost: null,
costCalculatedAt: null,
scenarios: [],
bomLines: [{
id: 'line-public-v1',
externalLineId: 'tabletop',
category: 'BOARD',
partName: '桌面板',
materialCode: 'OAK',
materialName: '橡木',
materialSpec: '20 mm',
processName: '',
packagingKind: '',
lengthMm: new Prisma.Decimal(1200),
widthMm: new Prisma.Decimal(600),
heightMm: new Prisma.Decimal(20),
quantity: new Prisma.Decimal(1),
unit: '块',
weightKg: null,
lossFactor: new Prisma.Decimal(1),
sourceConfidence: new Prisma.Decimal(0.8),
evidence: [],
matchStatus: 'UNMATCHED',
matchedLibraryType: 'SUBSTRATE',
matchedLibraryRecordId: 'private-library-row',
matchedLibraryName: '内部板材记录',
matchedLibrarySpec: '20 mm',
resolvedUnitPrice: new Prisma.Decimal(88),
calculatedQuantity: new Prisma.Decimal(0.72),
subtotal: null,
priceSnapshots: [{ unit: '㎡' }],
updatedAt: now,
}],
}],
} as never)
const detail = await bomCostingService.getCase('case-public-v1', operationsUser)
const [line] = detail.skus[0]!.bomLines
expect(detail).not.toHaveProperty('reviewInputs')
expect(detail).not.toHaveProperty('sourceUrl')
expect(detail).not.toHaveProperty('sourceHost')
expect(detail).not.toHaveProperty('providerCode')
expect(detail).not.toHaveProperty('providerTaskId')
expect(line).toMatchObject({
partName: '桌面板',
estimateStatus: 'PENDING',
})
for (const key of [
'matchStatus',
'matchedLibraryType',
'matchedLibraryRecordId',
'matchedLibraryName',
'matchedLibrarySpec',
'resolvedUnitPrice',
'resolvedPriceUnit',
'calculatedQuantity',
]) {
expect(line).not.toHaveProperty(key)
}
expect(JSON.stringify(detail)).not.toContain('review-private-price')
expect(JSON.stringify(detail)).not.toContain('private-library-row')
expect(JSON.stringify(detail)).not.toContain('https://example.com/item/public-v1')
expect(JSON.stringify(detail)).not.toContain('private-market')
expect(JSON.stringify(detail)).not.toContain('private-item-1')
expect(JSON.stringify(detail)).not.toContain('私有店铺')
})
it('旧版结果必须重新核算后才能生成动态成本场景', async () => {
jest.spyOn(bomCostingService, 'getCase').mockResolvedValue({
id: 'case-legacy',
@ -1144,7 +1273,7 @@ describe('BomCostingService', () => {
})
})
it('商品跳转优先使用归档商品标识,不继续打开登录或追踪地址', async () => {
it('归档商品标识只供服务端内部使用,普通列表不返回链接或站点来源', async () => {
jest.spyOn(bomCostingRepository, 'listCases').mockResolvedValue([{
id: 'case-canonical-url',
title: '规范商品地址',
@ -1172,6 +1301,9 @@ describe('BomCostingService', () => {
const [summary] = await bomCostingService.listCases(operationsUser)
expect(summary?.sourceUrl).toBe('https://item.taobao.com/item.htm?id=123456789')
expect(summary).not.toHaveProperty('sourceUrl')
expect(summary).not.toHaveProperty('sourceHost')
expect(JSON.stringify(summary)).not.toContain('login.taobao.com')
expect(JSON.stringify(summary)).not.toContain('123456789')
})
})

View File

@ -47,9 +47,7 @@ import { bomCostingProcessingService } from './bom-costing-processing.service'
import { BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION } from './bom-costing-structure-knowledge'
import {
buildCanonicalMarketProductUrl,
canonicalizeMarketProductUrl,
parseMarketProductIdentity,
type MarketProductIdentity,
} from './market-detail-client'
const CATEGORY_LABELS: Record<BomCostingLineCategory, string> = {
@ -65,24 +63,6 @@ const CURRENT_PACKAGING_LIBRARY_TYPE = 'ASSET_PACKAGING_VARIANT'
const MANUAL_REVIEW_PRICE_SOURCE = 'MANUAL_REVIEW'
const EXTERNAL_ESTIMATE_PRICE_SOURCE = 'EXTERNAL_ANALYSIS_ESTIMATE'
function canonicalCaseSourceUrl(record: {
sourceUrl: string
sourceSnapshot?: { channel?: string; externalItemId?: string } | null
}) {
const channel = record.sourceSnapshot?.channel
const itemId = record.sourceSnapshot?.externalItemId?.trim()
if (
itemId &&
(channel === 'taobao' || channel === 'tmall' || channel === '1688' || channel === 'jd' || channel === 'douyin')
) {
return buildCanonicalMarketProductUrl({
channel: channel as MarketProductIdentity['channel'],
itemId,
}, record.sourceUrl)
}
return canonicalizeMarketProductUrl(record.sourceUrl)
}
function decimalToNumber(value: Prisma.Decimal | null | undefined) {
return value === null || value === undefined ? null : value.toNumber()
}
@ -267,31 +247,10 @@ function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLine
weightKg: decimalToNumber(line.weightKg),
lossFactor: decimalToNumber(line.lossFactor) ?? 1,
sourceConfidence: decimalToNumber(line.sourceConfidence),
evidence: line.evidence,
matchStatus: needsPackagingReconfirmation
? BomCostingMatchStatus.NEEDS_CONFIRMATION
: line.matchStatus,
matchedLibraryType: needsPackagingReconfirmation
? ''
: line.matchedLibraryType,
matchedLibraryRecordId: needsPackagingReconfirmation
? ''
: line.matchedLibraryRecordId,
matchedLibraryName: needsPackagingReconfirmation
? ''
: line.matchedLibraryName,
matchedLibrarySpec: needsPackagingReconfirmation
? ''
: line.matchedLibrarySpec,
resolvedUnitPrice: needsPackagingReconfirmation
? null
: decimalToNumber(line.resolvedUnitPrice),
resolvedPriceUnit: needsPackagingReconfirmation
? ''
: line.priceSnapshots[0]?.unit || line.unit,
calculatedQuantity: needsPackagingReconfirmation
? null
: decimalToNumber(line.calculatedQuantity),
evidence: serializePublicLineEvidence(line.evidence),
estimateStatus: needsPackagingReconfirmation || line.subtotal === null
? 'PENDING'
: 'ESTIMATED',
subtotal: needsPackagingReconfirmation
? null
: decimalToNumber(line.subtotal),
@ -299,6 +258,28 @@ function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLine
}
}
function serializePublicLineEvidence(evidence: Prisma.JsonValue | null) {
if (!Array.isArray(evidence)) return []
return evidence.flatMap((entry) => {
if (!entry || Array.isArray(entry) || typeof entry !== 'object') return []
const record = entry as Prisma.JsonObject
const factIds = Array.isArray(record.factIds)
? record.factIds.filter((factId): factId is string => typeof factId === 'string')
: []
return [{
imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : null,
basis: typeof record.basis === 'string' ? record.basis : null,
note: typeof record.note === 'string' ? record.note : null,
factIds,
derivation: typeof record.derivation === 'string' ? record.derivation : null,
reviewReason: typeof record.reviewReason === 'string' ? record.reviewReason : null,
confidence: typeof record.confidence === 'number' ? record.confidence : null,
}]
})
}
function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
const resultQuality = getBomCostingResultQuality({
status: record.status,
@ -355,11 +336,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
})()
return {
id: record.id,
sourceUrl: canonicalCaseSourceUrl(record),
sourceHost: record.sourceHost,
title: record.title,
providerCode: record.providerCode,
providerTaskId: record.providerTaskId,
sourceCacheHit: record.sourceCacheHit,
status: record.status,
progress: record.progress,
@ -377,10 +354,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
? {
id: record.sourceSnapshot.id,
status: record.sourceSnapshot.status,
channel: record.sourceSnapshot.channel,
externalItemId: record.sourceSnapshot.externalItemId,
title: record.sourceSnapshot.title,
shopName: record.sourceSnapshot.shopName,
skuCount: record.sourceSnapshot.skuCount,
imageCount: record.sourceSnapshot.imageCount,
storedImageCount: record.sourceSnapshot.storedImageCount,
@ -449,30 +423,6 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
costSummary,
}
}),
reviewInputs: record.reviewInputs.map((input) => ({
id: input.id,
skuId: input.skuId,
lineId: input.lineId,
skuName: input.sku?.name ?? input.skuName,
partName: input.line?.partName ?? input.partName,
source: input.source,
materialCode: input.materialCode,
materialName: input.materialName,
materialSpec: input.materialSpec,
lengthMm: decimalToNumber(input.lengthMm),
widthMm: decimalToNumber(input.widthMm),
heightMm: decimalToNumber(input.heightMm),
quantity: decimalToNumber(input.quantity),
unitPrice: decimalToNumber(input.unitPrice),
priceUnit: input.priceUnit,
currency: input.currency,
lossFactor: decimalToNumber(input.lossFactor),
subtotal: decimalToNumber(input.subtotal),
note: input.note,
createdById: input.createdById,
createdByName: input.creator?.name ?? '已离职用户',
createdAt: input.createdAt.toISOString(),
})),
analysisRuns: record.analysisRuns.map((run) => ({
id: run.id,
revision: run.revision,
@ -544,8 +494,6 @@ export class BomCostingService {
return {
id: record.id,
title: record.title,
sourceUrl: canonicalCaseSourceUrl(record),
sourceHost: record.sourceHost,
status: record.status,
progress: record.progress,
statusMessage: record.statusMessage,