feat(bom-costing): complete unified cost estimates

This commit is contained in:
WuYongJie 2026-08-17 20:54:45 +08:00
parent fa48bcc48d
commit d3a1049b55
16 changed files with 1002 additions and 1839 deletions

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

@ -1401,6 +1401,7 @@ model BomCostingScenario {
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

@ -1576,30 +1576,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(
'这是原材质批次结果,不代表当前材质替换方案已经完成',
'其余 SKU 可按现有事实重新核算',
)
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 待补成本:结构事实已保存,但公司价格资料尚未闭合,补齐后再生成完整预估。',
'本轮该 SKU 的结构事实已保存;待补信息完善后可继续形成完整预估。',
)).toBeInTheDocument()
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
expect(screen.queryByText('约 ¥888.00')).not.toBeInTheDocument()
@ -1675,7 +1675,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 +1721,7 @@ describe('BomCostingWorkbenchClient', () => {
}
})
it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => {
it('未匹配结构项不显示零元,并保留中性的预估待补状态', async () => {
const originalFetch = global.fetch
const now = new Date().toISOString()
const unresolvedLine = {
@ -1803,69 +1803,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 +1845,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: {
@ -2182,14 +2057,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',
@ -2460,13 +2338,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)
@ -2476,31 +2352,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,
@ -2526,42 +2379,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('实时采购预估 · 1688 同款').length).toBeGreaterThan(0)
expect(screen.getAllByText(/1688 同款价 ¥1,500\.00 × 90%/).length).toBeGreaterThan(0)
expect(screen.getAllByText(/结构拆解参考 ¥1,350\.00/).length).toBeGreaterThan(0)
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
}
@ -2752,10 +2585,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,70 +216,19 @@ 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'
| 'REALTIME_1688_MARKET'
export interface BomCostingEstimateRangeValue {
min: number
max: number
}
export interface BomCostingRealtimeMarketOfferValue {
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 BomCostingRealtimeMarketQuoteValue {
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: BomCostingEstimateRangeValue | null
confidence: number | null
comparableOfferCount: number
returnedOfferCount: number
warnings: string[]
offers: BomCostingRealtimeMarketOfferValue[]
structuralCost?: number | null
structuralEstimateRange?: BomCostingEstimateRangeValue | null
}
export interface BomCostingScenarioLineDetail {
id: string
sourceLineId: string | null
sourceRowId: string
category: string
partName: string
originalMaterial: string
@ -340,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
@ -381,61 +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
/** Optional while older persisted scenario records are being upgraded. */
realtimeMarketQuote?: BomCostingRealtimeMarketQuoteValue | null
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

@ -223,6 +223,7 @@ type ScenarioCostResultInput = {
unresolvedCount: number
warning: string
realtimeMarketQuote?: unknown
wholeProductEstimateAudit?: unknown
lines: Array<Record<string, unknown>>
}
@ -273,6 +274,9 @@ async function createScenarioRecord(
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

@ -557,10 +557,11 @@ export class BomCosting1688ImageSearchService {
minSimilarityScore: config.minSimilarityScore,
returnedOffers: readImageSearchResponse(payload),
})
} catch (error) {
console.warn('[BOM costing] 1688 realtime image search unavailable', {
error: error instanceof Error ? error.message : String(error),
})
} 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,

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

@ -17,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
@ -87,6 +89,26 @@ export interface BomCostingAiEstimateInput {
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 {
quotes: Record<DynamicBomScenarioKind, Record<string, DynamicBomPriceQuote>>
requestedCount: number
@ -122,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
@ -167,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
@ -211,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) {}
@ -356,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

@ -183,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')
@ -195,7 +206,7 @@ describe('BomCostingScenarioService', () => {
jest.restoreAllMocks()
})
it('prioritizes a realtime 1688 procurement anchor while retaining the structural BOM estimate for audit', async () => {
it('uses a live market reference for the whole-SKU estimate while retaining structural detail for audit', async () => {
mockBase({
bomLines: [line({
id: 'drawer-hardware',
@ -239,7 +250,7 @@ describe('BomCostingScenarioService', () => {
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]?.[0]
expect(saved?.sameMaterial).toEqual(expect.objectContaining({
total: 108,
estimateBasis: 'REALTIME_1688_MARKET',
estimateBasis: 'AI_REFERENCE_ESTIMATE',
status: 'PROVISIONAL',
realtimeMarketQuote: expect.objectContaining({
procurementCost: 108,
@ -458,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',
@ -2959,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('资料不足,暂无法预估'),
})
})
@ -3102,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',
@ -3142,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',
@ -3167,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',
@ -3218,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

@ -28,6 +28,7 @@ import {
type BomCostingAiCompanyReference,
type BomCostingAiEstimateRequestLine,
type BomCostingAiKnownLine,
type BomCostingAiWholeProductEstimateResult,
} from './bom-costing-ai-cost-estimator'
import {
bomCosting1688ImageSearchService,
@ -2251,6 +2252,68 @@ 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
@ -2277,23 +2340,24 @@ function applyRealtime1688ProcurementAnchor(
result.total = quote.procurementCost
result.estimateRange = quote.estimateRange
result.estimateBasis = 'REALTIME_1688_MARKET'
// 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(
`已优先采用 1688 实时同款采购预估(同款价 × ${(quote.procurementRate * 100).toFixed(0)}%)`,
)
result.warnings.push('已形成整件预估,结构拆解用于解释构成与偏差复核')
if (structuralCost !== null) {
result.warnings.push(
`结构拆解预估 ¥${structuralCost.toFixed(2)} 已保留,仅用于解释构成与偏差复核`,
'结构拆解预估已保留,仅用于解释构成与偏差复核',
)
} else {
result.warnings.push(
'结构拆解仍有缺价明细;当前先给出实时同款采购预估,明细需继续补充事实或由模型预估',
'结构拆解仍有待补明细;当前整件预估已形成,明细可继续补充事实或由模型预估',
)
}
result.warning = Array.from(new Set(result.warnings)).join(';')
@ -2418,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,
@ -2516,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,
@ -2642,43 +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,
realtimeMarketQuote: record.realtimeMarketQuote
? record.realtimeMarketQuote as unknown as DynamicBomRealtimeMarketQuote
: null,
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,
@ -2686,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),
})),
}
}
@ -2855,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: {
@ -3341,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]
@ -3437,15 +3411,48 @@ export class BomCostingScenarioService {
result.warnings.push(failedWarning)
}
}
if (hasProductStructure) {
applyRealtime1688ProcurementAnchor(comparison.sameMaterial, realtimeMarketQuote)
// A replacement-material scenario cannot use the original product's
// real-time market price as its total. Retain the same source for audit
// and comparison, but leave its calculated total untouched.
applyRealtime1688ProcurementAnchor(comparison.materialSubstitution, realtimeMarketQuote)
} else {
comparison.sameMaterial.realtimeMarketQuote = realtimeMarketQuote
comparison.materialSubstitution.realtimeMarketQuote = realtimeMarketQuote
// 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,
@ -3505,6 +3512,7 @@ export class BomCostingScenarioService {
unresolvedCount: comparison.sameMaterial.unresolvedLineCount,
warning: warning(comparison.sameMaterial),
realtimeMarketQuote: comparison.sameMaterial.realtimeMarketQuote ?? null,
wholeProductEstimateAudit,
lines: comparison.sameMaterial.lines.map((line) => (
toSavedLine(line, line.materialName)
)),
@ -3816,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,