feat(bom-costing): complete unified cost estimates
This commit is contained in:
parent
fa48bcc48d
commit
d3a1049b55
@ -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;
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user