Merge latest shared mainline before supply-chain timing release
This commit is contained in:
commit
df3a8a7894
@ -135,6 +135,20 @@ BOM_COSTING_AI_MAX_TOKENS=12000
|
|||||||
BOM_COSTING_AI_REASONING_EFFORT=low
|
BOM_COSTING_AI_REASONING_EFFORT=low
|
||||||
BOM_COSTING_MODELING_PROVIDER=
|
BOM_COSTING_MODELING_PROVIDER=
|
||||||
|
|
||||||
|
# BOM 核算实时同款采购预估(1688 官方图搜)。
|
||||||
|
# 网关固定为 https://skills-gateway.1688.com/api/find_product/1.0.0。
|
||||||
|
# AK 只放服务器私密环境变量,服务端会按 x-csk-* 协议签名;实时同款价 × 采购系数
|
||||||
|
# 是整件采购预估,历史 BOM 仅保留为结构参考。不要把 AK 写入 Git 或 NEXT_PUBLIC_* 变量。
|
||||||
|
ALIBABA_1688_IMAGE_SEARCH_AK=replace-with-1688-image-search-ak
|
||||||
|
# 仅在官方网关地址调整时设置;系统会拒绝非官方 HTTPS 地址。
|
||||||
|
ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL=https://skills-gateway.1688.com/api/find_product/1.0.0
|
||||||
|
ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS=45000
|
||||||
|
ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY=0.78
|
||||||
|
ALIBABA_1688_PROCUREMENT_RATE=0.90
|
||||||
|
ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE=10
|
||||||
|
ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT=1
|
||||||
|
ALIBABA_1688_IMAGE_SEARCH_TAGS=4306497
|
||||||
|
|
||||||
# 素材库白底图回传
|
# 素材库白底图回传
|
||||||
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false
|
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false
|
||||||
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL=https://example.com
|
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL=https://example.com
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- Retain the auditable realtime 1688 search result with every scenario
|
||||||
|
-- revision. It is a whole-product procurement signal, not a line-level BOM
|
||||||
|
-- price, therefore it stays as a single JSON snapshot on the scenario.
|
||||||
|
ALTER TABLE "BomCostingScenario"
|
||||||
|
ADD COLUMN "realtimeMarketQuote" JSONB;
|
||||||
@ -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;
|
||||||
@ -1400,6 +1400,8 @@ model BomCostingScenario {
|
|||||||
lossCost Decimal? @db.Decimal(18, 2)
|
lossCost Decimal? @db.Decimal(18, 2)
|
||||||
competitorPrice Decimal? @db.Decimal(18, 2)
|
competitorPrice Decimal? @db.Decimal(18, 2)
|
||||||
priceDelta Decimal? @db.Decimal(18, 2)
|
priceDelta Decimal? @db.Decimal(18, 2)
|
||||||
|
realtimeMarketQuote Json?
|
||||||
|
wholeProductEstimateAudit Json?
|
||||||
confidence Decimal? @db.Decimal(5, 4)
|
confidence Decimal? @db.Decimal(5, 4)
|
||||||
warning String @default("") @db.Text
|
warning String @default("") @db.Text
|
||||||
unresolvedCount Int @default(0)
|
unresolvedCount Int @default(0)
|
||||||
|
|||||||
@ -5,8 +5,7 @@ export {}
|
|||||||
const mockHandleRequest = jest.fn()
|
const mockHandleRequest = jest.fn()
|
||||||
const mockJsonError = jest.fn()
|
const mockJsonError = jest.fn()
|
||||||
const mockJsonSuccess = jest.fn()
|
const mockJsonSuccess = jest.fn()
|
||||||
const mockAssertCanModifyCurrentCosting = jest.fn()
|
const mockGetCase = jest.fn()
|
||||||
const mockCalculate = jest.fn()
|
|
||||||
|
|
||||||
jest.mock('@/server/core/response', () => ({
|
jest.mock('@/server/core/response', () => ({
|
||||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||||
@ -16,15 +15,12 @@ jest.mock('@/server/core/response', () => ({
|
|||||||
|
|
||||||
jest.mock('@/server/services/bom-costing/bom-costing.service', () => ({
|
jest.mock('@/server/services/bom-costing/bom-costing.service', () => ({
|
||||||
bomCostingService: {
|
bomCostingService: {
|
||||||
assertCanModifyCurrentCosting: (...args: unknown[]) =>
|
getCase: (...args: unknown[]) => mockGetCase(...args),
|
||||||
mockAssertCanModifyCurrentCosting(...args),
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('@/server/services/bom-costing/bom-costing-scenario.service', () => ({
|
jest.mock('@/server/services/bom-costing/bom-costing-scenario.service', () => ({
|
||||||
bomCostingScenarioService: {
|
bomCostingScenarioService: {},
|
||||||
calculate: (...args: unknown[]) => mockCalculate(...args),
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { AppError, ErrorCode } = require('@/server/core/errors')
|
const { AppError, ErrorCode } = require('@/server/core/errors')
|
||||||
@ -56,9 +52,8 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('只在当前核算可修改时生成材质替换场景', async () => {
|
it('关闭换材质计算,但仍验证当前用户可以访问目标案例', async () => {
|
||||||
mockAssertCanModifyCurrentCosting.mockResolvedValue({ id: 'case-ready' })
|
mockGetCase.mockResolvedValue({ id: 'case-ready' })
|
||||||
mockCalculate.mockResolvedValue({ scenario: { id: 'scenario-1' } })
|
|
||||||
const request = {
|
const request = {
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
skuId: 'sku-1',
|
skuId: 'sku-1',
|
||||||
@ -70,20 +65,19 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => {
|
|||||||
params: Promise.resolve({ id: 'case-ready' }),
|
params: Promise.resolve({ id: 'case-ready' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(mockAssertCanModifyCurrentCosting).toHaveBeenCalledWith(
|
expect(mockGetCase).toHaveBeenCalledWith(
|
||||||
'case-ready',
|
'case-ready',
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
expect(mockCalculate).toHaveBeenCalledWith({
|
expect(response).toMatchObject({
|
||||||
caseId: 'case-ready',
|
success: false,
|
||||||
skuId: 'sku-1',
|
status: 409,
|
||||||
targetMaterialId: 'material-1',
|
error: { code: ErrorCode.VALIDATION_FAILED, message: expect.stringContaining('换材质预估已暂时关闭') },
|
||||||
})
|
})
|
||||||
expect(response).toMatchObject({ success: true, status: 200 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('旧版或处理中的结果被守卫拒绝时不调用成本引擎', async () => {
|
it('没有案例读取权限时维持原有鉴权错误', async () => {
|
||||||
mockAssertCanModifyCurrentCosting.mockRejectedValue(
|
mockGetCase.mockRejectedValue(
|
||||||
new AppError(
|
new AppError(
|
||||||
ErrorCode.VALIDATION_FAILED,
|
ErrorCode.VALIDATION_FAILED,
|
||||||
'当前核算尚未完成,请等待本次处理结束后再调整成本',
|
'当前核算尚未完成,请等待本次处理结束后再调整成本',
|
||||||
@ -105,6 +99,6 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => {
|
|||||||
status: 422,
|
status: 422,
|
||||||
error: { code: ErrorCode.VALIDATION_FAILED },
|
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 { NextRequest } from 'next/server'
|
||||||
import { bomCostingScenarioCalculationSchema } from '@/lib/bom-costing/contracts'
|
|
||||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||||
import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service'
|
import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service'
|
||||||
@ -38,15 +37,14 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const caseId = decodeURIComponent(id)
|
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 {
|
try {
|
||||||
await bomCostingService.assertCanModifyCurrentCosting(caseId, ctx.user)
|
await bomCostingService.getCase(caseId, ctx.user)
|
||||||
const result = await bomCostingScenarioService.calculate({ caseId, ...parsed.data })
|
return jsonError(
|
||||||
return jsonSuccess(result, 200, ctx.requestId)
|
ErrorCode.VALIDATION_FAILED,
|
||||||
|
'换材质预估已暂时关闭,请使用重新核算更新当前预估结果',
|
||||||
|
409,
|
||||||
|
ctx.requestId,
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||||
|
|||||||
@ -1,13 +1,37 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import BomCostingWorkbenchClient, {
|
import BomCostingWorkbenchClient, {
|
||||||
formatBomCategorySkillVersionHint,
|
formatBomCategorySkillVersionHint,
|
||||||
|
getHistoryStatusDotClass,
|
||||||
|
getHistoryStatusLabel,
|
||||||
} from './BomCostingWorkbenchClient'
|
} from './BomCostingWorkbenchClient'
|
||||||
|
import type { BomCostingCaseSummary } from '@/lib/bom-costing/client-types'
|
||||||
|
|
||||||
jest.mock('@/components/platform/AssetWorkbenchEmptyBackdrop', () => ({
|
jest.mock('@/components/platform/AssetWorkbenchEmptyBackdrop', () => ({
|
||||||
AssetWorkbenchEmptyBackdrop: () => <div data-testid="empty-backdrop" />,
|
AssetWorkbenchEmptyBackdrop: () => <div data-testid="empty-backdrop" />,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('BomCostingWorkbenchClient', () => {
|
describe('BomCostingWorkbenchClient', () => {
|
||||||
|
it('整件预估完成但结构待复核时,历史状态点仍表示完成', () => {
|
||||||
|
const item: BomCostingCaseSummary = {
|
||||||
|
id: 'case-structure-review',
|
||||||
|
title: '结构待复核但预估完成的案例',
|
||||||
|
status: 'READY',
|
||||||
|
progress: 100,
|
||||||
|
statusMessage: '整件预估已生成,结构差异待复核',
|
||||||
|
needsPriceReview: true,
|
||||||
|
sourceCacheHit: true,
|
||||||
|
storedImageCount: 79,
|
||||||
|
failedImageCount: 0,
|
||||||
|
createdById: 'user-1',
|
||||||
|
createdAt: '2026-08-18T00:00:00.000Z',
|
||||||
|
skuCount: 15,
|
||||||
|
estimatedCostRange: { min: 432.9, max: 930.38 },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getHistoryStatusLabel(item)).toBe('预估完成(结构待复核)')
|
||||||
|
expect(getHistoryStatusDotClass(item)).toBe('bg-[#3f8a68]')
|
||||||
|
})
|
||||||
|
|
||||||
it('旧结果 Skill 与当前版本不同时分别标明,且不把旧结果冒充为新版本', () => {
|
it('旧结果 Skill 与当前版本不同时分别标明,且不把旧结果冒充为新版本', () => {
|
||||||
expect(formatBomCategorySkillVersionHint({
|
expect(formatBomCategorySkillVersionHint({
|
||||||
resultVersion: 'bom-category-skill/cabinet-bookshelf/v1',
|
resultVersion: 'bom-category-skill/cabinet-bookshelf/v1',
|
||||||
@ -511,6 +535,100 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: 'SKU 图 2,联合分析中' }),
|
screen.getByRole('button', { name: 'SKU 图 2,联合分析中' }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||||
|
window.dispatchEvent(new Event('focus'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('处理时长优先展示整轮核算耗时,而不是模型合并的毫秒耗时', async () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
const processingFinishedAt = new Date().toISOString()
|
||||||
|
const processingStartedAt = new Date(
|
||||||
|
new Date(processingFinishedAt).getTime() - 86_000,
|
||||||
|
).toISOString()
|
||||||
|
const detail = {
|
||||||
|
id: 'case-duration',
|
||||||
|
sourceUrl: 'https://item.example.com/item?id=duration',
|
||||||
|
sourceHost: 'item.example.com',
|
||||||
|
title: '核算耗时案例',
|
||||||
|
providerCode: 'market-detail-multimodal',
|
||||||
|
providerTaskId: 'bom-task-duration',
|
||||||
|
sourceCacheHit: true,
|
||||||
|
status: 'ANALYZING',
|
||||||
|
progress: 97,
|
||||||
|
statusMessage: 'SKU 结构已就绪,正在生成分层成本预估',
|
||||||
|
targetErrorPercent: 10,
|
||||||
|
currency: 'CNY',
|
||||||
|
createdById: 'user-1',
|
||||||
|
processingStartedAt,
|
||||||
|
processingFinishedAt,
|
||||||
|
createdAt: processingStartedAt,
|
||||||
|
updatedAt: processingFinishedAt,
|
||||||
|
sourceSnapshot: null,
|
||||||
|
skus: [],
|
||||||
|
analysisRuns: [],
|
||||||
|
taskEvents: [{
|
||||||
|
id: 'event-model-started',
|
||||||
|
eventType: 'MODEL_ANALYSIS_STARTED',
|
||||||
|
status: 'ANALYZING',
|
||||||
|
progress: 78,
|
||||||
|
message: '准备生成结构化 BOM',
|
||||||
|
providerTaskId: 'bom-task-duration',
|
||||||
|
payload: { model: 'gpt-5.6-terra', imageCount: 79 },
|
||||||
|
createdAt: processingStartedAt,
|
||||||
|
}, {
|
||||||
|
id: 'event-model-completed',
|
||||||
|
eventType: 'MODEL_ANALYSIS_COMPLETED',
|
||||||
|
status: 'ANALYZING',
|
||||||
|
progress: 97,
|
||||||
|
message: 'SKU 结构已就绪,正在生成分层成本预估',
|
||||||
|
providerTaskId: 'bom-task-duration',
|
||||||
|
payload: { model: 'gpt-5.6-terra', imageCount: 79, durationMs: 10 },
|
||||||
|
createdAt: processingFinishedAt,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
global.fetch = jest.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, data: { case: detail } }),
|
||||||
|
})) as jest.Mock
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<BomCostingWorkbenchClient
|
||||||
|
initialCapabilities={{
|
||||||
|
enabled: true,
|
||||||
|
analysisConfigured: true,
|
||||||
|
modelingConfigured: false,
|
||||||
|
analysisProviderCode: 'market-detail-multimodal',
|
||||||
|
targetErrorPercent: 10,
|
||||||
|
fixtureAllowed: false,
|
||||||
|
}}
|
||||||
|
initialCases={[{
|
||||||
|
id: detail.id,
|
||||||
|
title: detail.title,
|
||||||
|
sourceUrl: detail.sourceUrl,
|
||||||
|
sourceHost: detail.sourceHost,
|
||||||
|
status: 'ANALYZING',
|
||||||
|
progress: 97,
|
||||||
|
statusMessage: detail.statusMessage,
|
||||||
|
sourceCacheHit: true,
|
||||||
|
storedImageCount: 79,
|
||||||
|
failedImageCount: 0,
|
||||||
|
createdById: 'user-1',
|
||||||
|
createdAt: processingStartedAt,
|
||||||
|
skuCount: 15,
|
||||||
|
estimatedCostRange: null,
|
||||||
|
}]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText('1 分 26 秒')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('0 秒')).not.toBeInTheDocument()
|
||||||
} finally {
|
} finally {
|
||||||
global.fetch = originalFetch
|
global.fetch = originalFetch
|
||||||
}
|
}
|
||||||
@ -1371,6 +1489,7 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
costedSkuCount: 1,
|
costedSkuCount: 1,
|
||||||
pendingSkuCount: 1,
|
pendingSkuCount: 1,
|
||||||
failedSkuCount: 1,
|
failedSkuCount: 1,
|
||||||
|
qualityWarningCount: 1,
|
||||||
succeededSkuIds: ['sku-recalculate', 'sku-pending'],
|
succeededSkuIds: ['sku-recalculate', 'sku-pending'],
|
||||||
costedSkuIds: ['sku-recalculate'],
|
costedSkuIds: ['sku-recalculate'],
|
||||||
pendingSkuIds: ['sku-pending'],
|
pendingSkuIds: ['sku-pending'],
|
||||||
@ -1576,30 +1695,30 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
expect(await screen.findByText('预计出厂成本')).toBeInTheDocument()
|
expect(await screen.findByText('预计出厂成本')).toBeInTheDocument()
|
||||||
const dynamicCostNotice = screen.getByTestId('bom-dynamic-cost-partial-notice')
|
const dynamicCostNotice = screen.getByTestId('bom-dynamic-cost-partial-notice')
|
||||||
expect(dynamicCostNotice).toHaveTextContent(
|
expect(dynamicCostNotice).toHaveTextContent(
|
||||||
'1/3 个 SKU 的原材质预估已完成',
|
'1/3 个 SKU 的预估已完成',
|
||||||
)
|
)
|
||||||
expect(dynamicCostNotice).toHaveTextContent(
|
expect(dynamicCostNotice).toHaveTextContent(
|
||||||
'这是原材质批次结果,不代表当前材质替换方案已经完成',
|
'失败规格 · 当前资料不足,暂无法预估',
|
||||||
)
|
)
|
||||||
expect(dynamicCostNotice).toHaveTextContent(
|
expect(dynamicCostNotice).toHaveTextContent(
|
||||||
'失败规格 · 事务启动超时',
|
'结构拆解差异已标记待复核,当前整件预估已保留',
|
||||||
)
|
)
|
||||||
expect(screen.getByRole('alert', {
|
expect(screen.getByRole('alert', {
|
||||||
name: '原材质批次部分完成,包含成本生成失败',
|
name: '本轮批次部分完成,部分 SKU 暂无法预估',
|
||||||
})).toBe(dynamicCostNotice)
|
})).toBe(dynamicCostNotice)
|
||||||
const failedSkuButton = screen.getByRole('button', { name: /失败规格/ })
|
const failedSkuButton = screen.getByRole('button', { name: /失败规格/ })
|
||||||
expect(within(failedSkuButton).getByText('生成失败')).toBeInTheDocument()
|
expect(within(failedSkuButton).getByText('资料不足')).toBeInTheDocument()
|
||||||
fireEvent.click(failedSkuButton)
|
fireEvent.click(failedSkuButton)
|
||||||
expect(screen.getAllByText('成本生成失败').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('资料不足,暂无法预估').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText(
|
expect(screen.getByText(
|
||||||
'本轮该 SKU 成本生成失败:事务启动超时,两次重试仍失败。已保留结构事实,可按现有事实重新核算。',
|
'该规格暂未形成预估;当前结构与资料已保留。',
|
||||||
)).toBeInTheDocument()
|
)).toBeInTheDocument()
|
||||||
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
|
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText('约 ¥999.00')).not.toBeInTheDocument()
|
expect(screen.queryByText('约 ¥999.00')).not.toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: /待补价格规格/ }))
|
fireEvent.click(screen.getByRole('button', { name: /待补价格规格/ }))
|
||||||
expect(screen.getAllByText('待补成本').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('待补成本').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText(
|
expect(screen.getByText(
|
||||||
'本轮该 SKU 待补成本:结构事实已保存,但公司价格资料尚未闭合,补齐后再生成完整预估。',
|
'该规格仍在整理预估依据;当前结构与资料已保留。',
|
||||||
)).toBeInTheDocument()
|
)).toBeInTheDocument()
|
||||||
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
|
expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText('约 ¥888.00')).not.toBeInTheDocument()
|
expect(screen.queryByText('约 ¥888.00')).not.toBeInTheDocument()
|
||||||
@ -1675,7 +1794,7 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
screen.getByText('按现有事实重算').closest('[data-motion="reveal"]'),
|
screen.getByText('按现有事实重算').closest('[data-motion="reveal"]'),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
const reuseFactsExplanation = screen.getByText(
|
const reuseFactsExplanation = screen.getByText(
|
||||||
'只重新汇总结构、BOM 与资料库价格,复用已保存的逐图结果。',
|
'只重新汇总结构、BOM 与预估结果,复用已保存的逐图结果。',
|
||||||
)
|
)
|
||||||
expect(reuseFactsExplanation.closest('[role="tooltip"]')).toHaveClass('sr-only')
|
expect(reuseFactsExplanation.closest('[role="tooltip"]')).toHaveClass('sr-only')
|
||||||
fireEvent.click(screen.getByRole('button', { name: '查看按现有事实重算说明' }))
|
fireEvent.click(screen.getByRole('button', { name: '查看按现有事实重算说明' }))
|
||||||
@ -1721,7 +1840,7 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => {
|
it('未匹配结构项不显示零元,并保留中性的预估待补状态', async () => {
|
||||||
const originalFetch = global.fetch
|
const originalFetch = global.fetch
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const unresolvedLine = {
|
const unresolvedLine = {
|
||||||
@ -1803,69 +1922,7 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
}],
|
}],
|
||||||
taskEvents: [],
|
taskEvents: [],
|
||||||
}
|
}
|
||||||
const resolvedDetail = {
|
global.fetch = jest.fn(async () => {
|
||||||
...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
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@ -1907,83 +1964,20 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('橡胶木桌面板')).toBeInTheDocument()
|
expect(await screen.findByText('橡胶木桌面板')).toBeInTheDocument()
|
||||||
expect(screen.getByText('SKU 结构已就绪')).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.getByText('预估待补')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
|
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /人工复核|确认价格|保存并留档/ })).not.toBeInTheDocument()
|
||||||
fireEvent.click(
|
expect((global.fetch as jest.Mock).mock.calls.some(([url]) => (
|
||||||
screen.getByRole('button', { name: '记录橡胶木桌面板人工复核值' }),
|
String(url).endsWith('/lines/line-rubberwood')
|
||||||
)
|
))).toBe(false)
|
||||||
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()
|
|
||||||
} finally {
|
} finally {
|
||||||
global.fetch = originalFetch
|
global.fetch = originalFetch
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('动态成本优先展示,并提交目标材质生成双场景且保留超售价金额', async () => {
|
it('统一展示同材质预估,单项待补不影响整件完成且不展示换材质入口', async () => {
|
||||||
const originalFetch = global.fetch
|
const originalFetch = global.fetch
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const scenario = (input: {
|
const scenario = (input: {
|
||||||
@ -2002,6 +1996,24 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
secondarySubtotal?: number
|
secondarySubtotal?: number
|
||||||
aiSubtotal?: number
|
aiSubtotal?: number
|
||||||
rulesetVersion?: string
|
rulesetVersion?: string
|
||||||
|
realtimeMarketQuote?: {
|
||||||
|
schemaVersion: 'bom-costing-1688-realtime/v1'
|
||||||
|
source: '1688_IMAGE_SEARCH'
|
||||||
|
status: 'AVAILABLE'
|
||||||
|
sourceImageUrl: string
|
||||||
|
searchedAt: string
|
||||||
|
procurementRate: number
|
||||||
|
marketPrice: number
|
||||||
|
procurementCost: number
|
||||||
|
estimateRange: { min: number; max: number }
|
||||||
|
confidence: number
|
||||||
|
comparableOfferCount: number
|
||||||
|
returnedOfferCount: number
|
||||||
|
warnings: string[]
|
||||||
|
offers: []
|
||||||
|
structuralCost: number
|
||||||
|
structuralEstimateRange: { min: number; max: number }
|
||||||
|
}
|
||||||
}) => ({
|
}) => ({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
skuId: 'sku-dynamic',
|
skuId: 'sku-dynamic',
|
||||||
@ -2034,6 +2046,7 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
syncStatus: 'SUCCEEDED',
|
syncStatus: 'SUCCEEDED',
|
||||||
syncCompletedAt: now,
|
syncCompletedAt: now,
|
||||||
rulesetVersion: input.rulesetVersion ?? 'dynamic-bom-cost/v1',
|
rulesetVersion: input.rulesetVersion ?? 'dynamic-bom-cost/v1',
|
||||||
|
realtimeMarketQuote: input.realtimeMarketQuote ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
boardCutting: [{
|
boardCutting: [{
|
||||||
sourceLineId: 'line-board',
|
sourceLineId: 'line-board',
|
||||||
@ -2163,14 +2176,17 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
kind: 'SAME_MATERIAL',
|
kind: 'SAME_MATERIAL',
|
||||||
targetMaterialId: null,
|
targetMaterialId: null,
|
||||||
targetMaterialName: '',
|
targetMaterialName: '',
|
||||||
status: 'INSUFFICIENT_DATA',
|
status: 'PROVISIONAL',
|
||||||
totalCost: null,
|
totalCost: 1_350,
|
||||||
knownCost: 500,
|
knownCost: 1_350,
|
||||||
unresolvedCount: 1,
|
unresolvedCount: 1,
|
||||||
competitorPrice: 600,
|
competitorPrice: 600,
|
||||||
priceDelta: null,
|
priceDelta: 750,
|
||||||
unitPrice: null,
|
unitPrice: 1_600,
|
||||||
subtotal: null,
|
subtotal: 800,
|
||||||
|
secondarySubtotal: 550,
|
||||||
|
aiSubtotal: 18.5,
|
||||||
|
rulesetVersion: 'dynamic-bom-cost/v6',
|
||||||
}),
|
}),
|
||||||
scenario({
|
scenario({
|
||||||
id: 'scenario-substitution-old',
|
id: 'scenario-substitution-old',
|
||||||
@ -2207,6 +2223,24 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
subtotal: 800,
|
subtotal: 800,
|
||||||
secondarySubtotal: 550,
|
secondarySubtotal: 550,
|
||||||
rulesetVersion: 'dynamic-bom-cost/v6',
|
rulesetVersion: 'dynamic-bom-cost/v6',
|
||||||
|
realtimeMarketQuote: {
|
||||||
|
schemaVersion: 'bom-costing-1688-realtime/v1',
|
||||||
|
source: '1688_IMAGE_SEARCH',
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
sourceImageUrl: 'https://images.example.com/desk.jpg',
|
||||||
|
searchedAt: now,
|
||||||
|
procurementRate: 0.9,
|
||||||
|
marketPrice: 1_500,
|
||||||
|
procurementCost: 1_350,
|
||||||
|
estimateRange: { min: 1_215, max: 1_485 },
|
||||||
|
confidence: 0.84,
|
||||||
|
comparableOfferCount: 3,
|
||||||
|
returnedOfferCount: 3,
|
||||||
|
warnings: [],
|
||||||
|
offers: [],
|
||||||
|
structuralCost: 1_350,
|
||||||
|
structuralEstimateRange: { min: 1_080, max: 1_620 },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
scenario({
|
scenario({
|
||||||
id: 'scenario-substitution-updated',
|
id: 'scenario-substitution-updated',
|
||||||
@ -2423,13 +2457,11 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect((await screen.findAllByText('旧版结果待重新核算')).length).toBeGreaterThan(0)
|
expect((await screen.findAllByText('约 ¥1,350.00')).length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText('已计部分 ¥500.00(不是整件成本)').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('预计出厂成本').length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText('旧版结果待重新核算').length).toBeGreaterThan(0)
|
expect(screen.getByText('1 行预估待补,不影响已形成的整件预估')).toBeInTheDocument()
|
||||||
expect(screen.getByText('1 行没有可验证价格来源')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/最近同步未成功:上游连接超时/)).toBeInTheDocument()
|
|
||||||
const skuButton = screen.getByRole('button', { name: /黑胡桃色 120cm/ })
|
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()
|
expect(screen.getByText('旧规则留档:¥1,300.00')).toBeInTheDocument()
|
||||||
const structureSummary = screen.getByText('结构与推导依据')
|
const structureSummary = screen.getByText('结构与推导依据')
|
||||||
fireEvent.click(structureSummary)
|
fireEvent.click(structureSummary)
|
||||||
@ -2439,31 +2471,8 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
expect(within(structureDetails as HTMLElement).getByText(/结构初判 1 轮 · 定向复核 2 轮/)).toBeInTheDocument()
|
expect(within(structureDetails as HTMLElement).getByText(/结构初判 1 轮 · 定向复核 2 轮/)).toBeInTheDocument()
|
||||||
expect(within(structureDetails as HTMLElement).getByText('待复核')).toBeInTheDocument()
|
expect(within(structureDetails as HTMLElement).getByText('待复核')).toBeInTheDocument()
|
||||||
expect(within(structureDetails as HTMLElement).getAllByText(/装配关系推导/).length).toBeGreaterThan(0)
|
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', {
|
const dynamicCostingHeading = screen.getByRole('heading', {
|
||||||
name: '分层成本预估',
|
name: '分层成本预估',
|
||||||
level: 2,
|
level: 2,
|
||||||
@ -2489,39 +2498,22 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
)
|
)
|
||||||
expect(screen.queryByText('暂无图片')).not.toBeInTheDocument()
|
expect(screen.queryByText('暂无图片')).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('heading', { name: '选择 SKU', level: 3 })).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', {
|
const costInfoButton = screen.getAllByRole('button', {
|
||||||
name: '查看预计出厂成本说明',
|
name: '查看预计出厂成本说明',
|
||||||
})[0]
|
})[0]
|
||||||
fireEvent.click(costInfoButton)
|
fireEvent.click(costInfoButton)
|
||||||
expect(screen.getAllByText(/板材按 8 件稳定批量排版/).length).toBeGreaterThan(0)
|
expect(screen.getAllByText(/估算区间 ¥1,080.00–¥1,620.00/).length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText(/批量利用率 82%/).length).toBeGreaterThan(0)
|
expect(screen.getAllByText(/结构明细预估待补/).length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText(/单件整板承担上限 ¥1,000.00/).length).toBeGreaterThan(0)
|
expect(screen.getAllByText('预估完成').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.getByText('成本贡献明细')).toBeInTheDocument()
|
expect(screen.getByText('成本贡献明细')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('最大贡献').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('最大贡献').length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText(/占(?:已知成本 )?62%/).length).toBeGreaterThan(0)
|
expect(screen.getAllByText(/占(?:已知成本 )?59%/).length).toBeGreaterThan(0)
|
||||||
expect(screen.getAllByText('较同材质').length).toBeGreaterThan(0)
|
expect(screen.queryByLabelText('选择替换目标材质')).not.toBeInTheDocument()
|
||||||
expect(screen.getAllByText('节省 ¥50.00').length).toBeGreaterThan(0)
|
expect(screen.queryByText('生成替换方案')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText('-¥50.00')).not.toBeInTheDocument()
|
expect(screen.queryByText('竞品店铺')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('item.example.com')).not.toBeInTheDocument()
|
||||||
const costSummaryAside = screen.getByText('预计出厂成本 · 橡胶木').closest('aside')
|
expect(screen.queryByText(/1688|实时同款|实时采购|公司单价|公司历史 BOM|参考 SKU|竞品售价|非正式采购价/iu)).not.toBeInTheDocument()
|
||||||
expect(costSummaryAside).not.toBeNull()
|
|
||||||
expect(within(costSummaryAside as HTMLElement).getByText('竞品售价 ¥600.00')).toBeInTheDocument()
|
|
||||||
expect(within(costSummaryAside as HTMLElement).getByText(/高于竞品售价 ¥700.00/)).toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '同材质基准' }))
|
|
||||||
expect(screen.queryByText('较同材质')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('替换后预计')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('节省 ¥50.00')).not.toBeInTheDocument()
|
|
||||||
expect(screen.getAllByText('历史价').length).toBeGreaterThan(0)
|
|
||||||
} finally {
|
} finally {
|
||||||
global.fetch = originalFetch
|
global.fetch = originalFetch
|
||||||
}
|
}
|
||||||
@ -2712,10 +2704,8 @@ describe('BomCostingWorkbenchClient', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(await screen.findByText('待确认品类商品')).toBeInTheDocument()
|
expect(await screen.findByText('结构草稿')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('品类待确认').length).toBeGreaterThan(0)
|
|
||||||
expect(screen.getByText('请先确认品类')).toBeInTheDocument()
|
expect(screen.getByText('请先确认品类')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('暂不估价').length).toBeGreaterThan(0)
|
|
||||||
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
|
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByRole('heading', { name: '分层成本预估' })).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 {
|
export interface BomCostingCaseSummary {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
sourceUrl: string
|
|
||||||
sourceHost: string
|
|
||||||
status: BomCostingCaseStatusValue
|
status: BomCostingCaseStatusValue
|
||||||
progress: number
|
progress: number
|
||||||
statusMessage: string
|
statusMessage: string
|
||||||
@ -61,10 +59,7 @@ export interface BomCostingSourceAssetDetail {
|
|||||||
export interface BomCostingSourceSnapshotDetail {
|
export interface BomCostingSourceSnapshotDetail {
|
||||||
id: string
|
id: string
|
||||||
status: BomCostingSourceStatusValue
|
status: BomCostingSourceStatusValue
|
||||||
channel: string
|
|
||||||
externalItemId: string
|
|
||||||
title: string
|
title: string
|
||||||
shopName: string
|
|
||||||
skuCount: number
|
skuCount: number
|
||||||
imageCount: number
|
imageCount: number
|
||||||
storedImageCount: number
|
storedImageCount: number
|
||||||
@ -95,7 +90,6 @@ export interface BomCostingLineDetail {
|
|||||||
lossFactor: number
|
lossFactor: number
|
||||||
sourceConfidence: number | null
|
sourceConfidence: number | null
|
||||||
evidence: Array<{
|
evidence: Array<{
|
||||||
sourceUrl?: string
|
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
basis?: 'IMAGE' | 'SOURCE_TEXT' | 'SKU_METADATA' | 'STRUCTURAL_INFERENCE'
|
basis?: 'IMAGE' | 'SOURCE_TEXT' | 'SKU_METADATA' | 'STRUCTURAL_INFERENCE'
|
||||||
note?: string
|
note?: string
|
||||||
@ -104,30 +98,11 @@ export interface BomCostingLineDetail {
|
|||||||
reviewReason?: string
|
reviewReason?: string
|
||||||
confidence: number
|
confidence: number
|
||||||
}>
|
}>
|
||||||
matchStatus: 'UNMATCHED' | 'MATCHED' | 'NEEDS_CONFIRMATION'
|
estimateStatus: 'ESTIMATED' | 'PENDING'
|
||||||
matchedLibraryType: string
|
|
||||||
matchedLibraryRecordId: string
|
|
||||||
matchedLibraryName: string
|
|
||||||
matchedLibrarySpec: string
|
|
||||||
resolvedUnitPrice: number | null
|
|
||||||
resolvedPriceUnit?: string
|
|
||||||
calculatedQuantity: number | null
|
|
||||||
subtotal: number | null
|
subtotal: number | null
|
||||||
updatedAt: string
|
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 {
|
export interface BomCostingSkuDetail {
|
||||||
id: string
|
id: string
|
||||||
externalSkuId: string
|
externalSkuId: string
|
||||||
@ -160,11 +135,7 @@ export interface BomCostingSkuDetail {
|
|||||||
|
|
||||||
export interface BomCostingCaseDetail {
|
export interface BomCostingCaseDetail {
|
||||||
id: string
|
id: string
|
||||||
sourceUrl: string
|
|
||||||
sourceHost: string
|
|
||||||
title: string
|
title: string
|
||||||
providerCode: string
|
|
||||||
providerTaskId: string
|
|
||||||
sourceCacheHit: boolean
|
sourceCacheHit: boolean
|
||||||
status: BomCostingCaseStatusValue
|
status: BomCostingCaseStatusValue
|
||||||
progress: number
|
progress: number
|
||||||
@ -209,30 +180,6 @@ export interface BomCostingCaseDetail {
|
|||||||
} | null
|
} | null
|
||||||
sourceSnapshot: BomCostingSourceSnapshotDetail | null
|
sourceSnapshot: BomCostingSourceSnapshotDetail | null
|
||||||
skus: BomCostingSkuDetail[]
|
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<{
|
analysisRuns: Array<{
|
||||||
id: string
|
id: string
|
||||||
revision: number
|
revision: number
|
||||||
@ -269,24 +216,12 @@ export interface BomCostingCaseDetail {
|
|||||||
dynamicCosting?: BomCostingDynamicCostingDetail
|
dynamicCosting?: BomCostingDynamicCostingDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BomCostingDynamicScenarioKindValue =
|
|
||||||
| 'SAME_MATERIAL'
|
|
||||||
| 'MATERIAL_SUBSTITUTION'
|
|
||||||
|
|
||||||
export type BomCostingDynamicStatusValue =
|
export type BomCostingDynamicStatusValue =
|
||||||
| 'FINAL'
|
| 'FINAL'
|
||||||
| 'PROVISIONAL'
|
| 'PROVISIONAL'
|
||||||
| 'INSUFFICIENT_DATA'
|
| 'INSUFFICIENT_DATA'
|
||||||
| 'STALE'
|
| 'STALE'
|
||||||
|
|
||||||
export type BomCostingEstimateBasisValue =
|
|
||||||
| 'MASTER_DATA'
|
|
||||||
| 'DIRECT_HISTORY'
|
|
||||||
| 'SKU_FAMILY'
|
|
||||||
| 'SIMILAR_HISTORY'
|
|
||||||
| 'CATEGORY_COHORT'
|
|
||||||
| 'AI_REFERENCE_ESTIMATE'
|
|
||||||
|
|
||||||
export interface BomCostingEstimateRangeValue {
|
export interface BomCostingEstimateRangeValue {
|
||||||
min: number
|
min: number
|
||||||
max: number
|
max: number
|
||||||
@ -294,8 +229,6 @@ export interface BomCostingEstimateRangeValue {
|
|||||||
|
|
||||||
export interface BomCostingScenarioLineDetail {
|
export interface BomCostingScenarioLineDetail {
|
||||||
id: string
|
id: string
|
||||||
sourceLineId: string | null
|
|
||||||
sourceRowId: string
|
|
||||||
category: string
|
category: string
|
||||||
partName: string
|
partName: string
|
||||||
originalMaterial: string
|
originalMaterial: string
|
||||||
@ -303,40 +236,22 @@ export interface BomCostingScenarioLineDetail {
|
|||||||
materialSpec: string
|
materialSpec: string
|
||||||
unit: string
|
unit: string
|
||||||
quantity: number | null
|
quantity: number | null
|
||||||
coefficient: number | null
|
|
||||||
pricingQuantity: number | null
|
|
||||||
unitPrice: number | null
|
|
||||||
subtotal: number | null
|
subtotal: number | null
|
||||||
estimateRange: BomCostingEstimateRangeValue | 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
|
confidence: number | null
|
||||||
status: BomCostingDynamicStatusValue
|
status: BomCostingDynamicStatusValue
|
||||||
warning: string
|
warning: string
|
||||||
rawSnapshot: unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BomCostingScenarioDetail {
|
export interface BomCostingScenarioDetail {
|
||||||
id: string
|
id: string
|
||||||
skuId: string
|
skuId: string
|
||||||
kind: BomCostingDynamicScenarioKindValue
|
kind: 'SAME_MATERIAL'
|
||||||
targetMaterialId: string | null
|
|
||||||
targetMaterialName: string
|
|
||||||
revision: number
|
revision: number
|
||||||
isCurrent: boolean
|
isCurrent: boolean
|
||||||
status: BomCostingDynamicStatusValue
|
status: BomCostingDynamicStatusValue
|
||||||
totalCost: number | null
|
totalCost: number | null
|
||||||
estimateRange: BomCostingEstimateRangeValue | null
|
estimateRange: BomCostingEstimateRangeValue | null
|
||||||
estimateBasis: BomCostingEstimateBasisValue | null
|
|
||||||
sampleCount: number
|
|
||||||
categoryEstimateRanges: {
|
categoryEstimateRanges: {
|
||||||
MATERIAL: BomCostingEstimateRangeValue | null
|
MATERIAL: BomCostingEstimateRangeValue | null
|
||||||
PROCESSING: BomCostingEstimateRangeValue | null
|
PROCESSING: BomCostingEstimateRangeValue | null
|
||||||
@ -344,59 +259,20 @@ export interface BomCostingScenarioDetail {
|
|||||||
PACKAGING: BomCostingEstimateRangeValue | null
|
PACKAGING: BomCostingEstimateRangeValue | null
|
||||||
LOSS: BomCostingEstimateRangeValue | null
|
LOSS: BomCostingEstimateRangeValue | null
|
||||||
}
|
}
|
||||||
estimatedLineCount: number
|
|
||||||
knownCost: number | null
|
|
||||||
materialCost: number | null
|
materialCost: number | null
|
||||||
processingCost: number | null
|
processingCost: number | null
|
||||||
laborCost: number | null
|
laborCost: number | null
|
||||||
packagingCost: number | null
|
packagingCost: number | null
|
||||||
lossCost: number | null
|
lossCost: number | null
|
||||||
competitorPrice: number | null
|
|
||||||
priceDelta: number | null
|
|
||||||
marketPriceAlert?: 'LOW' | 'HIGH' | null
|
|
||||||
confidence: number | null
|
confidence: number | null
|
||||||
warning: string
|
warning: string
|
||||||
unresolvedCount: number
|
unresolvedCount: number
|
||||||
dataAsOf: string | null
|
|
||||||
syncRunId: string | null
|
|
||||||
syncStatus: string | null
|
|
||||||
syncCompletedAt: string | null
|
|
||||||
rulesetVersion: string
|
|
||||||
createdAt: string
|
createdAt: string
|
||||||
boardCutting: Array<{
|
|
||||||
sourceLineId: string | null
|
|
||||||
materialName: string
|
|
||||||
rulesetVersion: string
|
|
||||||
selectedBatchSize: number
|
|
||||||
batchSheetCount: number
|
|
||||||
perUnitSheetCost: number
|
|
||||||
utilization: number
|
|
||||||
singleUnitSheetCount: number
|
|
||||||
singleUnitSheetCost: number
|
|
||||||
}>
|
|
||||||
lines: BomCostingScenarioLineDetail[]
|
lines: BomCostingScenarioLineDetail[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BomCostingDynamicCostingDetail {
|
export interface BomCostingDynamicCostingDetail {
|
||||||
materials: Array<{
|
|
||||||
id: string
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
category: string
|
|
||||||
aliases: string[]
|
|
||||||
}>
|
|
||||||
scenarios: BomCostingScenarioDetail[]
|
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 {
|
export type {
|
||||||
|
|||||||
@ -57,12 +57,55 @@ export type DynamicBomEstimateBasis =
|
|||||||
| 'SIMILAR_HISTORY'
|
| 'SIMILAR_HISTORY'
|
||||||
| 'CATEGORY_COHORT'
|
| 'CATEGORY_COHORT'
|
||||||
| 'AI_REFERENCE_ESTIMATE'
|
| 'AI_REFERENCE_ESTIMATE'
|
||||||
|
| 'REALTIME_1688_MARKET'
|
||||||
|
|
||||||
export interface DynamicBomEstimateRange {
|
export interface DynamicBomEstimateRange {
|
||||||
min: number
|
min: number
|
||||||
max: number
|
max: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-product realtime market signal. It is deliberately separate from
|
||||||
|
* line-level BOM prices: an image-search offer can anchor procurement cost for
|
||||||
|
* the finished SKU, but cannot truthfully become a board or hardware quote.
|
||||||
|
*/
|
||||||
|
export interface DynamicBomRealtimeMarketOffer {
|
||||||
|
itemId: string
|
||||||
|
skuId: string | null
|
||||||
|
title: string
|
||||||
|
skuTitle: string | null
|
||||||
|
detailUrl: string | null
|
||||||
|
imageUrl: string | null
|
||||||
|
supplier: string | null
|
||||||
|
currentPrice: number
|
||||||
|
similarityScore: number
|
||||||
|
qualityIndex: number | null
|
||||||
|
quantityBegin: number | null
|
||||||
|
unit: string | null
|
||||||
|
soldCount: number | null
|
||||||
|
stockAmount: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicBomRealtimeMarketQuote {
|
||||||
|
schemaVersion: 'bom-costing-1688-realtime/v1'
|
||||||
|
source: '1688_IMAGE_SEARCH'
|
||||||
|
status: 'AVAILABLE' | 'NO_COMPARABLE_OFFER' | 'NOT_CONFIGURED' | 'IMAGE_UNAVAILABLE' | 'FAILED'
|
||||||
|
sourceImageUrl: string | null
|
||||||
|
searchedAt: string | null
|
||||||
|
procurementRate: number
|
||||||
|
marketPrice: number | null
|
||||||
|
procurementCost: number | null
|
||||||
|
estimateRange: DynamicBomEstimateRange | null
|
||||||
|
confidence: number | null
|
||||||
|
comparableOfferCount: number
|
||||||
|
returnedOfferCount: number
|
||||||
|
warnings: string[]
|
||||||
|
offers: DynamicBomRealtimeMarketOffer[]
|
||||||
|
/** Structural BOM estimate before the current market anchor is selected. */
|
||||||
|
structuralCost?: number | null
|
||||||
|
structuralEstimateRange?: DynamicBomEstimateRange | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface DynamicBomSourceRow {
|
export interface DynamicBomSourceRow {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
Id?: unknown
|
Id?: unknown
|
||||||
@ -335,6 +378,8 @@ export interface DynamicBomScenarioResult {
|
|||||||
unresolvedCount: number
|
unresolvedCount: number
|
||||||
warning: string
|
warning: string
|
||||||
dataAsOf: string | null
|
dataAsOf: string | null
|
||||||
|
/** Attached by the service after a realtime 1688 image search. */
|
||||||
|
realtimeMarketQuote?: DynamicBomRealtimeMarketQuote | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DynamicBomScenarioCalculationInput {
|
export interface DynamicBomScenarioCalculationInput {
|
||||||
|
|||||||
@ -222,6 +222,8 @@ type ScenarioCostResultInput = {
|
|||||||
categoryTotals: Record<string, number | null>
|
categoryTotals: Record<string, number | null>
|
||||||
unresolvedCount: number
|
unresolvedCount: number
|
||||||
warning: string
|
warning: string
|
||||||
|
realtimeMarketQuote?: unknown
|
||||||
|
wholeProductEstimateAudit?: unknown
|
||||||
lines: Array<Record<string, unknown>>
|
lines: Array<Record<string, unknown>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,6 +271,12 @@ async function createScenarioRecord(
|
|||||||
lossCost: input.result.lossCost,
|
lossCost: input.result.lossCost,
|
||||||
competitorPrice: input.competitorPrice,
|
competitorPrice: input.competitorPrice,
|
||||||
priceDelta,
|
priceDelta,
|
||||||
|
realtimeMarketQuote: input.result.realtimeMarketQuote === null || input.result.realtimeMarketQuote === undefined
|
||||||
|
? undefined
|
||||||
|
: input.result.realtimeMarketQuote as Prisma.InputJsonValue,
|
||||||
|
wholeProductEstimateAudit: input.result.wholeProductEstimateAudit === null || input.result.wholeProductEstimateAudit === undefined
|
||||||
|
? undefined
|
||||||
|
: input.result.wholeProductEstimateAudit as Prisma.InputJsonValue,
|
||||||
warning: input.result.warning,
|
warning: input.result.warning,
|
||||||
unresolvedCount: input.result.unresolvedCount,
|
unresolvedCount: input.result.unresolvedCount,
|
||||||
confidence: input.result.confidence,
|
confidence: input.result.confidence,
|
||||||
|
|||||||
@ -151,21 +151,6 @@ const caseDetailInclude = Prisma.validator<Prisma.BomCostingCaseInclude>()({
|
|||||||
orderBy: { revision: 'desc' },
|
orderBy: { revision: 'desc' },
|
||||||
take: 20,
|
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<{
|
export type BomCostingCaseDetailRecord = Prisma.BomCostingCaseGetPayload<{
|
||||||
|
|||||||
@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
BomCosting1688ImageSearchService,
|
||||||
|
buildAlibaba1688SignedHeaders,
|
||||||
|
buildBomCosting1688RealtimeMarketQuote,
|
||||||
|
canonicalAlibaba1688Resource,
|
||||||
|
extractAlibaba1688AkKeys,
|
||||||
|
getBomCosting1688ImageSearchConfig,
|
||||||
|
resolveBomCostingImageSearchSourceUrl,
|
||||||
|
type BomCosting1688ImageSearchConfig,
|
||||||
|
} from './bom-costing-1688-image-search'
|
||||||
|
|
||||||
|
const testAk = Buffer.from('0123456789abcdef0123456789abcdeftest-ak-id').toString('base64url')
|
||||||
|
|
||||||
|
const config: BomCosting1688ImageSearchConfig = {
|
||||||
|
endpoint: new URL('https://skills-gateway.1688.com/api/find_product/1.0.0'),
|
||||||
|
ak: testAk,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
maxImageBytes: 5 * 1024 * 1024,
|
||||||
|
minSimilarityScore: 0.78,
|
||||||
|
procurementRate: 0.9,
|
||||||
|
pageSize: 10,
|
||||||
|
purchaseAmount: 1,
|
||||||
|
tags: '4306497',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Alibaba 1688 CSK signing', () => {
|
||||||
|
it('decodes the URL-safe AK into an AK id and a 32-character signing secret', () => {
|
||||||
|
expect(extractAlibaba1688AkKeys(`1688apikey:${testAk}`)).toEqual({
|
||||||
|
secret: '0123456789abcdef0123456789abcdef',
|
||||||
|
akId: 'test-ak-id',
|
||||||
|
})
|
||||||
|
expect(extractAlibaba1688AkKeys('bad')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the official gateway and server-only ALIBABA environment variables', () => {
|
||||||
|
const previousAk = process.env.ALIBABA_1688_IMAGE_SEARCH_AK
|
||||||
|
const previousEndpoint = process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_AK = testAk
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL = 'https://skills-gateway.1688.com/api/find_product/1.0.0'
|
||||||
|
try {
|
||||||
|
expect(getBomCosting1688ImageSearchConfig()).toEqual(expect.objectContaining({
|
||||||
|
ak: testAk,
|
||||||
|
endpoint: new URL('https://skills-gateway.1688.com/api/find_product/1.0.0'),
|
||||||
|
pageSize: 10,
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
if (previousAk === undefined) delete process.env.ALIBABA_1688_IMAGE_SEARCH_AK
|
||||||
|
else process.env.ALIBABA_1688_IMAGE_SEARCH_AK = previousAk
|
||||||
|
if (previousEndpoint === undefined) delete process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL
|
||||||
|
else process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL = previousEndpoint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns only the public same-origin storage route into an absolute image URL', () => {
|
||||||
|
expect(resolveBomCostingImageSearchSourceUrl(
|
||||||
|
'/api/storage/object?key=prod%2Fsku.webp',
|
||||||
|
(key) => `https://objects.example.com/${key}`,
|
||||||
|
)).toEqual(
|
||||||
|
new URL('https://objects.example.com/prod/sku.webp'),
|
||||||
|
)
|
||||||
|
expect(() => resolveBomCostingImageSearchSourceUrl('/api/auth/me')).toThrow('竞品链接格式不正确')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts canonical query pairs and signs exactly the body sent to the gateway', () => {
|
||||||
|
expect(canonicalAlibaba1688Resource('https://skills-gateway.1688.com/api/find_product/1.0.0?z=2&a=b&a=a')).toBe(
|
||||||
|
'/api/find_product/1.0.0?a=a&a=b&z=2',
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = '{"imgBase64":"aW1hZ2U=","pageSize":10}'
|
||||||
|
expect(buildAlibaba1688SignedHeaders({
|
||||||
|
uri: 'https://skills-gateway.1688.com/api/find_product/1.0.0',
|
||||||
|
body,
|
||||||
|
ak: testAk,
|
||||||
|
timestamp: '1786903200',
|
||||||
|
nonce: '01020304',
|
||||||
|
})).toEqual({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-csk-ak': 'test-ak-id',
|
||||||
|
'x-csk-time': '1786903200',
|
||||||
|
'x-csk-nonce': '01020304',
|
||||||
|
'x-csk-content-md5': 'x1mlqy1bkZMZMp7OFnTphA==',
|
||||||
|
'x-csk-version': '1.7.0',
|
||||||
|
'x-csk-sign': 'IYDSV+EEOCylcqpmITFvMyIlicUU6XxUVl81cbogwhE=',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildBomCosting1688RealtimeMarketQuote', () => {
|
||||||
|
it('uses the robust realtime price centre and the configured procurement rate', () => {
|
||||||
|
const quote = buildBomCosting1688RealtimeMarketQuote({
|
||||||
|
sourceImageUrl: 'https://images.example.com/sku.jpg',
|
||||||
|
searchedAt: new Date('2026-08-15T10:00:00.000Z'),
|
||||||
|
procurementRate: 0.9,
|
||||||
|
minSimilarityScore: 0.78,
|
||||||
|
returnedOffers: [
|
||||||
|
{ itemId: 'one', title: '同款 A', score: 0.99, currentPrice: 100 },
|
||||||
|
{ itemId: 'two', title: '同款 B', score: 0.95, currentPrice: 110 },
|
||||||
|
{ itemId: 'three', title: '同款 C', score: 0.9, currentPrice: 120 },
|
||||||
|
{ itemId: 'ignored', title: '低相关', score: 0.6, currentPrice: 1 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(quote.status).toBe('AVAILABLE')
|
||||||
|
expect(quote.marketPrice).toBe(110)
|
||||||
|
expect(quote.procurementCost).toBe(99)
|
||||||
|
expect(quote.estimateRange).toEqual({ min: 93.6, max: 104.4 })
|
||||||
|
expect(quote.comparableOfferCount).toBe(3)
|
||||||
|
expect(quote.offers).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fabricate a current price from weak or invalid offers', () => {
|
||||||
|
const quote = buildBomCosting1688RealtimeMarketQuote({
|
||||||
|
sourceImageUrl: 'https://images.example.com/sku.jpg',
|
||||||
|
searchedAt: new Date('2026-08-15T10:00:00.000Z'),
|
||||||
|
procurementRate: 0.9,
|
||||||
|
minSimilarityScore: 0.9,
|
||||||
|
returnedOffers: [
|
||||||
|
{ itemId: 'weak', title: '弱相似', score: 0.89, currentPrice: 100 },
|
||||||
|
{ itemId: 'invalid', title: '无价', score: 0.99, currentPrice: null },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(quote.status).toBe('NO_COMPARABLE_OFFER')
|
||||||
|
expect(quote.procurementCost).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BomCosting1688ImageSearchService', () => {
|
||||||
|
it('signs the pure Base64 request body and accepts official model/item aliases', async () => {
|
||||||
|
const imageResponse = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ 'content-type': 'image/jpeg' }),
|
||||||
|
arrayBuffer: async () => Buffer.from('image-bytes'),
|
||||||
|
} as unknown as Response
|
||||||
|
const apiResponse = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
model: {
|
||||||
|
count: 1,
|
||||||
|
data: [{
|
||||||
|
item_id: 1,
|
||||||
|
subject: '同款',
|
||||||
|
score: 0.99,
|
||||||
|
price: 200,
|
||||||
|
quantity_begin: 2,
|
||||||
|
store_amount: 99,
|
||||||
|
companyName: '测试工厂',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as unknown as Response
|
||||||
|
const fetch = jest.fn()
|
||||||
|
.mockResolvedValueOnce(imageResponse)
|
||||||
|
.mockResolvedValueOnce(apiResponse)
|
||||||
|
const service = new BomCosting1688ImageSearchService({
|
||||||
|
getConfig: () => config,
|
||||||
|
fetch,
|
||||||
|
now: () => new Date('2026-08-16T10:00:00.000Z'),
|
||||||
|
randomBytes: () => Buffer.from([1, 2, 3, 4]),
|
||||||
|
createStoredObjectReadUrl: (key) => `https://objects.example.com/${key}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const quote = await service.findRealtimeQuote({
|
||||||
|
imageUrl: 'https://images.example.com/sku.jpg',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(quote.procurementCost).toBe(180)
|
||||||
|
expect(quote.offers[0]).toEqual(expect.objectContaining({
|
||||||
|
itemId: '1',
|
||||||
|
supplier: '测试工厂',
|
||||||
|
quantityBegin: 2,
|
||||||
|
stockAmount: 99,
|
||||||
|
detailUrl: 'https://detail.1688.com/offer/1.html',
|
||||||
|
}))
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2)
|
||||||
|
const request = fetch.mock.calls[1]?.[1]
|
||||||
|
const expectedBody = JSON.stringify({
|
||||||
|
imgBase64: Buffer.from('image-bytes').toString('base64'),
|
||||||
|
pageSize: 10,
|
||||||
|
purchaseAmount: 1,
|
||||||
|
sortType: 'yx_desc',
|
||||||
|
scoreLevel: 'high',
|
||||||
|
tags: '4306497',
|
||||||
|
icTags: '',
|
||||||
|
})
|
||||||
|
expect(request?.body).toBe(expectedBody)
|
||||||
|
expect(request?.headers).toEqual(expect.objectContaining({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-csk-ak': 'test-ak-id',
|
||||||
|
'x-csk-time': '1786874400',
|
||||||
|
'x-csk-nonce': '01020304',
|
||||||
|
'x-csk-content-md5': 'o38VnblUKGb4d8GTwvYg7Q==',
|
||||||
|
'x-csk-version': '1.7.0',
|
||||||
|
}))
|
||||||
|
expect(JSON.parse(request?.body ?? '{}')).not.toHaveProperty('imageUrl')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches a stored same-origin product image through its backing object storage URL', async () => {
|
||||||
|
const imageResponse = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ 'content-type': 'image/jpeg' }),
|
||||||
|
arrayBuffer: async () => Buffer.from('image-bytes'),
|
||||||
|
} as unknown as Response
|
||||||
|
const apiResponse = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => JSON.stringify({ success: true, model: { count: 0, data: [] } }),
|
||||||
|
} as unknown as Response
|
||||||
|
const fetch = jest.fn()
|
||||||
|
.mockResolvedValueOnce(imageResponse)
|
||||||
|
.mockResolvedValueOnce(apiResponse)
|
||||||
|
const service = new BomCosting1688ImageSearchService({
|
||||||
|
getConfig: () => config,
|
||||||
|
fetch,
|
||||||
|
now: () => new Date('2026-08-16T10:00:00.000Z'),
|
||||||
|
randomBytes: () => Buffer.from([1, 2, 3, 4]),
|
||||||
|
createStoredObjectReadUrl: (key) => `https://objects.example.com/${key}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(service.findRealtimeQuote({
|
||||||
|
imageUrl: '/api/storage/object?key=prod%2Fbom-costing%2Fsku.webp',
|
||||||
|
})).resolves.toMatchObject({ status: 'NO_COMPARABLE_OFFER' })
|
||||||
|
expect(fetch.mock.calls[0]?.[0]).toEqual(
|
||||||
|
new URL('https://objects.example.com/prod/bom-costing/sku.webp'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('degrades without a hidden browser fallback when the service is not configured', async () => {
|
||||||
|
const fetch = jest.fn()
|
||||||
|
const service = new BomCosting1688ImageSearchService({
|
||||||
|
getConfig: () => null,
|
||||||
|
fetch,
|
||||||
|
now: () => new Date('2026-08-15T10:00:00.000Z'),
|
||||||
|
randomBytes: () => Buffer.from([1, 2, 3, 4]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(service.findRealtimeQuote({
|
||||||
|
imageUrl: 'https://images.example.com/sku.jpg',
|
||||||
|
})).resolves.toMatchObject({ status: 'NOT_CONFIGURED', procurementCost: null })
|
||||||
|
expect(fetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the structural estimate when the gateway returns a business error', async () => {
|
||||||
|
const imageResponse = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ 'content-type': 'image/jpeg' }),
|
||||||
|
arrayBuffer: async () => Buffer.from('image-bytes'),
|
||||||
|
} as unknown as Response
|
||||||
|
const apiResponse = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => JSON.stringify({ success: false, code: 'INVALID_AK', msgInfo: '无效 AK' }),
|
||||||
|
} as unknown as Response
|
||||||
|
const service = new BomCosting1688ImageSearchService({
|
||||||
|
getConfig: () => config,
|
||||||
|
fetch: jest.fn().mockResolvedValueOnce(imageResponse).mockResolvedValueOnce(apiResponse),
|
||||||
|
now: () => new Date('2026-08-15T10:00:00.000Z'),
|
||||||
|
randomBytes: () => Buffer.from([1, 2, 3, 4]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(service.findRealtimeQuote({
|
||||||
|
imageUrl: 'https://images.example.com/sku.jpg',
|
||||||
|
})).resolves.toMatchObject({ status: 'FAILED', procurementCost: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,603 @@
|
|||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
|
||||||
|
import { createObjectReadUrl, resolveObjectStorageUrlForServerAccess } from '@/lib/storage'
|
||||||
|
import type {
|
||||||
|
DynamicBomRealtimeMarketOffer,
|
||||||
|
DynamicBomRealtimeMarketQuote,
|
||||||
|
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
|
||||||
|
|
||||||
|
const OFFICIAL_IMAGE_SEARCH_ENDPOINT = 'https://skills-gateway.1688.com/api/find_product/1.0.0'
|
||||||
|
const IMAGE_SEARCH_PATH = '/api/find_product/1.0.0'
|
||||||
|
const CSK_SIGNATURE_VERSION = '1.7.0'
|
||||||
|
const DEFAULT_IMAGE_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
const DEFAULT_TIMEOUT_MS = 45_000
|
||||||
|
const DEFAULT_MINIMUM_SCORE = 0.78
|
||||||
|
const DEFAULT_PROCUREMENT_RATE = 0.9
|
||||||
|
|
||||||
|
const finiteNumber = z.union([z.number(), z.string()]).transform((value, context) => {
|
||||||
|
const parsed = typeof value === 'number' ? value : Number(value.trim())
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
context.addIssue({ code: 'custom', message: '必须是有限数字' })
|
||||||
|
return z.NEVER
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
})
|
||||||
|
|
||||||
|
const nullableFiniteNumber = finiteNumber.nullish().transform((value) => value ?? null)
|
||||||
|
|
||||||
|
const imageSearchItemSchema = z.object({
|
||||||
|
itemId: z.union([z.string(), z.number()]).transform(String).pipe(z.string().trim().min(1)),
|
||||||
|
title: z.string().trim().max(500).optional().default(''),
|
||||||
|
imageUrl: z.string().url().optional().nullable().default(null),
|
||||||
|
detailUrl: z.string().url().optional().nullable().default(null),
|
||||||
|
score: nullableFiniteNumber,
|
||||||
|
currentPrice: nullableFiniteNumber,
|
||||||
|
skuId: z.union([z.string(), z.number()]).optional().nullable().transform((value) => (
|
||||||
|
value === null || value === undefined ? null : String(value)
|
||||||
|
)),
|
||||||
|
skuTitle: z.string().trim().max(500).optional().nullable().default(null),
|
||||||
|
yxIndex: nullableFiniteNumber,
|
||||||
|
quantityBegin: nullableFiniteNumber,
|
||||||
|
unit: z.string().trim().max(80).optional().nullable().default(null),
|
||||||
|
company: z.string().trim().max(300).optional().nullable().default(null),
|
||||||
|
stockAmount: nullableFiniteNumber,
|
||||||
|
}).passthrough()
|
||||||
|
|
||||||
|
export interface BomCosting1688ImageSearchConfig {
|
||||||
|
endpoint: URL
|
||||||
|
/** URL-safe Base64 AK. It is never exposed to browser clients or logs. */
|
||||||
|
ak: string
|
||||||
|
timeoutMs: number
|
||||||
|
maxImageBytes: number
|
||||||
|
minSimilarityScore: number
|
||||||
|
procurementRate: number
|
||||||
|
pageSize: number
|
||||||
|
purchaseAmount: number
|
||||||
|
tags: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BomCosting1688ComparableOffer = DynamicBomRealtimeMarketOffer
|
||||||
|
export type BomCosting1688RealtimeMarketQuote = DynamicBomRealtimeMarketQuote
|
||||||
|
|
||||||
|
export interface BomCosting1688ImageSearchGateway {
|
||||||
|
getConfig(): BomCosting1688ImageSearchConfig | null
|
||||||
|
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
|
||||||
|
now(): Date
|
||||||
|
randomBytes(size: number): Buffer
|
||||||
|
createStoredObjectReadUrl?: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedPositiveInteger(
|
||||||
|
value: string | undefined,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum
|
||||||
|
? parsed
|
||||||
|
: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedDecimal(
|
||||||
|
value: string | undefined,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum
|
||||||
|
? parsed
|
||||||
|
: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBomCosting1688ImageSearchConfig(): BomCosting1688ImageSearchConfig | null {
|
||||||
|
const explicitEndpoint = (
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL ??
|
||||||
|
process.env.BOM_COSTING_1688_IMAGE_SEARCH_ENDPOINT
|
||||||
|
)?.trim()
|
||||||
|
const ak = (
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_AK ??
|
||||||
|
process.env.BOM_COSTING_1688_IMAGE_SEARCH_API_KEY
|
||||||
|
)?.trim()
|
||||||
|
if (!ak || !extractAlibaba1688AkKeys(ak)) return null
|
||||||
|
|
||||||
|
let endpoint: URL
|
||||||
|
try {
|
||||||
|
endpoint = new URL(explicitEndpoint || OFFICIAL_IMAGE_SEARCH_ENDPOINT)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
endpoint.protocol !== 'https:' ||
|
||||||
|
endpoint.hostname !== 'skills-gateway.1688.com' ||
|
||||||
|
endpoint.pathname !== IMAGE_SEARCH_PATH
|
||||||
|
) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
ak,
|
||||||
|
timeoutMs: boundedPositiveInteger(
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS ??
|
||||||
|
process.env.BOM_COSTING_1688_IMAGE_SEARCH_TIMEOUT_MS,
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
5_000,
|
||||||
|
120_000,
|
||||||
|
),
|
||||||
|
maxImageBytes: boundedPositiveInteger(
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_MAX_IMAGE_BYTES ??
|
||||||
|
process.env.BOM_COSTING_1688_IMAGE_SEARCH_MAX_IMAGE_BYTES,
|
||||||
|
DEFAULT_IMAGE_MAX_BYTES,
|
||||||
|
1_024,
|
||||||
|
DEFAULT_IMAGE_MAX_BYTES,
|
||||||
|
),
|
||||||
|
minSimilarityScore: boundedDecimal(
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY ??
|
||||||
|
process.env.BOM_COSTING_1688_IMAGE_SEARCH_MIN_SIMILARITY,
|
||||||
|
DEFAULT_MINIMUM_SCORE,
|
||||||
|
0.5,
|
||||||
|
0.9999,
|
||||||
|
),
|
||||||
|
procurementRate: boundedDecimal(
|
||||||
|
process.env.ALIBABA_1688_PROCUREMENT_RATE ??
|
||||||
|
process.env.BOM_COSTING_1688_PROCUREMENT_RATE,
|
||||||
|
DEFAULT_PROCUREMENT_RATE,
|
||||||
|
0.5,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
pageSize: boundedPositiveInteger(
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE ??
|
||||||
|
process.env.BOM_COSTING_1688_IMAGE_SEARCH_PAGE_SIZE,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
),
|
||||||
|
purchaseAmount: boundedPositiveInteger(
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT ??
|
||||||
|
process.env.BOM_COSTING_1688_IMAGE_SEARCH_PURCHASE_AMOUNT,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
100_000,
|
||||||
|
),
|
||||||
|
tags: (
|
||||||
|
process.env.ALIBABA_1688_IMAGE_SEARCH_TAGS ??
|
||||||
|
process.env.BOM_COSTING_1688_IMAGE_SEARCH_TAGS
|
||||||
|
)?.trim() || '4306497',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source snapshots keep object-storage links as same-origin relative paths.
|
||||||
|
* The server-side image-search request must read the backing object directly:
|
||||||
|
* the browser proxy correctly requires a login cookie and therefore cannot be
|
||||||
|
* used by a background pricing job. Only its storage-object route is eligible
|
||||||
|
* for this conversion.
|
||||||
|
*/
|
||||||
|
export function resolveBomCostingImageSearchSourceUrl(
|
||||||
|
rawValue: string,
|
||||||
|
buildStoredObjectReadUrl: (key: string) => string = createObjectReadUrl,
|
||||||
|
): URL {
|
||||||
|
const trimmed = rawValue.trim()
|
||||||
|
if (trimmed.startsWith('/api/storage/object?')) {
|
||||||
|
const parsed = new URL(trimmed, 'https://bom-costing.invalid')
|
||||||
|
const objectKey = parsed.searchParams.get('key')?.trim()
|
||||||
|
if (!objectKey) throw new Error('SKU 主图缺少对象存储 key')
|
||||||
|
return parseSafeCompetitorUrl(buildStoredObjectReadUrl(objectKey))
|
||||||
|
}
|
||||||
|
return parseSafeCompetitorUrl(resolveObjectStorageUrlForServerAccess(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultGateway: BomCosting1688ImageSearchGateway = {
|
||||||
|
getConfig: getBomCosting1688ImageSearchConfig,
|
||||||
|
fetch: (...args) => fetch(...args),
|
||||||
|
now: () => new Date(),
|
||||||
|
randomBytes: (size) => randomBytes(size),
|
||||||
|
createStoredObjectReadUrl: createObjectReadUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Alibaba1688AkKeys {
|
||||||
|
akId: string
|
||||||
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The official AK is a URL-safe Base64 text. Its decoded first 32 characters
|
||||||
|
* are the signing secret; the remainder is the public AK id.
|
||||||
|
*/
|
||||||
|
export function extractAlibaba1688AkKeys(rawAk: string | null | undefined): Alibaba1688AkKeys | null {
|
||||||
|
const labeled = String(rawAk ?? '').trim()
|
||||||
|
const raw = labeled.replace(/^1688apikey\s*:/i, '').trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
let decoded = ''
|
||||||
|
try {
|
||||||
|
decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
||||||
|
} catch {
|
||||||
|
decoded = raw
|
||||||
|
}
|
||||||
|
if (decoded.length <= 32) decoded = raw
|
||||||
|
const secret = decoded.slice(0, 32)
|
||||||
|
const akId = decoded.slice(32).trim()
|
||||||
|
return secret.length === 32 && akId ? { secret, akId } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalAlibaba1688Resource(uri: string | URL): string {
|
||||||
|
const parsed = new URL(uri, OFFICIAL_IMAGE_SEARCH_ENDPOINT)
|
||||||
|
const entries = Array.from(parsed.searchParams.entries()).sort(([leftKey, leftValue], [rightKey, rightValue]) => (
|
||||||
|
leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)
|
||||||
|
))
|
||||||
|
const query = entries
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||||
|
.join('&')
|
||||||
|
return `${parsed.pathname || '/'}${query ? `?${query}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAlibaba1688SignedHeaders(input: {
|
||||||
|
method?: string
|
||||||
|
uri: string | URL
|
||||||
|
body: string
|
||||||
|
ak: string
|
||||||
|
timestamp?: string
|
||||||
|
nonce?: string
|
||||||
|
}): Record<string, string> {
|
||||||
|
const keys = extractAlibaba1688AkKeys(input.ak)
|
||||||
|
if (!keys) throw new Error('1688 AK 格式无效')
|
||||||
|
const contentType = 'application/json'
|
||||||
|
const timestamp = input.timestamp ?? String(Math.trunc(Date.now() / 1000))
|
||||||
|
const nonce = input.nonce ?? randomBytes(4).toString('hex')
|
||||||
|
if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{8}$/i.test(nonce)) {
|
||||||
|
throw new Error('1688 签名时间或随机数格式无效')
|
||||||
|
}
|
||||||
|
const contentMd5 = input.body
|
||||||
|
? createHash('md5').update(input.body, 'utf8').digest('base64')
|
||||||
|
: ''
|
||||||
|
const cskHeaders: Record<string, string> = {
|
||||||
|
'x-csk-ak': keys.akId,
|
||||||
|
'x-csk-time': timestamp,
|
||||||
|
'x-csk-nonce': nonce,
|
||||||
|
'x-csk-content-md5': contentMd5,
|
||||||
|
'x-csk-version': CSK_SIGNATURE_VERSION,
|
||||||
|
}
|
||||||
|
const canonicalHeaders = Object.keys(cskHeaders)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${key}:${cskHeaders[key]!.trim()}\n`)
|
||||||
|
.join('')
|
||||||
|
const stringToSign = `${(input.method ?? 'POST').toUpperCase()}\n${contentMd5}\n${contentType}\n${timestamp}\n${canonicalHeaders}${canonicalAlibaba1688Resource(input.uri)}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'x-csk-sign': createHmac('sha256', keys.secret).update(stringToSign, 'utf8').digest('base64'),
|
||||||
|
...cskHeaders,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMoney(value: number) {
|
||||||
|
return Number(value.toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageSearchItem = z.infer<typeof imageSearchItemSchema>
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstDefined(record: Record<string, unknown>, keys: readonly string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (record[key] !== undefined && record[key] !== null) return record[key]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function textValue(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string' && typeof value !== 'number') return null
|
||||||
|
const normalized = String(value).trim()
|
||||||
|
return normalized || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteValue(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const parsed = Number(value.trim())
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpUrlValue(value: unknown): string | null {
|
||||||
|
const text = textValue(value)
|
||||||
|
if (!text) return null
|
||||||
|
try {
|
||||||
|
const parsed = new URL(text)
|
||||||
|
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.toString() : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponseItem(value: unknown): ImageSearchItem | null {
|
||||||
|
const raw = asRecord(value)
|
||||||
|
if (!raw) return null
|
||||||
|
const itemId = textValue(firstDefined(raw, ['itemId', 'item_id', 'offerId', 'offer_id']))
|
||||||
|
if (!itemId) return null
|
||||||
|
const parsed = imageSearchItemSchema.safeParse({
|
||||||
|
itemId,
|
||||||
|
title: textValue(firstDefined(raw, ['title', 'subject', 'name'])) ?? '',
|
||||||
|
imageUrl: httpUrlValue(firstDefined(raw, ['imageUrl', 'image_url', 'image', 'imgUrl', 'img_url'])),
|
||||||
|
detailUrl: httpUrlValue(firstDefined(raw, ['detailUrl', 'detail_url', 'url'])) ?? `https://detail.1688.com/offer/${encodeURIComponent(itemId)}.html`,
|
||||||
|
score: finiteValue(firstDefined(raw, ['score', 'similarityScore', 'similarity_score'])),
|
||||||
|
currentPrice: finiteValue(firstDefined(raw, ['currentPrice', 'current_price', 'price'])),
|
||||||
|
skuId: textValue(firstDefined(raw, ['skuId', 'sku_id'])),
|
||||||
|
skuTitle: textValue(firstDefined(raw, ['skuTitle', 'sku_title'])),
|
||||||
|
yxIndex: finiteValue(firstDefined(raw, ['yxIndex', 'yx_index'])),
|
||||||
|
quantityBegin: finiteValue(firstDefined(raw, ['quantityBegin', 'quantity_begin'])),
|
||||||
|
unit: textValue(firstDefined(raw, ['unit'])),
|
||||||
|
company: textValue(firstDefined(raw, ['company', 'companyName', 'supplierName', 'supplier_name'])),
|
||||||
|
stockAmount: finiteValue(firstDefined(raw, ['storeAmount', 'store_amount', 'stockAmount', 'stock_amount'])),
|
||||||
|
raw,
|
||||||
|
})
|
||||||
|
return parsed.success ? parsed.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseFailureMessage(envelope: Record<string, unknown>): string | null {
|
||||||
|
const success = envelope.success
|
||||||
|
const httpCode = finiteValue(envelope.httpCode)
|
||||||
|
if (success !== false && (httpCode === null || httpCode < 400)) return null
|
||||||
|
const code = textValue(firstDefined(envelope, ['code', 'msgCode', 'errorCode']))
|
||||||
|
const message = textValue(firstDefined(envelope, ['msgInfo', 'message', 'error', 'errorMessage']))
|
||||||
|
return `1688 同款服务业务失败${code ? `(${code})` : ''}${message ? `:${message}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImageSearchResponse(payload: unknown): ImageSearchItem[] {
|
||||||
|
const envelope = asRecord(payload)
|
||||||
|
if (!envelope) throw new Error('1688 同款服务返回格式不完整')
|
||||||
|
const businessFailure = responseFailureMessage(envelope)
|
||||||
|
if (businessFailure) throw new Error(businessFailure)
|
||||||
|
|
||||||
|
const bodies = [envelope.model, envelope.data, envelope.result, envelope]
|
||||||
|
for (const body of bodies) {
|
||||||
|
const rows = Array.isArray(body)
|
||||||
|
? body
|
||||||
|
: (() => {
|
||||||
|
const record = asRecord(body)
|
||||||
|
if (!record) return null
|
||||||
|
if (Array.isArray(record.data)) return record.data
|
||||||
|
if (Array.isArray(record.items)) return record.items
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
if (rows) return rows.map(normalizeResponseItem).filter((item): item is ImageSearchItem => item !== null)
|
||||||
|
}
|
||||||
|
throw new Error('1688 同款服务未返回商品列表')
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentile(values: readonly number[], ratio: number) {
|
||||||
|
if (values.length === 0) return null
|
||||||
|
const ordered = [...values].sort((left, right) => left - right)
|
||||||
|
const position = Math.max(0, Math.min(ordered.length - 1, (ordered.length - 1) * ratio))
|
||||||
|
const lower = Math.floor(position)
|
||||||
|
const upper = Math.ceil(position)
|
||||||
|
const lowerValue = ordered[lower] ?? 0
|
||||||
|
const upperValue = ordered[upper] ?? lowerValue
|
||||||
|
return lowerValue + (upperValue - lowerValue) * (position - lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBoundedImage(response: Response, maxBytes: number) {
|
||||||
|
const declaredLength = Number(response.headers.get('content-length'))
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||||
|
throw new Error('1688 同款检索图片超过 5MB 限制')
|
||||||
|
}
|
||||||
|
return response.arrayBuffer().then((buffer) => {
|
||||||
|
const bytes = Buffer.from(buffer)
|
||||||
|
if (bytes.length > maxBytes) throw new Error('1688 同款检索图片超过 5MB 限制')
|
||||||
|
return bytes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyQuote(input: Pick<
|
||||||
|
BomCosting1688RealtimeMarketQuote,
|
||||||
|
'status' | 'sourceImageUrl' | 'procurementRate' | 'warnings'
|
||||||
|
> & Partial<Pick<BomCosting1688RealtimeMarketQuote, 'searchedAt' | 'returnedOfferCount' | 'offers'>>): BomCosting1688RealtimeMarketQuote {
|
||||||
|
return {
|
||||||
|
schemaVersion: 'bom-costing-1688-realtime/v1',
|
||||||
|
source: '1688_IMAGE_SEARCH',
|
||||||
|
status: input.status,
|
||||||
|
sourceImageUrl: input.sourceImageUrl,
|
||||||
|
searchedAt: input.searchedAt ?? null,
|
||||||
|
procurementRate: input.procurementRate,
|
||||||
|
marketPrice: null,
|
||||||
|
procurementCost: null,
|
||||||
|
estimateRange: null,
|
||||||
|
confidence: null,
|
||||||
|
comparableOfferCount: 0,
|
||||||
|
returnedOfferCount: input.returnedOfferCount ?? 0,
|
||||||
|
warnings: input.warnings,
|
||||||
|
offers: input.offers ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBomCosting1688RealtimeMarketQuote(input: {
|
||||||
|
sourceImageUrl: string
|
||||||
|
searchedAt: Date
|
||||||
|
procurementRate: number
|
||||||
|
minSimilarityScore: number
|
||||||
|
returnedOffers: ImageSearchItem[]
|
||||||
|
}): BomCosting1688RealtimeMarketQuote {
|
||||||
|
const offers = input.returnedOffers.flatMap((item) => {
|
||||||
|
const price = item.currentPrice
|
||||||
|
const score = item.score
|
||||||
|
if (
|
||||||
|
price === null || price <= 0 ||
|
||||||
|
score === null || score < input.minSimilarityScore
|
||||||
|
) return []
|
||||||
|
return [{
|
||||||
|
itemId: item.itemId,
|
||||||
|
skuId: item.skuId,
|
||||||
|
title: item.title,
|
||||||
|
skuTitle: item.skuTitle,
|
||||||
|
detailUrl: item.detailUrl,
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
supplier: item.company,
|
||||||
|
currentPrice: roundMoney(price),
|
||||||
|
similarityScore: Number(score.toFixed(4)),
|
||||||
|
qualityIndex: item.yxIndex == null ? null : Number(item.yxIndex.toFixed(4)),
|
||||||
|
quantityBegin: item.quantityBegin == null ? null : Math.max(1, Math.floor(item.quantityBegin)),
|
||||||
|
unit: item.unit,
|
||||||
|
soldCount: null,
|
||||||
|
stockAmount: item.stockAmount == null ? null : Math.max(0, Math.floor(item.stockAmount)),
|
||||||
|
} satisfies BomCosting1688ComparableOffer]
|
||||||
|
}).sort((left, right) => (
|
||||||
|
right.similarityScore - left.similarityScore ||
|
||||||
|
(right.qualityIndex ?? -1) - (left.qualityIndex ?? -1) ||
|
||||||
|
left.currentPrice - right.currentPrice ||
|
||||||
|
left.itemId.localeCompare(right.itemId)
|
||||||
|
)).slice(0, 8)
|
||||||
|
|
||||||
|
if (offers.length === 0) {
|
||||||
|
return emptyQuote({
|
||||||
|
status: 'NO_COMPARABLE_OFFER',
|
||||||
|
sourceImageUrl: input.sourceImageUrl,
|
||||||
|
searchedAt: input.searchedAt.toISOString(),
|
||||||
|
procurementRate: input.procurementRate,
|
||||||
|
returnedOfferCount: input.returnedOffers.length,
|
||||||
|
warnings: ['1688 未返回相似度足够且价格有效的同款,未覆盖结构拆解预估'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = offers.map((offer) => offer.currentPrice)
|
||||||
|
const marketPrice = percentile(prices, 0.5) ?? prices[0] ?? 0
|
||||||
|
const lowerMarketPrice = percentile(prices, 0.2) ?? marketPrice
|
||||||
|
const upperMarketPrice = percentile(prices, 0.8) ?? marketPrice
|
||||||
|
const procurementCost = roundMoney(marketPrice * input.procurementRate)
|
||||||
|
const lower = roundMoney(Math.min(lowerMarketPrice, marketPrice) * input.procurementRate)
|
||||||
|
const upper = roundMoney(Math.max(upperMarketPrice, marketPrice) * input.procurementRate)
|
||||||
|
const topScore = offers[0]?.similarityScore ?? input.minSimilarityScore
|
||||||
|
const confidence = Math.min(0.9, Number((
|
||||||
|
0.35 + topScore * 0.42 + Math.min(offers.length, 4) * 0.045
|
||||||
|
).toFixed(4)))
|
||||||
|
const warnings = [
|
||||||
|
`1688 实时同款价格中位数 ¥${roundMoney(marketPrice)},按采购系数 ${(input.procurementRate * 100).toFixed(0)}% 形成采购预估`,
|
||||||
|
offers.length < 3 ? '可比同款不足 3 条,区间已按有限样本保守处理' : '',
|
||||||
|
offers.some((offer) => (offer.quantityBegin ?? 1) > 1)
|
||||||
|
? '部分同款存在起批量,已按本次采购数量返回的实时价格估算,需下单前复核阶梯价' : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
return {
|
||||||
|
schemaVersion: 'bom-costing-1688-realtime/v1',
|
||||||
|
source: '1688_IMAGE_SEARCH',
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
sourceImageUrl: input.sourceImageUrl,
|
||||||
|
searchedAt: input.searchedAt.toISOString(),
|
||||||
|
procurementRate: input.procurementRate,
|
||||||
|
marketPrice: roundMoney(marketPrice),
|
||||||
|
procurementCost,
|
||||||
|
estimateRange: { min: lower, max: upper },
|
||||||
|
confidence,
|
||||||
|
comparableOfferCount: offers.length,
|
||||||
|
returnedOfferCount: input.returnedOffers.length,
|
||||||
|
warnings,
|
||||||
|
offers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BomCosting1688ImageSearchService {
|
||||||
|
constructor(private readonly gateway: BomCosting1688ImageSearchGateway = defaultGateway) {}
|
||||||
|
|
||||||
|
async findRealtimeQuote(input: {
|
||||||
|
imageUrl: string | null | undefined
|
||||||
|
}): Promise<BomCosting1688RealtimeMarketQuote> {
|
||||||
|
const config = this.gateway.getConfig()
|
||||||
|
const sourceImageUrl = input.imageUrl?.trim() || null
|
||||||
|
if (!config) {
|
||||||
|
return emptyQuote({
|
||||||
|
status: 'NOT_CONFIGURED',
|
||||||
|
sourceImageUrl,
|
||||||
|
procurementRate: DEFAULT_PROCUREMENT_RATE,
|
||||||
|
warnings: ['1688 实时同款服务尚未配置,当前继续使用结构拆解预估'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!sourceImageUrl) {
|
||||||
|
return emptyQuote({
|
||||||
|
status: 'IMAGE_UNAVAILABLE',
|
||||||
|
sourceImageUrl: null,
|
||||||
|
procurementRate: config.procurementRate,
|
||||||
|
warnings: ['该 SKU 没有可用于 1688 同款检索的主图,当前继续使用结构拆解预估'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageUrl = resolveBomCostingImageSearchSourceUrl(
|
||||||
|
sourceImageUrl,
|
||||||
|
this.gateway.createStoredObjectReadUrl,
|
||||||
|
)
|
||||||
|
const imageResponse = await this.gateway.fetch(imageUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' },
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'error',
|
||||||
|
signal: AbortSignal.timeout(config.timeoutMs),
|
||||||
|
})
|
||||||
|
if (!imageResponse.ok) throw new Error(`图片读取失败(${imageResponse.status})`)
|
||||||
|
const contentType = imageResponse.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
|
||||||
|
if (!contentType.startsWith('image/')) throw new Error('SKU 主图不是可检索图片')
|
||||||
|
const image = await readBoundedImage(imageResponse, config.maxImageBytes)
|
||||||
|
const body = JSON.stringify({
|
||||||
|
imgBase64: image.toString('base64'),
|
||||||
|
pageSize: config.pageSize,
|
||||||
|
purchaseAmount: config.purchaseAmount,
|
||||||
|
sortType: 'yx_desc',
|
||||||
|
scoreLevel: 'high',
|
||||||
|
tags: config.tags,
|
||||||
|
icTags: '',
|
||||||
|
})
|
||||||
|
const searchedAt = this.gateway.now()
|
||||||
|
const response = await this.gateway.fetch(config.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...buildAlibaba1688SignedHeaders({
|
||||||
|
method: 'POST',
|
||||||
|
uri: config.endpoint,
|
||||||
|
body,
|
||||||
|
ak: config.ak,
|
||||||
|
timestamp: String(Math.trunc(searchedAt.getTime() / 1000)),
|
||||||
|
nonce: this.gateway.randomBytes(4).toString('hex'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: AbortSignal.timeout(config.timeoutMs),
|
||||||
|
// The body is signed. Never let a client re-serialize it after MD5.
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
const rawBody = await response.text().catch(() => '')
|
||||||
|
let payload: unknown = null
|
||||||
|
try {
|
||||||
|
payload = rawBody ? JSON.parse(rawBody) : null
|
||||||
|
} catch {
|
||||||
|
payload = null
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(`1688 同款服务返回 ${response.status}`)
|
||||||
|
return buildBomCosting1688RealtimeMarketQuote({
|
||||||
|
sourceImageUrl,
|
||||||
|
searchedAt,
|
||||||
|
procurementRate: config.procurementRate,
|
||||||
|
minSimilarityScore: config.minSimilarityScore,
|
||||||
|
returnedOffers: readImageSearchResponse(payload),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// The provider response may contain offer data or diagnostics. Keep it
|
||||||
|
// out of ordinary application logs; the calling estimate degrades to a
|
||||||
|
// source-neutral no-reference result instead.
|
||||||
|
console.warn('[BOM costing] realtime procurement reference unavailable')
|
||||||
|
return emptyQuote({
|
||||||
|
status: 'FAILED',
|
||||||
|
sourceImageUrl,
|
||||||
|
searchedAt: this.gateway.now().toISOString(),
|
||||||
|
procurementRate: config.procurementRate,
|
||||||
|
warnings: ['1688 实时同款检索本次未返回可用价格,当前继续使用结构拆解预估'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bomCosting1688ImageSearchService = new BomCosting1688ImageSearchService()
|
||||||
@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
BomCostingAiCostEstimator,
|
BomCostingAiCostEstimator,
|
||||||
parseBomCostingAiEstimatePayload,
|
parseBomCostingAiEstimatePayload,
|
||||||
|
parseBomCostingAiWholeProductEstimatePayload,
|
||||||
type BomCostingAiEstimateInput,
|
type BomCostingAiEstimateInput,
|
||||||
} from './bom-costing-ai-cost-estimator'
|
} from './bom-costing-ai-cost-estimator'
|
||||||
|
|
||||||
@ -178,4 +179,79 @@ describe('BomCostingAiCostEstimator', () => {
|
|||||||
quotes: { SAME_MATERIAL: {}, MATERIAL_SUBSTITUTION: {} },
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'server-only'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type {
|
import type {
|
||||||
DynamicBomPriceQuote,
|
DynamicBomPriceQuote,
|
||||||
|
DynamicBomRealtimeMarketQuote,
|
||||||
DynamicBomScenarioKind,
|
DynamicBomScenarioKind,
|
||||||
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
|
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
|
||||||
import {
|
import {
|
||||||
@ -16,6 +17,8 @@ import {
|
|||||||
|
|
||||||
const AI_COST_ESTIMATE_RULE = 'multimodal-reference-cost-estimate/v1'
|
const AI_COST_ESTIMATE_RULE = 'multimodal-reference-cost-estimate/v1'
|
||||||
const AI_COST_PROMPT_VERSION = 'bom-costing-ai-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 {
|
export interface BomCostingAiEstimateRequestLine {
|
||||||
requestId: string
|
requestId: string
|
||||||
@ -83,6 +86,27 @@ export interface BomCostingAiEstimateInput {
|
|||||||
requests: BomCostingAiEstimateRequestLine[]
|
requests: BomCostingAiEstimateRequestLine[]
|
||||||
knownLines: BomCostingAiKnownLine[]
|
knownLines: BomCostingAiKnownLine[]
|
||||||
companyReferences: BomCostingAiCompanyReference[]
|
companyReferences: BomCostingAiCompanyReference[]
|
||||||
|
realtimeMarketReference?: DynamicBomRealtimeMarketQuote | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomCostingAiWholeProductEstimateInput {
|
||||||
|
product: BomCostingAiEstimateInput['product'] & {
|
||||||
|
attributes: unknown
|
||||||
|
}
|
||||||
|
knownLines: BomCostingAiKnownLine[]
|
||||||
|
companyReferences: BomCostingAiCompanyReference[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomCostingAiWholeProductEstimateResult {
|
||||||
|
totalCost: number | null
|
||||||
|
estimateRange: { min: number; max: number } | null
|
||||||
|
confidence: number | null
|
||||||
|
reason: string | null
|
||||||
|
referenceRowIds: string[]
|
||||||
|
warning: string | null
|
||||||
|
callId: string | null
|
||||||
|
model: string | null
|
||||||
|
sourceAudit: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BomCostingAiEstimateResult {
|
export interface BomCostingAiEstimateResult {
|
||||||
@ -120,6 +144,15 @@ const aiEstimateLineSchema = z.object({
|
|||||||
referenceRowIds: z.array(z.string().trim().min(1).max(200)).max(30).default([]),
|
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 {
|
export interface ParsedBomCostingAiEstimate {
|
||||||
requestId: string
|
requestId: string
|
||||||
estimatedSubtotal: number
|
estimatedSubtotal: number
|
||||||
@ -165,6 +198,27 @@ export function parseBomCostingAiEstimatePayload(
|
|||||||
return estimates
|
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: {
|
function emptyResult(input: {
|
||||||
requestedCount: number
|
requestedCount: number
|
||||||
warning?: string | null
|
warning?: string | null
|
||||||
@ -198,6 +252,7 @@ function buildSystemPrompt() {
|
|||||||
return [
|
return [
|
||||||
'你是家具制造成本预估助手。任务是补齐缺少直接公司价格的 BOM 明细,不是生成正式采购报价。',
|
'你是家具制造成本预估助手。任务是补齐缺少直接公司价格的 BOM 明细,不是生成正式采购报价。',
|
||||||
'公司历史 BOM、公司主数据和已核算明细都是参考证据,不是缺一不可的硬门槛。',
|
'公司历史 BOM、公司主数据和已核算明细都是参考证据,不是缺一不可的硬门槛。',
|
||||||
|
'若提供了 1688 实时同款采购预估,它是整件 SKU 的当前市场锚点:优先用于让缺价明细的总和保持合理,但不得把整件价格伪装成某一块板材或五金的精确单价。',
|
||||||
'当没有完全相同的公司记录时,结合部件结构、材质、尺寸、数量、同类公司记录和常见家具制造成本关系,给出合理的工厂成本区间。',
|
'当没有完全相同的公司记录时,结合部件结构、材质、尺寸、数量、同类公司记录和常见家具制造成本关系,给出合理的工厂成本区间。',
|
||||||
'不得因为缺少完全相同的价格来源而拒绝预估,也不得返回 0、null 或把竞品售价倒推成成本。',
|
'不得因为缺少完全相同的价格来源而拒绝预估,也不得返回 0、null 或把竞品售价倒推成成本。',
|
||||||
'优先使用 companyReferences;证据较弱时降低 confidence、扩大区间并在 reason 中说明。',
|
'优先使用 companyReferences;证据较弱时降低 confidence、扩大区间并在 reason 中说明。',
|
||||||
@ -208,6 +263,37 @@ function buildSystemPrompt() {
|
|||||||
].join('\n')
|
].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 {
|
export class BomCostingAiCostEstimator {
|
||||||
constructor(private readonly gateway: BomCostingAiEstimatorGateway = defaultGateway) {}
|
constructor(private readonly gateway: BomCostingAiEstimatorGateway = defaultGateway) {}
|
||||||
|
|
||||||
@ -245,10 +331,20 @@ export class BomCostingAiCostEstimator {
|
|||||||
})),
|
})),
|
||||||
knownCostLines: input.knownLines.slice(0, 80),
|
knownCostLines: input.knownLines.slice(0, 80),
|
||||||
companyReferences: referenceRows,
|
companyReferences: referenceRows,
|
||||||
|
realtimeMarketReference: input.realtimeMarketReference?.status === 'AVAILABLE'
|
||||||
|
? {
|
||||||
|
procurementCost: input.realtimeMarketReference.procurementCost,
|
||||||
|
estimateRange: input.realtimeMarketReference.estimateRange,
|
||||||
|
confidence: input.realtimeMarketReference.confidence,
|
||||||
|
comparableOfferCount: input.realtimeMarketReference.comparableOfferCount,
|
||||||
|
procurementRate: input.realtimeMarketReference.procurementRate,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
constraints: {
|
constraints: {
|
||||||
estimatesAreReferenceOnly: true,
|
estimatesAreReferenceOnly: true,
|
||||||
officialPriceRequired: false,
|
officialPriceRequired: false,
|
||||||
competitorSalePriceProvided: false,
|
competitorSalePriceProvided: false,
|
||||||
|
realtimeMarketReferenceProvided: input.realtimeMarketReference?.status === 'AVAILABLE',
|
||||||
preserveUncertainty: true,
|
preserveUncertainty: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -343,6 +439,114 @@ export class BomCostingAiCostEstimator {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async estimateWholeProductCost(
|
||||||
|
input: BomCostingAiWholeProductEstimateInput,
|
||||||
|
): Promise<BomCostingAiWholeProductEstimateResult> {
|
||||||
|
const hasUsableProductContext = Boolean(
|
||||||
|
input.product.name.trim() ||
|
||||||
|
input.product.structureGroupKey.trim() ||
|
||||||
|
input.product.costFamilyKey.trim() ||
|
||||||
|
input.product.lengthMm ||
|
||||||
|
input.product.widthMm ||
|
||||||
|
input.product.heightMm ||
|
||||||
|
input.knownLines.length > 0,
|
||||||
|
)
|
||||||
|
if (!hasUsableProductContext) {
|
||||||
|
return emptyWholeProductResult({
|
||||||
|
warning: '产品资料不足,暂无法形成整件预估',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: BomCostingMultimodalProviderConfig | null
|
||||||
|
try {
|
||||||
|
config = await this.gateway.getConfig()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[BOM costing] whole-product AI estimator config unavailable', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
return emptyWholeProductResult({
|
||||||
|
warning: '多模态整件预估服务暂时不可用,可稍后重新核算',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!config) {
|
||||||
|
return emptyWholeProductResult({
|
||||||
|
warning: '尚未配置多模态整件预估服务,暂无法形成整件预估',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceRows = input.companyReferences.slice(0, 80)
|
||||||
|
const userPayload = {
|
||||||
|
schemaVersion: AI_WHOLE_PRODUCT_PROMPT_VERSION,
|
||||||
|
currency: 'CNY',
|
||||||
|
product: {
|
||||||
|
...input.product,
|
||||||
|
attributes: compactEvidence(input.product.attributes),
|
||||||
|
},
|
||||||
|
knownCostLines: input.knownLines.slice(0, 80),
|
||||||
|
companyReferences: referenceRows,
|
||||||
|
constraints: {
|
||||||
|
estimatesAreReferenceOnly: true,
|
||||||
|
officialPriceRequired: false,
|
||||||
|
preserveUncertainty: true,
|
||||||
|
mustAttemptEstimateWhenProductContextExists: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const call = await this.gateway.call({
|
||||||
|
config,
|
||||||
|
operation: 'SYNTHESIS',
|
||||||
|
systemPrompt: buildWholeProductSystemPrompt(),
|
||||||
|
userContent: JSON.stringify(userPayload),
|
||||||
|
maxTokens: readBomCostingBoundedInteger(
|
||||||
|
process.env.BOM_COSTING_AI_WHOLE_PRODUCT_MAX_TOKENS,
|
||||||
|
1_500,
|
||||||
|
500,
|
||||||
|
4_000,
|
||||||
|
),
|
||||||
|
timeoutMs: readBomCostingBoundedInteger(
|
||||||
|
process.env.BOM_COSTING_AI_WHOLE_PRODUCT_TIMEOUT_MS,
|
||||||
|
60_000,
|
||||||
|
10_000,
|
||||||
|
120_000,
|
||||||
|
),
|
||||||
|
retrySynthesisTimeout: false,
|
||||||
|
})
|
||||||
|
const estimate = parseBomCostingAiWholeProductEstimatePayload(
|
||||||
|
this.gateway.parse(call.content),
|
||||||
|
new Set(referenceRows.map((row) => row.sourceRowId)),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...estimate,
|
||||||
|
warning: null,
|
||||||
|
callId: call.id,
|
||||||
|
model: call.model,
|
||||||
|
sourceAudit: {
|
||||||
|
promptVersion: AI_WHOLE_PRODUCT_PROMPT_VERSION,
|
||||||
|
calculationRule: AI_WHOLE_PRODUCT_ESTIMATE_RULE,
|
||||||
|
model: call.model,
|
||||||
|
callId: call.id,
|
||||||
|
reason: estimate.reason,
|
||||||
|
durationMs: call.durationMs,
|
||||||
|
promptTokens: call.promptTokens,
|
||||||
|
completionTokens: call.completionTokens,
|
||||||
|
companyReferenceCount: referenceRows.length,
|
||||||
|
knownCostLineCount: input.knownLines.length,
|
||||||
|
referenceRowIds: estimate.referenceRowIds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[BOM costing] whole-product AI estimate failed without blocking scenario persistence', {
|
||||||
|
model: config.model,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
return emptyWholeProductResult({
|
||||||
|
model: config.model,
|
||||||
|
warning: '多模态整件预估本次未返回可用区间,可稍后重新核算',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bomCostingAiCostEstimator = new BomCostingAiCostEstimator()
|
export const bomCostingAiCostEstimator = new BomCostingAiCostEstimator()
|
||||||
|
|||||||
@ -1254,6 +1254,96 @@ describe('BomCostingProcessingService', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('平台 SKU 规格差异与相同结构拆解冲突时仍进入整件预估阶段', async () => {
|
||||||
|
const variantSnapshot: MarketDetailSnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
skus: [
|
||||||
|
{
|
||||||
|
...snapshot.skus[0]!,
|
||||||
|
externalSkuId: 'sku-walnut-80',
|
||||||
|
name: '胡桃木 80cm 规格',
|
||||||
|
size: '80cm',
|
||||||
|
attributes: { 材质: '胡桃木', 尺寸: '80cm' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...snapshot.skus[0]!,
|
||||||
|
externalSkuId: 'sku-oak-100',
|
||||||
|
name: '橡木 100cm 规格',
|
||||||
|
size: '100cm',
|
||||||
|
attributes: { 材质: '橡木', 尺寸: '100cm' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skuTotalRows: 2,
|
||||||
|
skuReturnedRows: 2,
|
||||||
|
skuTruncated: false,
|
||||||
|
}
|
||||||
|
mockBuildStoredSnapshot.mockResolvedValue(variantSnapshot)
|
||||||
|
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
|
||||||
|
.mockResolvedValue(factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY) as never)
|
||||||
|
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
|
||||||
|
.mockResolvedValue(factAnalysisRecord(BOM_COSTING_FACT_ANALYSIS_STATUS.READY) as never)
|
||||||
|
mockRunAnalysis.mockResolvedValue({
|
||||||
|
result: {
|
||||||
|
...analysisResult,
|
||||||
|
skus: variantSnapshot.skus.map((sku) => ({
|
||||||
|
...analysisResult.skus[0]!,
|
||||||
|
externalSkuId: sku.externalSkuId,
|
||||||
|
name: sku.name,
|
||||||
|
attributes: sku.attributes,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
trace: {
|
||||||
|
model: 'grok-4.5',
|
||||||
|
callId: 'call-structure-review',
|
||||||
|
imageCount: 2,
|
||||||
|
inputSkuCount: 2,
|
||||||
|
durationMs: 12_000,
|
||||||
|
promptTokens: 50,
|
||||||
|
completionTokens: 80,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
jest.spyOn(bomCostingScenarioService, 'calculateSameMaterialForCase')
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
totalSkuCount: 2,
|
||||||
|
succeededSkuCount: 2,
|
||||||
|
costedSkuCount: 2,
|
||||||
|
pendingSkuCount: 0,
|
||||||
|
failedSkuCount: 0,
|
||||||
|
succeededSkuIds: ['sku-db-walnut-80', 'sku-db-oak-100'],
|
||||||
|
costedSkuIds: ['sku-db-walnut-80', 'sku-db-oak-100'],
|
||||||
|
pendingSkuIds: [],
|
||||||
|
failures: [],
|
||||||
|
reviewSkuCount: 0,
|
||||||
|
reviewSkuIds: [],
|
||||||
|
consistencyWarnings: [],
|
||||||
|
})
|
||||||
|
const { BomCostingProcessingService } = await import(
|
||||||
|
'./bom-costing-processing.service'
|
||||||
|
)
|
||||||
|
|
||||||
|
await new BomCostingProcessingService().processCase('case-1')
|
||||||
|
|
||||||
|
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
|
||||||
|
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
skus: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ externalSkuId: 'sku-walnut-80' }),
|
||||||
|
expect.objectContaining({ externalSkuId: 'sku-oak-100' }),
|
||||||
|
]),
|
||||||
|
eventPayload: expect.objectContaining({ qualityWarningCount: 1 }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(bomCostingScenarioService.calculateSameMaterialForCase)
|
||||||
|
.toHaveBeenCalledWith('case-1')
|
||||||
|
expect(bomCostingRepository.finalizeCaseDynamicCosting).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
caseId: 'case-1',
|
||||||
|
message: expect.stringContaining('整件预估已保留'),
|
||||||
|
payload: expect.objectContaining({ qualityWarningCount: 1 }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('紧凑成本阶段仅有个别 SKU 待补成本时仍完成整单并保留可继续核算状态', async () => {
|
it('紧凑成本阶段仅有个别 SKU 待补成本时仍完成整单并保留可继续核算状态', async () => {
|
||||||
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
|
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
|
||||||
.mockResolvedValue(factAnalysisRecord(
|
.mockResolvedValue(factAnalysisRecord(
|
||||||
|
|||||||
@ -284,6 +284,7 @@ async function completeDynamicCostingPhase(input: {
|
|||||||
caseId: string
|
caseId: string
|
||||||
analysisRunId: string
|
analysisRunId: string
|
||||||
skus: Array<{ externalSkuId: string }>
|
skus: Array<{ externalSkuId: string }>
|
||||||
|
qualityWarningCount?: number
|
||||||
}) {
|
}) {
|
||||||
let ownershipLost = false
|
let ownershipLost = false
|
||||||
let heartbeatInFlight: Promise<void> | null = null
|
let heartbeatInFlight: Promise<void> | null = null
|
||||||
@ -319,13 +320,15 @@ async function completeDynamicCostingPhase(input: {
|
|||||||
}, DYNAMIC_COST_HEARTBEAT_INTERVAL_MS)
|
}, DYNAMIC_COST_HEARTBEAT_INTERVAL_MS)
|
||||||
|
|
||||||
const skuCount = input.skus.length
|
const skuCount = input.skus.length
|
||||||
let dynamicCosting: Awaited<ReturnType<
|
type DynamicCostingCompletion = Awaited<ReturnType<
|
||||||
typeof bomCostingScenarioService.calculateSameMaterialForCase
|
typeof bomCostingScenarioService.calculateSameMaterialForCase
|
||||||
>>
|
>> & { qualityWarningCount: number }
|
||||||
|
let dynamicCosting: DynamicCostingCompletion
|
||||||
try {
|
try {
|
||||||
dynamicCosting = await bomCostingScenarioService.calculateSameMaterialForCase(
|
dynamicCosting = {
|
||||||
input.caseId,
|
...await bomCostingScenarioService.calculateSameMaterialForCase(input.caseId),
|
||||||
)
|
qualityWarningCount: input.qualityWarningCount ?? 0,
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = readErrorMessage(error)
|
const message = readErrorMessage(error)
|
||||||
dynamicCosting = {
|
dynamicCosting = {
|
||||||
@ -345,6 +348,7 @@ async function completeDynamicCostingPhase(input: {
|
|||||||
reviewSkuCount: 0,
|
reviewSkuCount: 0,
|
||||||
reviewSkuIds: [],
|
reviewSkuIds: [],
|
||||||
consistencyWarnings: [],
|
consistencyWarnings: [],
|
||||||
|
qualityWarningCount: input.qualityWarningCount ?? 0,
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(heartbeatTimer)
|
clearInterval(heartbeatTimer)
|
||||||
@ -362,13 +366,16 @@ async function completeDynamicCostingPhase(input: {
|
|||||||
? `所有 SKU 的成本生成均失败:${firstFailure}`
|
? `所有 SKU 的成本生成均失败:${firstFailure}`
|
||||||
: '所有 SKU 的成本生成均失败,请按现有事实重新核算')
|
: '所有 SKU 的成本生成均失败,请按现有事实重新核算')
|
||||||
}
|
}
|
||||||
const finalMessage = dynamicCosting.failedSkuCount > 0
|
const finalMessageBase = dynamicCosting.failedSkuCount > 0
|
||||||
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.failedSkuCount} 个成本生成失败,可按现有事实重新核算`
|
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.failedSkuCount} 个成本生成失败,可按现有事实重新核算`
|
||||||
: dynamicCosting.pendingSkuCount > 0
|
: dynamicCosting.pendingSkuCount > 0
|
||||||
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.pendingSkuCount} 个仍缺公司价格资料`
|
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.pendingSkuCount} 个仍缺公司价格资料`
|
||||||
: dynamicCosting.reviewSkuCount > 0
|
: dynamicCosting.reviewSkuCount > 0
|
||||||
? `成本预估已生成,共 ${dynamicCosting.totalSkuCount} 个 SKU;${dynamicCosting.reviewSkuCount} 个尺寸与成本关系待复核`
|
? `成本预估已生成,共 ${dynamicCosting.totalSkuCount} 个 SKU;${dynamicCosting.reviewSkuCount} 个尺寸与成本关系待复核`
|
||||||
: `核算完成,共 ${dynamicCosting.totalSkuCount} 个 SKU,已生成同材质成本预估`
|
: `核算完成,共 ${dynamicCosting.totalSkuCount} 个 SKU,已生成同材质成本预估`
|
||||||
|
const finalMessage = dynamicCosting.qualityWarningCount > 0
|
||||||
|
? `${finalMessageBase};结构拆解差异已标记待复核,整件预估已保留`
|
||||||
|
: finalMessageBase
|
||||||
await bomCostingRepository.finalizeCaseDynamicCosting({
|
await bomCostingRepository.finalizeCaseDynamicCosting({
|
||||||
caseId: input.caseId,
|
caseId: input.caseId,
|
||||||
analysisRunId: input.analysisRunId,
|
analysisRunId: input.analysisRunId,
|
||||||
@ -1594,16 +1601,20 @@ export class BomCostingProcessingService {
|
|||||||
[BomCostingLineCategory.LABOR]: [],
|
[BomCostingLineCategory.LABOR]: [],
|
||||||
}
|
}
|
||||||
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
|
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
|
||||||
if (canGenerateCost) {
|
const qualityWarnings = canGenerateCost
|
||||||
assertBomCostingResultQuality({
|
? assertBomCostingResultQuality({
|
||||||
analysis: persistedResult,
|
analysis: persistedResult,
|
||||||
sourceSnapshot: marketSnapshot,
|
sourceSnapshot: marketSnapshot,
|
||||||
preparedSkus,
|
preparedSkus,
|
||||||
allowIncompleteCosting,
|
allowIncompleteCosting,
|
||||||
allowUnverifiedSkuStructures: isFactBasedStructure,
|
allowUnverifiedSkuStructures: isFactBasedStructure,
|
||||||
})
|
})
|
||||||
|
: []
|
||||||
|
const stats = {
|
||||||
|
...buildResultStats(preparedSkus),
|
||||||
|
qualityWarningCount: qualityWarnings.length,
|
||||||
|
qualityWarnings,
|
||||||
}
|
}
|
||||||
const stats = buildResultStats(preparedSkus)
|
|
||||||
const completedFailedFactImages = buildFailedFactImagePayload(
|
const completedFailedFactImages = buildFailedFactImagePayload(
|
||||||
completedFactSnapshot,
|
completedFactSnapshot,
|
||||||
analysisPlanByIdentity,
|
analysisPlanByIdentity,
|
||||||
@ -1695,6 +1706,7 @@ export class BomCostingProcessingService {
|
|||||||
caseId,
|
caseId,
|
||||||
analysisRunId,
|
analysisRunId,
|
||||||
skus: preparedSkus,
|
skus: preparedSkus,
|
||||||
|
qualityWarningCount: qualityWarnings.length,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BomCostingTaskOwnershipLostError) {
|
if (error instanceof BomCostingTaskOwnershipLostError) {
|
||||||
|
|||||||
@ -194,7 +194,7 @@ function buildV3Analysis(skuIds: string[]): CompetitorAnalysisResultV3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('BOM 核算结果质量保护', () => {
|
describe('BOM 核算结果质量保护', () => {
|
||||||
it('来源 SKU 的尺寸、材质或是否含镜不同却生成相同 BOM 时拒绝保存', () => {
|
it('来源 SKU 的尺寸、材质或是否含镜不同却生成相同 BOM 时保留待复核并继续预估', () => {
|
||||||
const sourceSnapshot = buildSourceSnapshot([
|
const sourceSnapshot = buildSourceSnapshot([
|
||||||
{
|
{
|
||||||
externalSkuId: 'solid-64-no-mirror',
|
externalSkuId: 'solid-64-no-mirror',
|
||||||
@ -219,11 +219,15 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
])
|
])
|
||||||
const analysis = buildAnalysis(sourceSnapshot.skus)
|
const analysis = buildAnalysis(sourceSnapshot.skus)
|
||||||
|
|
||||||
expect(() => assertBomCostingResultQuality({
|
const warnings = assertBomCostingResultQuality({
|
||||||
analysis,
|
analysis,
|
||||||
sourceSnapshot,
|
sourceSnapshot,
|
||||||
preparedSkus: sourceSnapshot.skus.map((sku) => buildPreparedSku(sku.externalSkuId)),
|
preparedSkus: sourceSnapshot.skus.map((sku) => buildPreparedSku(sku.externalSkuId)),
|
||||||
})).toThrow('SKU 结构可信度校验未通过')
|
})
|
||||||
|
|
||||||
|
expect(warnings).toEqual([
|
||||||
|
expect.stringContaining('已标记待复核;整件预估继续生成'),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('仅颜色不同的 SKU 可以复用相同 BOM', () => {
|
it('仅颜色不同的 SKU 可以复用相同 BOM', () => {
|
||||||
@ -303,11 +307,11 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
})).not.toThrow()
|
})).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('v2 蓝图要求每个兄弟 SKU 都保留必要部件', () => {
|
it('v2 蓝图缺少必要部件时标记待复核,不阻断整件预估', () => {
|
||||||
const analysis = buildV2Analysis(['sku-small', 'sku-large'])
|
const analysis = buildV2Analysis(['sku-small', 'sku-large'])
|
||||||
analysis.skus[1]!.bomLines = []
|
analysis.skus[1]!.bomLines = []
|
||||||
|
|
||||||
expect(() => assertBomCostingResultQuality({
|
const warnings = assertBomCostingResultQuality({
|
||||||
analysis,
|
analysis,
|
||||||
sourceSnapshot: null,
|
sourceSnapshot: null,
|
||||||
preparedSkus: [
|
preparedSkus: [
|
||||||
@ -315,7 +319,11 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
buildPreparedSku('sku-large', null),
|
buildPreparedSku('sku-large', null),
|
||||||
],
|
],
|
||||||
allowIncompleteCosting: true,
|
allowIncompleteCosting: true,
|
||||||
})).toThrow('sku-large缺少board-top')
|
})
|
||||||
|
|
||||||
|
expect(warnings).toEqual([
|
||||||
|
expect.stringContaining('sku-large缺少board-top'),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('v2 同结构组尺寸变大但板材面积异常时只标记当前 SKU 待复核', () => {
|
it('v2 同结构组尺寸变大但板材面积异常时只标记当前 SKU 待复核', () => {
|
||||||
@ -343,11 +351,11 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
expect(analysis.skus[1]?.bomLines[0]?.confidence).toBeLessThanOrEqual(0.45)
|
expect(analysis.skus[1]?.bomLines[0]?.confidence).toBeLessThanOrEqual(0.45)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('v3 每个 SKU 的结构组和成本族必须反向属于蓝图', () => {
|
it('v3 SKU 的结构组或成本族引用异常时保留待复核,不阻断整件预估', () => {
|
||||||
const analysis = buildV3Analysis(['sku-small', 'sku-large'])
|
const analysis = buildV3Analysis(['sku-small', 'sku-large'])
|
||||||
analysis.skus[1]!.costFamilyKey = 'wardrobe:cost:不存在'
|
analysis.skus[1]!.costFamilyKey = 'wardrobe:cost:不存在'
|
||||||
|
|
||||||
expect(() => assertBomCostingResultQuality({
|
const warnings = assertBomCostingResultQuality({
|
||||||
analysis,
|
analysis,
|
||||||
sourceSnapshot: null,
|
sourceSnapshot: null,
|
||||||
preparedSkus: [
|
preparedSkus: [
|
||||||
@ -355,7 +363,11 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
buildPreparedSku('sku-large', null),
|
buildPreparedSku('sku-large', null),
|
||||||
],
|
],
|
||||||
allowIncompleteCosting: true,
|
allowIncompleteCosting: true,
|
||||||
})).toThrow('sku-large的成本族引用无效')
|
})
|
||||||
|
|
||||||
|
expect(warnings).toEqual([
|
||||||
|
expect.stringContaining('sku-large的成本族引用无效'),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('能识别米制尺寸和衣柜结构款式差异,避免相同 BOM 混组', () => {
|
it('能识别米制尺寸和衣柜结构款式差异,避免相同 BOM 混组', () => {
|
||||||
@ -382,13 +394,17 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(() => assertBomCostingResultQuality({
|
const warnings = assertBomCostingResultQuality({
|
||||||
analysis: buildAnalysis(sourceSnapshot.skus),
|
analysis: buildAnalysis(sourceSnapshot.skus),
|
||||||
sourceSnapshot,
|
sourceSnapshot,
|
||||||
preparedSkus: sourceSnapshot.skus.map((sku) => (
|
preparedSkus: sourceSnapshot.skus.map((sku) => (
|
||||||
buildPreparedSku(sku.externalSkuId)
|
buildPreparedSku(sku.externalSkuId)
|
||||||
)),
|
)),
|
||||||
})).toThrow('SKU 结构可信度校验未通过')
|
})
|
||||||
|
|
||||||
|
expect(warnings).toEqual([
|
||||||
|
expect.stringContaining('已标记待复核;整件预估继续生成'),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('同成本族板材面积增加超过5%却无依据降本超过10%时标记待复核而不拒绝整批', () => {
|
it('同成本族板材面积增加超过5%却无依据降本超过10%时标记待复核而不拒绝整批', () => {
|
||||||
@ -494,7 +510,7 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
expect(boardOnly.attributes).not.toHaveProperty('costingConsistencyStatus')
|
expect(boardOnly.attributes).not.toHaveProperty('costingConsistencyStatus')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('显式无抽屉结构出现蓝图未启用的抽屉行时拒绝保存', () => {
|
it('显式无抽屉结构出现未启用的抽屉行时标记待复核,不阻断整件预估', () => {
|
||||||
const analysis = buildV3Analysis(['sku-no-drawer'])
|
const analysis = buildV3Analysis(['sku-no-drawer'])
|
||||||
const group = analysis.structureBlueprint.structureGroups[0]!
|
const group = analysis.structureBlueprint.structureGroups[0]!
|
||||||
group.structureGroupKey = 'wardrobe:structure:door-4.hanging.no-drawer'
|
group.structureGroupKey = 'wardrobe:structure:door-4.hanging.no-drawer'
|
||||||
@ -510,11 +526,13 @@ describe('BOM 核算结果质量保护', () => {
|
|||||||
confidence: 0.8,
|
confidence: 0.8,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(() => assertBomCostingResultQuality({
|
const warnings = assertBomCostingResultQuality({
|
||||||
analysis,
|
analysis,
|
||||||
sourceSnapshot: null,
|
sourceSnapshot: null,
|
||||||
preparedSkus: [buildPreparedSku('sku-no-drawer', null)],
|
preparedSkus: [buildPreparedSku('sku-no-drawer', null)],
|
||||||
allowIncompleteCosting: true,
|
allowIncompleteCosting: true,
|
||||||
})).toThrow(/蓝图未启用的drawer-box|互斥结构禁止的drawer-box/)
|
})
|
||||||
|
|
||||||
|
expect(warnings.join(';')).toMatch(/蓝图未启用的drawer-box|互斥结构禁止的drawer-box/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -179,7 +179,7 @@ function buildBomFingerprint(sku: CompetitorSkuV1) {
|
|||||||
.join('\u001f')
|
.join('\u001f')
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertDistinctSourceSkuStructures(
|
function collectDistinctSourceSkuStructureWarnings(
|
||||||
analysis: CompetitorAnalysisResult,
|
analysis: CompetitorAnalysisResult,
|
||||||
source: MarketDetailSnapshot,
|
source: MarketDetailSnapshot,
|
||||||
) {
|
) {
|
||||||
@ -220,14 +220,18 @@ function assertDistinctSourceSkuStructures(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (violations.length === 0) return
|
if (violations.length === 0) return []
|
||||||
const examples = violations
|
const examples = violations
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((item) => `${item.left} 与 ${item.right}(${item.differences.join('、')})`)
|
.map((item) => `${item.left} 与 ${item.right}(${item.differences.join('、')})`)
|
||||||
.join(';')
|
.join(';')
|
||||||
throw new Error(
|
// Source-side specification differences tell us that this BOM needs review;
|
||||||
`SKU 结构可信度校验未通过:${violations.length} 组平台规格存在结构差异,却生成了相同 BOM(${examples})。请重新核算或补充复核图片`,
|
// they do not mean the product, its images, or the available cost knowledge
|
||||||
)
|
// disappeared. Keep the signal for audit, but let the whole-product estimate
|
||||||
|
// continue instead of discarding every SKU in the run.
|
||||||
|
return [
|
||||||
|
`检测到 ${violations.length} 组 SKU 规格差异与当前结构拆解不完全一致,已标记待复核;整件预估继续生成(${examples})`,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertPreparedCostRange(skus: BomCostingPreparedSku[]) {
|
function assertPreparedCostRange(skus: BomCostingPreparedSku[]) {
|
||||||
@ -450,12 +454,12 @@ function horizontalSizeMm(sku: CompetitorSkuV1) {
|
|||||||
return values.length > 0 ? Math.max(...values) : null
|
return values.length > 0 ? Math.max(...values) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult) {
|
function collectStructureBlueprintWarnings(analysis: CompetitorAnalysisResult) {
|
||||||
if (analysis.schemaVersion === 'competitor-analysis/v1') return
|
if (analysis.schemaVersion === 'competitor-analysis/v1') return []
|
||||||
if (
|
if (
|
||||||
analysis.schemaVersion === 'competitor-analysis/v3' &&
|
analysis.schemaVersion === 'competitor-analysis/v3' &&
|
||||||
analysis.structureBlueprint.supportStatus !== 'SUPPORTED'
|
analysis.structureBlueprint.supportStatus !== 'SUPPORTED'
|
||||||
) return
|
) return []
|
||||||
const analyzedById = new Map(
|
const analyzedById = new Map(
|
||||||
analysis.skus.map((sku) => [sku.externalSkuId, sku] as const),
|
analysis.skus.map((sku) => [sku.externalSkuId, sku] as const),
|
||||||
)
|
)
|
||||||
@ -466,7 +470,7 @@ function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult)
|
|||||||
blueprintSkuIds.length !== analysis.skus.length ||
|
blueprintSkuIds.length !== analysis.skus.length ||
|
||||||
analysis.skus.some((sku) => !blueprintSkuIds.includes(sku.externalSkuId))
|
analysis.skus.some((sku) => !blueprintSkuIds.includes(sku.externalSkuId))
|
||||||
) {
|
) {
|
||||||
throw new Error('产品结构蓝图与真实 SKU 数量不一致,系统未接受该结果')
|
return ['产品结构蓝图与真实 SKU 的对应关系不完整,已保留可识别 SKU 的整件预估并标记待复核']
|
||||||
}
|
}
|
||||||
|
|
||||||
const violations: string[] = []
|
const violations: string[] = []
|
||||||
@ -574,11 +578,9 @@ function assertStructureBlueprintConsistency(analysis: CompetitorAnalysisResult)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (violations.length > 0) {
|
return violations.length > 0
|
||||||
throw new Error(
|
? [`结构拆解存在 ${violations.length} 项待复核:${violations.slice(0, 5).join(';')}`]
|
||||||
`结构蓝图一致性校验未通过:${violations.slice(0, 5).join(';')}`,
|
: []
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertBomCostingResultQuality(input: {
|
export function assertBomCostingResultQuality(input: {
|
||||||
@ -600,7 +602,7 @@ export function assertBomCostingResultQuality(input: {
|
|||||||
*/
|
*/
|
||||||
allowUnverifiedSkuStructures?: boolean
|
allowUnverifiedSkuStructures?: boolean
|
||||||
}) {
|
}) {
|
||||||
assertStructureBlueprintConsistency(input.analysis)
|
const warnings = collectStructureBlueprintWarnings(input.analysis)
|
||||||
if (!input.allowIncompleteCosting) {
|
if (!input.allowIncompleteCosting) {
|
||||||
assertPreparedCostRange(input.preparedSkus)
|
assertPreparedCostRange(input.preparedSkus)
|
||||||
}
|
}
|
||||||
@ -608,6 +610,10 @@ export function assertBomCostingResultQuality(input: {
|
|||||||
const allowUnverifiedSkuStructures =
|
const allowUnverifiedSkuStructures =
|
||||||
input.allowUnverifiedSkuStructures ?? input.allowIncompleteCosting ?? false
|
input.allowUnverifiedSkuStructures ?? input.allowIncompleteCosting ?? false
|
||||||
if (input.sourceSnapshot && !allowUnverifiedSkuStructures) {
|
if (input.sourceSnapshot && !allowUnverifiedSkuStructures) {
|
||||||
assertDistinctSourceSkuStructures(input.analysis, input.sourceSnapshot)
|
warnings.push(...collectDistinctSourceSkuStructureWarnings(
|
||||||
|
input.analysis,
|
||||||
|
input.sourceSnapshot,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
return Array.from(new Set(warnings))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
buildDynamicCostBreakdown,
|
buildDynamicCostBreakdown,
|
||||||
} from './bom-costing-scenario.service'
|
} from './bom-costing-scenario.service'
|
||||||
import { bomCostingAiCostEstimator } from './bom-costing-ai-cost-estimator'
|
import { bomCostingAiCostEstimator } from './bom-costing-ai-cost-estimator'
|
||||||
|
import { bomCosting1688ImageSearchService } from './bom-costing-1688-image-search'
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
@ -158,6 +159,22 @@ function mockBase(input: {
|
|||||||
|
|
||||||
describe('BomCostingScenarioService', () => {
|
describe('BomCostingScenarioService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.spyOn(bomCosting1688ImageSearchService, 'findRealtimeQuote').mockResolvedValue({
|
||||||
|
schemaVersion: 'bom-costing-1688-realtime/v1',
|
||||||
|
source: '1688_IMAGE_SEARCH',
|
||||||
|
status: 'NOT_CONFIGURED',
|
||||||
|
sourceImageUrl: null,
|
||||||
|
searchedAt: null,
|
||||||
|
procurementRate: 0.9,
|
||||||
|
marketPrice: null,
|
||||||
|
procurementCost: null,
|
||||||
|
estimateRange: null,
|
||||||
|
confidence: null,
|
||||||
|
comparableOfferCount: 0,
|
||||||
|
returnedOfferCount: 0,
|
||||||
|
warnings: [],
|
||||||
|
offers: [],
|
||||||
|
})
|
||||||
jest.spyOn(bomCostingAiCostEstimator, 'estimateMissingCosts').mockResolvedValue({
|
jest.spyOn(bomCostingAiCostEstimator, 'estimateMissingCosts').mockResolvedValue({
|
||||||
quotes: { SAME_MATERIAL: {}, MATERIAL_SUBSTITUTION: {} },
|
quotes: { SAME_MATERIAL: {}, MATERIAL_SUBSTITUTION: {} },
|
||||||
requestedCount: 0,
|
requestedCount: 0,
|
||||||
@ -166,6 +183,17 @@ describe('BomCostingScenarioService', () => {
|
|||||||
callId: null,
|
callId: null,
|
||||||
model: 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')
|
jest.spyOn(bomCostingScenarioRepository, 'invalidateCurrentScenarios')
|
||||||
.mockResolvedValue({ count: 0 })
|
.mockResolvedValue({ count: 0 })
|
||||||
jest.spyOn(bomCostingScenarioRepository, 'listCaseDynamicConsistencyRows')
|
jest.spyOn(bomCostingScenarioRepository, 'listCaseDynamicConsistencyRows')
|
||||||
@ -178,6 +206,61 @@ describe('BomCostingScenarioService', () => {
|
|||||||
jest.restoreAllMocks()
|
jest.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses a live market reference for the whole-SKU estimate while retaining structural detail for audit', async () => {
|
||||||
|
mockBase({
|
||||||
|
bomLines: [line({
|
||||||
|
id: 'drawer-hardware',
|
||||||
|
category: 'ACCESSORY',
|
||||||
|
partName: '抽屉盒五金',
|
||||||
|
materialName: '橡胶木',
|
||||||
|
unit: '套',
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([
|
||||||
|
candidate({
|
||||||
|
id: 'history-hardware',
|
||||||
|
category: 'ACCESSORY',
|
||||||
|
partName: '抽屉盒五金',
|
||||||
|
materialName: '橡胶木',
|
||||||
|
unitPrice: 100,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
jest.mocked(bomCosting1688ImageSearchService.findRealtimeQuote).mockResolvedValue({
|
||||||
|
schemaVersion: 'bom-costing-1688-realtime/v1',
|
||||||
|
source: '1688_IMAGE_SEARCH',
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
sourceImageUrl: 'https://images.example.com/sku.jpg',
|
||||||
|
searchedAt: '2026-08-15T10:00:00.000Z',
|
||||||
|
procurementRate: 0.9,
|
||||||
|
marketPrice: 120,
|
||||||
|
procurementCost: 108,
|
||||||
|
estimateRange: { min: 99, max: 117 },
|
||||||
|
confidence: 0.84,
|
||||||
|
comparableOfferCount: 3,
|
||||||
|
returnedOfferCount: 3,
|
||||||
|
warnings: [],
|
||||||
|
offers: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await new BomCostingScenarioService().calculateSameMaterial({
|
||||||
|
caseId: 'case-1',
|
||||||
|
skuId: 'sku-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]?.[0]
|
||||||
|
expect(saved?.sameMaterial).toEqual(expect.objectContaining({
|
||||||
|
total: 108,
|
||||||
|
estimateBasis: 'AI_REFERENCE_ESTIMATE',
|
||||||
|
status: 'PROVISIONAL',
|
||||||
|
realtimeMarketQuote: expect.objectContaining({
|
||||||
|
procurementCost: 108,
|
||||||
|
structuralCost: 100,
|
||||||
|
structuralEstimateRange: { min: 78, max: 122 },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
expect(bomCostingAiCostEstimator.estimateMissingCosts).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('recalculates both scenarios from company prices and substitutes only the target category', async () => {
|
it('recalculates both scenarios from company prices and substitutes only the target category', async () => {
|
||||||
const bomLines = [
|
const bomLines = [
|
||||||
line({
|
line({
|
||||||
@ -386,6 +469,34 @@ describe('BomCostingScenarioService', () => {
|
|||||||
expect(saved.sameMaterial.lines[0]?.warning).toContain('不是公司正式采购价格')
|
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 () => {
|
it('替换材质没有直接公司价格时也形成低可信替换预估,不阻断整套方案', async () => {
|
||||||
const bomLines = [line({
|
const bomLines = [line({
|
||||||
id: 'drawer-box',
|
id: 'drawer-box',
|
||||||
@ -2887,18 +2998,132 @@ describe('BomCostingScenarioService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('only reports structure insufficiency when the SKU has no BOM lines at all', async () => {
|
it('uses a whole-product model estimate when the SKU has no BOM lines yet', async () => {
|
||||||
mockBase({ bomLines: [] })
|
mockBase({ bomLines: [] })
|
||||||
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([])
|
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()
|
const service = new BomCostingScenarioService()
|
||||||
|
|
||||||
await service.calculateSameMaterial({ caseId: 'case-1', skuId: 'sku-1' })
|
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]
|
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
|
||||||
expect(saved.sameMaterial).toMatchObject({
|
expect(saved.sameMaterial).toMatchObject({
|
||||||
total: null,
|
total: null,
|
||||||
status: 'INSUFFICIENT_DATA',
|
status: 'INSUFFICIENT_DATA',
|
||||||
warning: expect.stringContaining('没有识别出可用于成本预估的产品结构'),
|
warning: expect.stringContaining('资料不足,暂无法预估'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -3030,6 +3255,29 @@ describe('BomCostingScenarioService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps every component category pending when only a whole-SKU estimate exists', () => {
|
||||||
|
const result = {
|
||||||
|
lines: [],
|
||||||
|
} as DynamicBomScenarioResult
|
||||||
|
|
||||||
|
expect(buildDynamicCostBreakdown(result)).toEqual({
|
||||||
|
categoryTotals: {
|
||||||
|
MATERIAL: null,
|
||||||
|
PROCESSING: null,
|
||||||
|
LABOR: null,
|
||||||
|
PACKAGING: null,
|
||||||
|
},
|
||||||
|
categoryEstimateRanges: {
|
||||||
|
MATERIAL: null,
|
||||||
|
PROCESSING: null,
|
||||||
|
LABOR: null,
|
||||||
|
PACKAGING: null,
|
||||||
|
},
|
||||||
|
lossCost: null,
|
||||||
|
lossEstimateRange: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a line unresolved when the company price unit cannot be converted', async () => {
|
it('keeps a line unresolved when the company price unit cannot be converted', async () => {
|
||||||
const bomLines = [line({
|
const bomLines = [line({
|
||||||
id: 'board',
|
id: 'board',
|
||||||
@ -3070,7 +3318,7 @@ describe('BomCostingScenarioService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serializes loss and price provenance fields for the workbench', async () => {
|
it('serializes a unified public estimate without price-source provenance', async () => {
|
||||||
jest.spyOn(bomCostingScenarioRepository, 'listCurrentScenarios').mockResolvedValue([{
|
jest.spyOn(bomCostingScenarioRepository, 'listCurrentScenarios').mockResolvedValue([{
|
||||||
id: 'scenario-1',
|
id: 'scenario-1',
|
||||||
skuId: 'sku-1',
|
skuId: 'sku-1',
|
||||||
@ -3095,6 +3343,15 @@ describe('BomCostingScenarioService', () => {
|
|||||||
dataAsOf: now,
|
dataAsOf: now,
|
||||||
syncRunId: 'run-success',
|
syncRunId: 'run-success',
|
||||||
rulesetVersion: 'dynamic-bom-cost/v1',
|
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,
|
createdAt: now,
|
||||||
syncRun: {
|
syncRun: {
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
@ -3146,28 +3403,59 @@ describe('BomCostingScenarioService', () => {
|
|||||||
expect(scenario).toMatchObject({
|
expect(scenario).toMatchObject({
|
||||||
totalCost: 152,
|
totalCost: 152,
|
||||||
lossCost: 5,
|
lossCost: 5,
|
||||||
competitorPrice: 100,
|
estimateRange: null,
|
||||||
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,
|
|
||||||
}],
|
|
||||||
})
|
})
|
||||||
expect(scenario?.lines[0]).toMatchObject({
|
expect(scenario?.lines[0]).toMatchObject({
|
||||||
sourceRowId: 'reference-row-1',
|
partName: '桌面板',
|
||||||
sourceCreatedAt: now.toISOString(),
|
subtotal: 55,
|
||||||
sourceSyncedAt: now.toISOString(),
|
|
||||||
calculationRule: 'board-sheet-maxrects/v2-batch',
|
|
||||||
})
|
})
|
||||||
|
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 () => {
|
it('reports mirror completion as data freshness while exposing the upstream timestamp separately', async () => {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
type DynamicBomLineCostResult,
|
type DynamicBomLineCostResult,
|
||||||
type DynamicBomEstimateBasis,
|
type DynamicBomEstimateBasis,
|
||||||
type DynamicBomPriceQuote,
|
type DynamicBomPriceQuote,
|
||||||
|
type DynamicBomRealtimeMarketQuote,
|
||||||
type DynamicBomScenarioResult,
|
type DynamicBomScenarioResult,
|
||||||
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
|
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
|
||||||
import {
|
import {
|
||||||
@ -27,7 +28,11 @@ import {
|
|||||||
type BomCostingAiCompanyReference,
|
type BomCostingAiCompanyReference,
|
||||||
type BomCostingAiEstimateRequestLine,
|
type BomCostingAiEstimateRequestLine,
|
||||||
type BomCostingAiKnownLine,
|
type BomCostingAiKnownLine,
|
||||||
|
type BomCostingAiWholeProductEstimateResult,
|
||||||
} from './bom-costing-ai-cost-estimator'
|
} from './bom-costing-ai-cost-estimator'
|
||||||
|
import {
|
||||||
|
bomCosting1688ImageSearchService,
|
||||||
|
} from './bom-costing-1688-image-search'
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
BOARD: '板材',
|
BOARD: '板材',
|
||||||
@ -2247,6 +2252,117 @@ function toAiKnownLine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadWholeProductAiReferences(input: {
|
||||||
|
syncRunId: string | null
|
||||||
|
skuName: string
|
||||||
|
lines: BomCostingLine[]
|
||||||
|
}) {
|
||||||
|
if (!input.syncRunId) return []
|
||||||
|
const referenceLines = input.lines.length > 0
|
||||||
|
? input.lines
|
||||||
|
: [{
|
||||||
|
partName: input.skuName,
|
||||||
|
materialName: '',
|
||||||
|
materialSpec: '',
|
||||||
|
processName: '',
|
||||||
|
unit: '',
|
||||||
|
}]
|
||||||
|
const categories = Array.from(new Set(input.lines.map((line) => line.category)))
|
||||||
|
const referenceCategories = categories.length > 0
|
||||||
|
? categories
|
||||||
|
: ['BOARD', 'STEEL', 'ACCESSORY', 'PROCESSING', 'LABOR', 'PACKAGING']
|
||||||
|
const groups = await Promise.all(referenceCategories.map(async (category) => {
|
||||||
|
const candidates = await bomCostingScenarioRepository.findCategoryCohortCandidates({
|
||||||
|
syncRunId: input.syncRunId!,
|
||||||
|
category,
|
||||||
|
limit: 40,
|
||||||
|
})
|
||||||
|
return selectAiCompanyReferences({
|
||||||
|
candidates,
|
||||||
|
lines: referenceLines,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
return Array.from(new Map(groups.flat().map((reference) => [
|
||||||
|
reference.sourceRowId,
|
||||||
|
reference,
|
||||||
|
])).values()).slice(0, 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWholeProductAiEstimate(
|
||||||
|
result: DynamicBomScenarioResult,
|
||||||
|
estimate: BomCostingAiWholeProductEstimateResult,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
result.scenario !== 'SAME_MATERIAL' ||
|
||||||
|
estimate.totalCost === null ||
|
||||||
|
estimate.estimateRange === null ||
|
||||||
|
estimate.confidence === null
|
||||||
|
) return null
|
||||||
|
|
||||||
|
result.total = estimate.totalCost
|
||||||
|
result.estimateRange = estimate.estimateRange
|
||||||
|
result.estimateBasis = 'AI_REFERENCE_ESTIMATE'
|
||||||
|
result.sampleCount = Math.max(result.sampleCount, estimate.referenceRowIds.length)
|
||||||
|
result.estimatedLineCount = Math.max(result.estimatedLineCount, 1)
|
||||||
|
result.confidence = Math.min(result.confidence ?? estimate.confidence, estimate.confidence)
|
||||||
|
if (result.status !== 'STALE') result.status = 'PROVISIONAL'
|
||||||
|
result.warnings.push(
|
||||||
|
'已依据产品资料、结构事实与知识参考形成整件预估;未覆盖明细已标记为待补',
|
||||||
|
)
|
||||||
|
result.warning = Array.from(new Set(result.warnings)).join(';')
|
||||||
|
return estimate.sourceAudit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 1688 image-search result is a live price for a finished product, not an
|
||||||
|
* exact quote for every BOM component. We therefore keep the structural BOM
|
||||||
|
* result in the attached audit object and use the live procurement estimate as
|
||||||
|
* the SKU-level headline only for the original-material scenario.
|
||||||
|
*/
|
||||||
|
function applyRealtime1688ProcurementAnchor(
|
||||||
|
result: DynamicBomScenarioResult,
|
||||||
|
quote: DynamicBomRealtimeMarketQuote,
|
||||||
|
) {
|
||||||
|
const structuralCost = result.total
|
||||||
|
const structuralEstimateRange = result.estimateRange
|
||||||
|
result.realtimeMarketQuote = {
|
||||||
|
...quote,
|
||||||
|
structuralCost,
|
||||||
|
structuralEstimateRange,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.scenario !== 'SAME_MATERIAL' ||
|
||||||
|
quote.status !== 'AVAILABLE' ||
|
||||||
|
quote.procurementCost === null ||
|
||||||
|
quote.estimateRange === null
|
||||||
|
) return
|
||||||
|
|
||||||
|
result.total = quote.procurementCost
|
||||||
|
result.estimateRange = quote.estimateRange
|
||||||
|
// The private quote remains attached for recalculation and diagnostics. The
|
||||||
|
// regular BOM contract exposes this as a source-neutral comprehensive
|
||||||
|
// estimate, consistent with all other non-final estimate inputs.
|
||||||
|
result.estimateBasis = 'AI_REFERENCE_ESTIMATE'
|
||||||
|
result.sampleCount = quote.comparableOfferCount
|
||||||
|
result.confidence = quote.confidence
|
||||||
|
// A current image-search result is still an estimate, never a formal
|
||||||
|
// supplier quotation. It can however avoid presenting the whole SKU as
|
||||||
|
// unpriced merely because one historical BOM line is absent.
|
||||||
|
result.status = 'PROVISIONAL'
|
||||||
|
result.warnings.push('已形成整件预估,结构拆解用于解释构成与偏差复核')
|
||||||
|
if (structuralCost !== null) {
|
||||||
|
result.warnings.push(
|
||||||
|
'结构拆解预估已保留,仅用于解释构成与偏差复核',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result.warnings.push(
|
||||||
|
'结构拆解仍有待补明细;当前整件预估已形成,明细可继续补充事实或由模型预估',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.warning = Array.from(new Set(result.warnings)).join(';')
|
||||||
|
}
|
||||||
|
|
||||||
function withRequiredEstimateCategories(
|
function withRequiredEstimateCategories(
|
||||||
lines: BomCostingLine[],
|
lines: BomCostingLine[],
|
||||||
skuId: string,
|
skuId: string,
|
||||||
@ -2366,6 +2482,24 @@ function buildScenarioRawSnapshot(
|
|||||||
|
|
||||||
export function buildDynamicCostBreakdown(result: DynamicBomScenarioResult) {
|
export function buildDynamicCostBreakdown(result: DynamicBomScenarioResult) {
|
||||||
const zero = new Prisma.Decimal(0)
|
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> = {
|
const netByCategory: Record<string, Prisma.Decimal> = {
|
||||||
BOARD: zero,
|
BOARD: zero,
|
||||||
STEEL: zero,
|
STEEL: zero,
|
||||||
@ -2464,83 +2598,24 @@ function readMaterialAliases(value: unknown) {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBoardCuttingSummary(
|
function publicScenarioWarning(value: string) {
|
||||||
lines: BomCostingScenarioRecord['lines'],
|
const sourceSpecificPattern = /(?:1688|实时同款|同款价|采购系数|市场价|竞品售价|公司(?:历史)?\s*BOM|参考\s*SKU|公司价格来源|基础资料库|正式采购|历史用量|镜像(?:同步|批次)?|供应商|店铺|商品链接)/iu
|
||||||
) {
|
const genericWarning = '当前预估仍有待补信息,建议补充结构或尺寸后重新核算'
|
||||||
const summaries = new Map<string, {
|
const warnings = value
|
||||||
sourceLineId: string | null
|
.split(/;\s*/u)
|
||||||
materialName: string
|
.map((part) => part.trim())
|
||||||
rulesetVersion: string
|
.filter(Boolean)
|
||||||
selectedBatchSize: number
|
.map((part) => (sourceSpecificPattern.test(part) ? genericWarning : part))
|
||||||
batchSheetCount: number
|
|
||||||
perUnitSheetCost: number
|
return Array.from(new Set(warnings)).join(';')
|
||||||
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 serializeScenario(record: BomCostingScenarioRecord) {
|
function serializeScenario(record: BomCostingScenarioRecord) {
|
||||||
const totalCost = record.totalCost === null ? null : Number(record.totalCost)
|
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 {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
skuId: record.skuId,
|
skuId: record.skuId,
|
||||||
kind: record.kind,
|
kind: record.kind,
|
||||||
targetMaterialId: record.targetMaterialId,
|
|
||||||
targetMaterialName: record.targetMaterialName,
|
|
||||||
revision: record.revision,
|
revision: record.revision,
|
||||||
isCurrent: record.isCurrent,
|
isCurrent: record.isCurrent,
|
||||||
status: record.status,
|
status: record.status,
|
||||||
@ -2590,40 +2665,17 @@ function serializeScenario(record: BomCostingScenarioRecord) {
|
|||||||
: null
|
: null
|
||||||
return result
|
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),
|
materialCost: record.materialCost === null ? null : Number(record.materialCost),
|
||||||
processingCost: record.processingCost === null ? null : Number(record.processingCost),
|
processingCost: record.processingCost === null ? null : Number(record.processingCost),
|
||||||
laborCost: record.laborCost === null ? null : Number(record.laborCost),
|
laborCost: record.laborCost === null ? null : Number(record.laborCost),
|
||||||
packagingCost: record.packagingCost === null ? null : Number(record.packagingCost),
|
packagingCost: record.packagingCost === null ? null : Number(record.packagingCost),
|
||||||
lossCost: record.lossCost === null ? null : Number(record.lossCost),
|
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),
|
confidence: record.confidence === null ? null : Number(record.confidence),
|
||||||
warning: record.warning,
|
warning: publicScenarioWarning(record.warning),
|
||||||
unresolvedCount: record.unresolvedCount,
|
unresolvedCount: record.unresolvedCount,
|
||||||
dataAsOf: record.dataAsOf?.toISOString() ?? null,
|
|
||||||
syncRunId: record.syncRunId,
|
|
||||||
syncStatus: record.syncRun?.status ?? null,
|
|
||||||
syncCompletedAt: record.syncRun?.completedAt?.toISOString() ?? null,
|
|
||||||
rulesetVersion: record.rulesetVersion,
|
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
boardCutting: readBoardCuttingSummary(record.lines),
|
|
||||||
lines: record.lines.map((line) => ({
|
lines: record.lines.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
sourceLineId: line.sourceLineId,
|
|
||||||
sourceRowId: line.sourceRowId,
|
|
||||||
category: line.category,
|
category: line.category,
|
||||||
partName: line.partName,
|
partName: line.partName,
|
||||||
originalMaterial: line.originalMaterial,
|
originalMaterial: line.originalMaterial,
|
||||||
@ -2631,27 +2683,13 @@ function serializeScenario(record: BomCostingScenarioRecord) {
|
|||||||
materialSpec: line.materialSpec,
|
materialSpec: line.materialSpec,
|
||||||
unit: line.unit,
|
unit: line.unit,
|
||||||
quantity: line.quantity === null ? null : Number(line.quantity),
|
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),
|
subtotal: line.subtotal === null ? null : Number(line.subtotal),
|
||||||
estimateRange: line.estimateLow == null || line.estimateHigh == null
|
estimateRange: line.estimateLow == null || line.estimateHigh == null
|
||||||
? null
|
? null
|
||||||
: { min: Number(line.estimateLow), max: Number(line.estimateHigh) },
|
: { 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),
|
confidence: line.confidence === null ? null : Number(line.confidence),
|
||||||
status: line.status,
|
status: line.status,
|
||||||
warning: line.warning,
|
warning: publicScenarioWarning(line.warning),
|
||||||
rawSnapshot: line.rawSnapshot,
|
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2800,7 +2838,9 @@ export class BomCostingScenarioService {
|
|||||||
|
|
||||||
async listScenarios(input: { caseId: string; skuId?: string }) {
|
async listScenarios(input: { caseId: string; skuId?: string }) {
|
||||||
const records = await bomCostingScenarioRepository.listCurrentScenarios(input)
|
const records = await bomCostingScenarioRepository.listCurrentScenarios(input)
|
||||||
return records.map(serializeScenario)
|
return records
|
||||||
|
.filter((record) => record.kind === 'SAME_MATERIAL')
|
||||||
|
.map(serializeScenario)
|
||||||
}
|
}
|
||||||
|
|
||||||
async calculate(input: {
|
async calculate(input: {
|
||||||
@ -2846,6 +2886,12 @@ export class BomCostingScenarioService {
|
|||||||
}) {
|
}) {
|
||||||
const sku = await bomCostingScenarioRepository.findSkuForScenario(input)
|
const sku = await bomCostingScenarioRepository.findSkuForScenario(input)
|
||||||
if (!sku) throw new AppError(ErrorCode.BOM_COSTING_CASE_NOT_FOUND, 'BOM SKU 不存在或无权访问')
|
if (!sku) throw new AppError(ErrorCode.BOM_COSTING_CASE_NOT_FOUND, 'BOM SKU 不存在或无权访问')
|
||||||
|
// Start the remote request immediately so it overlaps the local history
|
||||||
|
// and master-data reads below. A failed/unconfigured search resolves to a
|
||||||
|
// visible degradation object and never blocks a BOM calculation.
|
||||||
|
const realtimeMarketQuotePromise = bomCosting1688ImageSearchService.findRealtimeQuote({
|
||||||
|
imageUrl: sku.imageUrl,
|
||||||
|
})
|
||||||
const materials = await bomCostingScenarioRepository.listEnabledMaterials()
|
const materials = await bomCostingScenarioRepository.listEnabledMaterials()
|
||||||
const target = input.targetMaterialId
|
const target = input.targetMaterialId
|
||||||
? materials.find((material) => material.id === input.targetMaterialId) ?? null
|
? materials.find((material) => material.id === input.targetMaterialId) ?? null
|
||||||
@ -3147,6 +3193,7 @@ export class BomCostingScenarioService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const hasProductStructure = sku.bomLines.length > 0
|
const hasProductStructure = sku.bomLines.length > 0
|
||||||
|
const realtimeMarketQuote = await realtimeMarketQuotePromise
|
||||||
let comparison = calculateDynamicBomScenarios({
|
let comparison = calculateDynamicBomScenarios({
|
||||||
rows,
|
rows,
|
||||||
sameMaterialPrices,
|
sameMaterialPrices,
|
||||||
@ -3238,6 +3285,7 @@ export class BomCostingScenarioService {
|
|||||||
requests: aiRequests,
|
requests: aiRequests,
|
||||||
knownLines,
|
knownLines,
|
||||||
companyReferences: referenceGroups.flat().slice(0, 80),
|
companyReferences: referenceGroups.flat().slice(0, 80),
|
||||||
|
realtimeMarketReference: realtimeMarketQuote,
|
||||||
})
|
})
|
||||||
Object.assign(sameMaterialPrices, aiEstimate.quotes.SAME_MATERIAL)
|
Object.assign(sameMaterialPrices, aiEstimate.quotes.SAME_MATERIAL)
|
||||||
Object.assign(substitutionPrices, aiEstimate.quotes.MATERIAL_SUBSTITUTION)
|
Object.assign(substitutionPrices, aiEstimate.quotes.MATERIAL_SUBSTITUTION)
|
||||||
@ -3278,17 +3326,6 @@ export class BomCostingScenarioService {
|
|||||||
// zero/negative price for the missing rows.
|
// zero/negative price for the missing rows.
|
||||||
applyCategoryProfileConflicts(comparison.sameMaterial, categoryFallback.conflicts)
|
applyCategoryProfileConflicts(comparison.sameMaterial, categoryFallback.conflicts)
|
||||||
applyCategoryProfileFallbackWarnings(comparison.sameMaterial, categoryFallback.warnings)
|
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
|
const scenarioResults = input.sameMaterialOnly
|
||||||
? [comparison.sameMaterial]
|
? [comparison.sameMaterial]
|
||||||
: [comparison.sameMaterial, comparison.materialSubstitution]
|
: [comparison.sameMaterial, comparison.materialSubstitution]
|
||||||
@ -3374,6 +3411,49 @@ export class BomCostingScenarioService {
|
|||||||
result.warnings.push(failedWarning)
|
result.warnings.push(failedWarning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A finished-product reference can form a useful whole-SKU estimate even
|
||||||
|
// before the structure extractor has identified every BOM line. It is not
|
||||||
|
// used as a component price, and never changes an internal line subtotal.
|
||||||
|
applyRealtime1688ProcurementAnchor(comparison.sameMaterial, realtimeMarketQuote)
|
||||||
|
comparison.materialSubstitution.realtimeMarketQuote = realtimeMarketQuote
|
||||||
|
|
||||||
|
let wholeProductEstimateAudit: unknown = null
|
||||||
|
if (comparison.sameMaterial.total === null) {
|
||||||
|
const companyReferences = await loadWholeProductAiReferences({
|
||||||
|
syncRunId: syncRun?.id ?? null,
|
||||||
|
skuName: sku.name,
|
||||||
|
lines: scenarioLines,
|
||||||
|
})
|
||||||
|
const wholeProductEstimate = await bomCostingAiCostEstimator.estimateWholeProductCost({
|
||||||
|
product: {
|
||||||
|
externalSkuId: sku.externalSkuId,
|
||||||
|
name: sku.name,
|
||||||
|
structureGroupKey: sku.structureGroupKey,
|
||||||
|
costFamilyKey: sku.costFamilyKey,
|
||||||
|
lengthMm: decimalNumber(sku.lengthMm),
|
||||||
|
widthMm: decimalNumber(sku.widthMm),
|
||||||
|
heightMm: decimalNumber(sku.heightMm),
|
||||||
|
attributes: sku.attributes,
|
||||||
|
},
|
||||||
|
knownLines: comparison.sameMaterial.lines.flatMap((line) => {
|
||||||
|
const known = toAiKnownLine(line, 'SAME_MATERIAL')
|
||||||
|
return known ? [known] : []
|
||||||
|
}),
|
||||||
|
companyReferences,
|
||||||
|
})
|
||||||
|
wholeProductEstimateAudit = applyWholeProductAiEstimate(
|
||||||
|
comparison.sameMaterial,
|
||||||
|
wholeProductEstimate,
|
||||||
|
)
|
||||||
|
if (comparison.sameMaterial.total === null) {
|
||||||
|
comparison.sameMaterial.status = 'INSUFFICIENT_DATA'
|
||||||
|
comparison.sameMaterial.estimateRange = null
|
||||||
|
comparison.sameMaterial.warnings.push('资料不足,暂无法预估')
|
||||||
|
comparison.sameMaterial.warning = Array.from(new Set(
|
||||||
|
comparison.sameMaterial.warnings,
|
||||||
|
)).join(';')
|
||||||
|
}
|
||||||
|
}
|
||||||
const warning = (result: DynamicBomScenarioResult) => [
|
const warning = (result: DynamicBomScenarioResult) => [
|
||||||
...result.warnings,
|
...result.warnings,
|
||||||
result.marketPriceAlertKind === 'HIGH' ? '预估成本高于竞品售价,需复核主要驱动项' : '',
|
result.marketPriceAlertKind === 'HIGH' ? '预估成本高于竞品售价,需复核主要驱动项' : '',
|
||||||
@ -3431,6 +3511,8 @@ export class BomCostingScenarioService {
|
|||||||
categoryTotals: sameBreakdown.categoryTotals,
|
categoryTotals: sameBreakdown.categoryTotals,
|
||||||
unresolvedCount: comparison.sameMaterial.unresolvedLineCount,
|
unresolvedCount: comparison.sameMaterial.unresolvedLineCount,
|
||||||
warning: warning(comparison.sameMaterial),
|
warning: warning(comparison.sameMaterial),
|
||||||
|
realtimeMarketQuote: comparison.sameMaterial.realtimeMarketQuote ?? null,
|
||||||
|
wholeProductEstimateAudit,
|
||||||
lines: comparison.sameMaterial.lines.map((line) => (
|
lines: comparison.sameMaterial.lines.map((line) => (
|
||||||
toSavedLine(line, line.materialName)
|
toSavedLine(line, line.materialName)
|
||||||
)),
|
)),
|
||||||
@ -3482,6 +3564,7 @@ export class BomCostingScenarioService {
|
|||||||
categoryTotals: substitutionBreakdown.categoryTotals,
|
categoryTotals: substitutionBreakdown.categoryTotals,
|
||||||
unresolvedCount: comparison.materialSubstitution.unresolvedLineCount,
|
unresolvedCount: comparison.materialSubstitution.unresolvedLineCount,
|
||||||
warning: warning(comparison.materialSubstitution),
|
warning: warning(comparison.materialSubstitution),
|
||||||
|
realtimeMarketQuote: comparison.materialSubstitution.realtimeMarketQuote ?? null,
|
||||||
lines: comparison.materialSubstitution.lines.map((line) => (
|
lines: comparison.materialSubstitution.lines.map((line) => (
|
||||||
toSavedLine(
|
toSavedLine(
|
||||||
line,
|
line,
|
||||||
@ -3741,21 +3824,9 @@ export class BomCostingScenarioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCaseDynamicCosting(caseId: string) {
|
async getCaseDynamicCosting(caseId: string) {
|
||||||
const [materials, scenarios, sync] = await Promise.all([
|
const scenarios = await this.listScenarios({ caseId })
|
||||||
this.listMaterials(),
|
|
||||||
this.listScenarios({ caseId }),
|
|
||||||
this.getSyncFreshness(),
|
|
||||||
])
|
|
||||||
return {
|
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,
|
scenarios,
|
||||||
sync,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,6 +86,135 @@ describe('BomCostingService', () => {
|
|||||||
).rejects.toThrow('当前账号不能访问 BOM 核算')
|
).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 () => {
|
it('旧版结果必须重新核算后才能生成动态成本场景', async () => {
|
||||||
jest.spyOn(bomCostingService, 'getCase').mockResolvedValue({
|
jest.spyOn(bomCostingService, 'getCase').mockResolvedValue({
|
||||||
id: 'case-legacy',
|
id: 'case-legacy',
|
||||||
@ -1144,7 +1273,7 @@ describe('BomCostingService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('商品跳转优先使用归档商品标识,不继续打开登录或追踪地址', async () => {
|
it('归档商品标识只供服务端内部使用,普通列表不返回链接或站点来源', async () => {
|
||||||
jest.spyOn(bomCostingRepository, 'listCases').mockResolvedValue([{
|
jest.spyOn(bomCostingRepository, 'listCases').mockResolvedValue([{
|
||||||
id: 'case-canonical-url',
|
id: 'case-canonical-url',
|
||||||
title: '规范商品地址',
|
title: '规范商品地址',
|
||||||
@ -1172,6 +1301,9 @@ describe('BomCostingService', () => {
|
|||||||
|
|
||||||
const [summary] = await bomCostingService.listCases(operationsUser)
|
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 { BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION } from './bom-costing-structure-knowledge'
|
||||||
import {
|
import {
|
||||||
buildCanonicalMarketProductUrl,
|
buildCanonicalMarketProductUrl,
|
||||||
canonicalizeMarketProductUrl,
|
|
||||||
parseMarketProductIdentity,
|
parseMarketProductIdentity,
|
||||||
type MarketProductIdentity,
|
|
||||||
} from './market-detail-client'
|
} from './market-detail-client'
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<BomCostingLineCategory, string> = {
|
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 MANUAL_REVIEW_PRICE_SOURCE = 'MANUAL_REVIEW'
|
||||||
const EXTERNAL_ESTIMATE_PRICE_SOURCE = 'EXTERNAL_ANALYSIS_ESTIMATE'
|
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) {
|
function decimalToNumber(value: Prisma.Decimal | null | undefined) {
|
||||||
return value === null || value === undefined ? null : value.toNumber()
|
return value === null || value === undefined ? null : value.toNumber()
|
||||||
}
|
}
|
||||||
@ -267,31 +247,10 @@ function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLine
|
|||||||
weightKg: decimalToNumber(line.weightKg),
|
weightKg: decimalToNumber(line.weightKg),
|
||||||
lossFactor: decimalToNumber(line.lossFactor) ?? 1,
|
lossFactor: decimalToNumber(line.lossFactor) ?? 1,
|
||||||
sourceConfidence: decimalToNumber(line.sourceConfidence),
|
sourceConfidence: decimalToNumber(line.sourceConfidence),
|
||||||
evidence: line.evidence,
|
evidence: serializePublicLineEvidence(line.evidence),
|
||||||
matchStatus: needsPackagingReconfirmation
|
estimateStatus: needsPackagingReconfirmation || line.subtotal === null
|
||||||
? BomCostingMatchStatus.NEEDS_CONFIRMATION
|
? 'PENDING'
|
||||||
: line.matchStatus,
|
: 'ESTIMATED',
|
||||||
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),
|
|
||||||
subtotal: needsPackagingReconfirmation
|
subtotal: needsPackagingReconfirmation
|
||||||
? null
|
? null
|
||||||
: decimalToNumber(line.subtotal),
|
: 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) {
|
function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
|
||||||
const resultQuality = getBomCostingResultQuality({
|
const resultQuality = getBomCostingResultQuality({
|
||||||
status: record.status,
|
status: record.status,
|
||||||
@ -355,11 +336,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
|
|||||||
})()
|
})()
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
sourceUrl: canonicalCaseSourceUrl(record),
|
|
||||||
sourceHost: record.sourceHost,
|
|
||||||
title: record.title,
|
title: record.title,
|
||||||
providerCode: record.providerCode,
|
|
||||||
providerTaskId: record.providerTaskId,
|
|
||||||
sourceCacheHit: record.sourceCacheHit,
|
sourceCacheHit: record.sourceCacheHit,
|
||||||
status: record.status,
|
status: record.status,
|
||||||
progress: record.progress,
|
progress: record.progress,
|
||||||
@ -377,10 +354,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
|
|||||||
? {
|
? {
|
||||||
id: record.sourceSnapshot.id,
|
id: record.sourceSnapshot.id,
|
||||||
status: record.sourceSnapshot.status,
|
status: record.sourceSnapshot.status,
|
||||||
channel: record.sourceSnapshot.channel,
|
|
||||||
externalItemId: record.sourceSnapshot.externalItemId,
|
|
||||||
title: record.sourceSnapshot.title,
|
title: record.sourceSnapshot.title,
|
||||||
shopName: record.sourceSnapshot.shopName,
|
|
||||||
skuCount: record.sourceSnapshot.skuCount,
|
skuCount: record.sourceSnapshot.skuCount,
|
||||||
imageCount: record.sourceSnapshot.imageCount,
|
imageCount: record.sourceSnapshot.imageCount,
|
||||||
storedImageCount: record.sourceSnapshot.storedImageCount,
|
storedImageCount: record.sourceSnapshot.storedImageCount,
|
||||||
@ -449,30 +423,6 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
|
|||||||
costSummary,
|
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) => ({
|
analysisRuns: record.analysisRuns.map((run) => ({
|
||||||
id: run.id,
|
id: run.id,
|
||||||
revision: run.revision,
|
revision: run.revision,
|
||||||
@ -544,8 +494,6 @@ export class BomCostingService {
|
|||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
title: record.title,
|
title: record.title,
|
||||||
sourceUrl: canonicalCaseSourceUrl(record),
|
|
||||||
sourceHost: record.sourceHost,
|
|
||||||
status: record.status,
|
status: record.status,
|
||||||
progress: record.progress,
|
progress: record.progress,
|
||||||
statusMessage: record.statusMessage,
|
statusMessage: record.statusMessage,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user