Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-mainline-todo-unionid-20260729

This commit is contained in:
jingchuan 2026-07-31 11:24:49 +08:00
commit 46a7f25619
39 changed files with 4870 additions and 641 deletions

View File

@ -0,0 +1,49 @@
-- CreateEnum
CREATE TYPE "BomCostingFactAnalysisStatus" AS ENUM (
'PENDING',
'PROCESSING',
'READY',
'FAILED'
);
-- CreateTable
CREATE TABLE "BomCostingSourceFactAnalysis" (
"id" TEXT NOT NULL,
"sourceSnapshotId" TEXT NOT NULL,
"schemaVersion" TEXT NOT NULL,
"assetFingerprint" TEXT NOT NULL,
"status" "BomCostingFactAnalysisStatus" NOT NULL DEFAULT 'PENDING',
"model" TEXT NOT NULL DEFAULT '',
"factSnapshot" JSONB,
"analyzedImageCount" INTEGER NOT NULL DEFAULT 0,
"totalImageCount" INTEGER NOT NULL DEFAULT 0,
"batchCount" INTEGER NOT NULL DEFAULT 0,
"promptTokens" INTEGER NOT NULL DEFAULT 0,
"completionTokens" INTEGER NOT NULL DEFAULT 0,
"durationMs" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT NOT NULL DEFAULT '',
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingSourceFactAnalysis_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BomCostingSourceFactAnalysis_sourceSnapshotId_schemaVersion_key"
ON "BomCostingSourceFactAnalysis"("sourceSnapshotId", "schemaVersion");
-- CreateIndex
CREATE INDEX "BomCostingSourceFactAnalysis_sourceSnapshotId_status_idx"
ON "BomCostingSourceFactAnalysis"("sourceSnapshotId", "status");
-- CreateIndex
CREATE INDEX "BomCostingSourceFactAnalysis_status_updatedAt_idx"
ON "BomCostingSourceFactAnalysis"("status", "updatedAt");
-- AddForeignKey
ALTER TABLE "BomCostingSourceFactAnalysis"
ADD CONSTRAINT "BomCostingSourceFactAnalysis_sourceSnapshotId_fkey"
FOREIGN KEY ("sourceSnapshotId") REFERENCES "BomCostingSourceSnapshot"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -701,6 +701,7 @@ model BomCostingSourceSnapshot {
cases BomCostingCase[]
assets BomCostingSourceAsset[]
factAnalyses BomCostingSourceFactAnalysis[]
@@index([status, updatedAt])
@@index([channel, externalItemId])
@ -728,6 +729,32 @@ model BomCostingSourceAsset {
@@index([sourceSnapshotId, status, sortOrder])
}
model BomCostingSourceFactAnalysis {
id String @id @default(cuid())
sourceSnapshotId String
sourceSnapshot BomCostingSourceSnapshot @relation(fields: [sourceSnapshotId], references: [id], onDelete: Cascade)
schemaVersion String
assetFingerprint String
status BomCostingFactAnalysisStatus @default(PENDING)
model String @default("")
factSnapshot Json?
analyzedImageCount Int @default(0)
totalImageCount Int @default(0)
batchCount Int @default(0)
promptTokens Int @default(0)
completionTokens Int @default(0)
durationMs Int @default(0)
errorMessage String @default("")
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sourceSnapshotId, schemaVersion])
@@index([sourceSnapshotId, status])
@@index([status, updatedAt])
}
model BomCostingSku {
id String @id @default(cuid())
caseId String
@ -1830,6 +1857,13 @@ enum BomCostingSourceAssetStatus {
FAILED
}
enum BomCostingFactAnalysisStatus {
PENDING
PROCESSING
READY
FAILED
}
enum BomCostingLineCategory {
BOARD
STEEL

View File

@ -8,6 +8,34 @@ type RouteContext = {
params: Promise<{ id: string; lineId: string }>
}
export async function GET(request: NextRequest, context: RouteContext) {
return handleRequest(
request,
async (ctx) => {
const { id, lineId } = await context.params
try {
return jsonSuccess(
{
candidates: await bomCostingService.listLinePriceCandidates(
decodeURIComponent(id),
decodeURIComponent(lineId),
ctx.user,
),
},
200,
ctx.requestId,
)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function PATCH(request: NextRequest, context: RouteContext) {
return handleRequest(
request,

View File

@ -122,4 +122,143 @@ describe('deriveBomCostingImageActivity', () => {
])
expect(activity.analysisImages).toHaveLength(2)
})
it('分批事实阶段只高亮当前批次,并保留已完成图片状态', () => {
const analysisImages = [
{
sequence: 1,
assetId: 'asset-main',
kind: 'MAIN',
label: '主图 1',
externalSkuIds: [],
},
{
sequence: 2,
assetId: 'asset-sku',
kind: 'SKU',
label: 'SKU 图 sku-1',
externalSkuIds: ['sku-1'],
},
]
const detail = buildDetail([
{
id: 'event-model-started',
eventType: 'MODEL_ANALYSIS_STARTED',
status: 'ANALYZING',
progress: 52,
message: '准备逐批分析全部 2 张竞品图片',
providerTaskId: 'task-1',
payload: {
imageCount: 2,
analysisImages,
reusedAnalysisImages: [],
},
createdAt: '2026-07-29T01:00:03.000Z',
},
{
id: 'event-batch-1-started',
eventType: 'MODEL_FACT_BATCH_STARTED',
status: 'ANALYZING',
progress: 52,
message: '正在分析第 1/2 批图片',
providerTaskId: 'task-1',
payload: {
batchIndex: 1,
totalBatches: 2,
completedImageCount: 0,
totalImageCount: 2,
batchImages: [analysisImages[0]],
},
createdAt: '2026-07-29T01:00:04.000Z',
},
{
id: 'event-batch-1-completed',
eventType: 'MODEL_FACT_BATCH_COMPLETED',
status: 'ANALYZING',
progress: 64,
message: '已分析 1/2 张图片',
providerTaskId: 'task-1',
payload: {
batchIndex: 1,
totalBatches: 2,
completedImageCount: 1,
totalImageCount: 2,
batchImages: [analysisImages[0]],
},
createdAt: '2026-07-29T01:00:05.000Z',
},
{
id: 'event-batch-2-started',
eventType: 'MODEL_FACT_BATCH_STARTED',
status: 'ANALYZING',
progress: 64,
message: '正在分析第 2/2 批图片',
providerTaskId: 'task-1',
payload: {
batchIndex: 2,
totalBatches: 2,
completedImageCount: 1,
totalImageCount: 2,
batchImages: [analysisImages[1]],
},
createdAt: '2026-07-29T01:00:06.000Z',
},
])
const activity = deriveBomCostingImageActivity(detail)
expect(activity.focusAssetId).toBe('asset-sku')
expect(Array.from(activity.activeAssetIds)).toEqual(['asset-sku'])
expect(Array.from(activity.completedAnalysisAssetIds)).toEqual([
'asset-main',
])
expect(activity.modelBatchIndex).toBe(2)
expect(activity.modelBatchCount).toBe(2)
expect(activity.completedImageCount).toBe(1)
expect(activity.totalImageCount).toBe(2)
})
it('逐图事实缓存完整时不把图片误显示为重新分析中', () => {
const analysisImages = [
{
sequence: 1,
assetId: 'asset-main',
kind: 'MAIN',
label: '主图 1',
externalSkuIds: [],
},
{
sequence: 2,
assetId: 'asset-sku',
kind: 'SKU',
label: 'SKU 图 sku-1',
externalSkuIds: ['sku-1'],
},
]
const detail = buildDetail([
{
id: 'event-model-started',
eventType: 'MODEL_ANALYSIS_STARTED',
status: 'ANALYZING',
progress: 76,
message: '逐图事实已就绪,准备合并产品结构与成本信息',
providerTaskId: 'task-1',
payload: {
imageCount: 2,
analysisImages,
reusedAnalysisImages: analysisImages,
},
createdAt: '2026-07-29T01:00:03.000Z',
},
])
const activity = deriveBomCostingImageActivity(detail)
expect(activity.activeAssetIds.size).toBe(0)
expect(Array.from(activity.completedAnalysisAssetIds)).toEqual([
'asset-main',
'asset-sku',
])
expect(activity.completedImageCount).toBe(2)
})
})

View File

@ -37,9 +37,14 @@ export interface BomCostingImageActivity {
focusAssetId: string | null
activeAssetIds: Set<string>
analysisAssetIds: Set<string>
completedAnalysisAssetIds: Set<string>
failedAssetIds: Set<string>
latestMessage: string
analysisImages: AnalysisImagePlanItem[]
modelBatchIndex: number | null
modelBatchCount: number | null
completedImageCount: number
totalImageCount: number
}
const KIND_LABELS: Record<BomCostingSourceAssetDetail['kind'], string> = {
@ -141,6 +146,8 @@ export function deriveBomCostingImageActivity(
new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(),
)
const activeArchiveIds = new Set<string>()
const activeModelIds = new Set<string>()
const completedAnalysisAssetIds = new Set<string>()
const failedAssetIds = new Set<string>()
let focusAssetId: string | null = null
let latestMessage = detail.statusMessage
@ -149,6 +156,11 @@ export function deriveBomCostingImageActivity(
let priceStartedIndex = -1
let resultSavedIndex = -1
let analysisImages: AnalysisImagePlanItem[] = []
let usesFactBatchProtocol = false
let modelBatchIndex: number | null = null
let modelBatchCount: number | null = null
let completedImageCount = 0
let totalImageCount = 0
events.forEach((event, index) => {
const payload = readRecord(event.payload)
@ -168,6 +180,11 @@ export function deriveBomCostingImageActivity(
}
if (event.eventType === 'MODEL_ANALYSIS_STARTED') {
modelStartedIndex = index
activeModelIds.clear()
completedAnalysisAssetIds.clear()
modelBatchIndex = null
modelBatchCount = null
completedImageCount = 0
analysisImages = readAnalysisImagePlan(payload?.analysisImages)
if (analysisImages.length === 0) {
analysisImages = buildLegacyAnalysisPlan(
@ -175,12 +192,76 @@ export function deriveBomCostingImageActivity(
readNumber(payload?.imageCount) ?? 0,
)
}
totalImageCount =
readNumber(payload?.imageCount) ?? analysisImages.length
usesFactBatchProtocol =
Boolean(payload) &&
Object.prototype.hasOwnProperty.call(
payload,
'reusedAnalysisImages',
)
const reusedAnalysisImages = readAnalysisImagePlan(
payload?.reusedAnalysisImages,
)
reusedAnalysisImages.forEach((image) => {
if (image.assetId) completedAnalysisAssetIds.add(image.assetId)
})
completedImageCount = reusedAnalysisImages.length
focusAssetId =
reusedAnalysisImages.find((image) => image.assetId)?.assetId ??
analysisImages.find((image) => image.assetId)?.assetId ??
focusAssetId
}
if (event.eventType === 'MODEL_FACT_BATCH_STARTED') {
activeModelIds.clear()
const batchImages = readAnalysisImagePlan(payload?.batchImages)
batchImages.forEach((image) => {
if (image.assetId) activeModelIds.add(image.assetId)
})
modelBatchIndex = readNumber(payload?.batchIndex)
modelBatchCount = readNumber(payload?.totalBatches)
completedImageCount =
readNumber(payload?.completedImageCount) ?? completedImageCount
totalImageCount =
readNumber(payload?.totalImageCount) ?? totalImageCount
focusAssetId =
batchImages.find((image) => image.assetId)?.assetId ?? focusAssetId
}
if (event.eventType === 'MODEL_FACT_BATCH_COMPLETED') {
const batchImages = readAnalysisImagePlan(payload?.batchImages)
batchImages.forEach((image) => {
if (!image.assetId) return
activeModelIds.delete(image.assetId)
completedAnalysisAssetIds.add(image.assetId)
})
modelBatchIndex = readNumber(payload?.batchIndex) ?? modelBatchIndex
modelBatchCount = readNumber(payload?.totalBatches) ?? modelBatchCount
completedImageCount =
readNumber(payload?.completedImageCount) ?? completedImageCount
totalImageCount =
readNumber(payload?.totalImageCount) ?? totalImageCount
focusAssetId =
batchImages.findLast((image) => image.assetId)?.assetId ?? focusAssetId
}
if (
event.eventType === 'MODEL_FACT_EXTRACTION_COMPLETED' ||
event.eventType === 'MODEL_SYNTHESIS_STARTED'
) {
activeModelIds.clear()
analysisImages.forEach((image) => {
if (image.assetId) completedAnalysisAssetIds.add(image.assetId)
})
completedImageCount =
readNumber(payload?.imageCount) ??
totalImageCount ??
completedImageCount
}
if (event.eventType === 'MODEL_ANALYSIS_COMPLETED') {
modelCompletedIndex = index
activeModelIds.clear()
analysisImages.forEach((image) => {
if (image.assetId) completedAnalysisAssetIds.add(image.assetId)
})
}
if (event.eventType === 'PRICE_MATCH_STARTED') priceStartedIndex = index
if (event.eventType === 'RESULT_SAVED') resultSavedIndex = index
@ -199,8 +280,16 @@ export function deriveBomCostingImageActivity(
modelCompletedIndex < modelStartedIndex &&
detail.status === 'FAILED'
if (modelFailed) {
analysisAssetIds.forEach((assetId) => failedAssetIds.add(assetId))
const failedModelIds =
usesFactBatchProtocol && activeModelIds.size > 0
? activeModelIds
: usesFactBatchProtocol
? new Set<string>()
: analysisAssetIds
failedModelIds.forEach((assetId) => failedAssetIds.add(assetId))
}
const modelActiveAssetIds =
usesFactBatchProtocol ? activeModelIds : analysisAssetIds
let phase: BomCostingImageActivity['phase'] = 'SOURCE'
if (detail.status === 'READY' || resultSavedIndex >= 0) phase = 'DONE'
@ -221,11 +310,16 @@ export function deriveBomCostingImageActivity(
assets.find((asset) => asset.status === 'STORED')?.id ??
assets[0]?.id ??
null,
activeAssetIds: modelActive ? analysisAssetIds : activeArchiveIds,
activeAssetIds: modelActive ? modelActiveAssetIds : activeArchiveIds,
analysisAssetIds,
completedAnalysisAssetIds,
failedAssetIds,
latestMessage,
analysisImages,
modelBatchIndex,
modelBatchCount,
completedImageCount,
totalImageCount,
}
}
@ -247,7 +341,8 @@ function assetStatusLabel(
? '分析未返回'
: '归档失败'
}
if (activity.analysisAssetIds.has(asset.id)) return '分析参考'
if (activity.completedAnalysisAssetIds.has(asset.id)) return '事实已提取'
if (activity.analysisAssetIds.has(asset.id)) return '等待分析'
if (asset.status === 'STORED') return '已归档'
if (asset.status === 'FAILED') return '归档失败'
return '等待归档'
@ -273,7 +368,10 @@ function AssetStatusIcon({
if (activity.failedAssetIds.has(asset.id) || asset.status === 'FAILED') {
return <X size={12} strokeWidth={2.2} className="text-[#a34e49]" />
}
if (asset.status === 'STORED') {
if (
activity.completedAnalysisAssetIds.has(asset.id) ||
asset.status === 'STORED'
) {
return <Check size={12} strokeWidth={2.2} className="text-[#4c625f]" />
}
return <span className="h-2 w-2 rounded-full bg-[#b8bfc0]" />
@ -408,7 +506,10 @@ export function BomCostingAssetWorkspace({
</span>
) : null}
</div>
<div className="absolute right-3 top-3 z-[4] flex items-center gap-2 rounded-[5px] bg-white/[0.88] px-2 py-1 text-[10px] font-bold text-[#455256] backdrop-blur-md">
<div
className="absolute right-3 top-3 z-[4] flex items-center gap-2 rounded-[5px] bg-white/[0.88] px-2 py-1 text-[10px] font-bold text-[#455256] backdrop-blur-md"
aria-live="polite"
>
<AssetStatusIcon asset={selectedAsset} activity={activity} />
{assetStatusLabel(selectedAsset, activity)}
</div>
@ -428,7 +529,9 @@ export function BomCostingAssetWorkspace({
<div className="flex shrink-0 items-center gap-1.5 text-[10px] font-bold">
<ScanSearch size={13} />
{activity.phase === 'MODEL'
? `${activity.analysisAssetIds.size} 张联合分析`
? activity.modelBatchIndex && activity.modelBatchCount
? `第 ${activity.modelBatchIndex}/${activity.modelBatchCount} 批 · ${activity.completedImageCount}/${activity.totalImageCount}`
: activity.latestMessage
: activity.latestMessage}
</div>
) : null}

View File

@ -659,4 +659,214 @@ describe('BomCostingWorkbenchClient', () => {
global.fetch = originalFetch
}
})
it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => {
const originalFetch = global.fetch
const now = new Date().toISOString()
const unresolvedLine = {
id: 'line-rubberwood',
externalLineId: 'top-board',
category: 'BOARD',
categoryLabel: '板材',
partName: '橡胶木桌面板',
materialCode: '',
materialName: 'FAS级橡胶木实木板',
materialSpec: '',
processName: '',
packagingKind: '',
lengthMm: 740,
widthMm: 400,
heightMm: null,
quantity: 2,
unit: '块',
weightKg: null,
lossFactor: 1.08,
sourceConfidence: 0.82,
evidence: [],
matchStatus: 'NEEDS_CONFIRMATION',
matchedLibraryType: '',
matchedLibraryRecordId: '',
matchedLibraryName: '',
matchedLibrarySpec: '',
resolvedUnitPrice: null,
calculatedQuantity: 0.592,
subtotal: null,
updatedAt: now,
}
const detail = {
id: 'case-price',
sourceUrl: 'https://example.com/product/price',
sourceHost: 'example.com',
title: '竞品橡胶木茶几',
providerCode: 'market-detail-multimodal',
providerTaskId: 'bom-price-task',
sourceCacheHit: true,
status: 'READY',
progress: 100,
statusMessage: '已完成',
targetErrorPercent: 10,
currency: 'CNY',
createdById: 'user-1',
processingStartedAt: now,
processingFinishedAt: now,
createdAt: now,
updatedAt: now,
sourceSnapshot: null,
skus: [{
id: 'sku-price',
externalSkuId: 'sku-price',
name: '原木色',
structureGroupKey: 'table',
imageUrl: null,
attributes: {},
lengthMm: 740,
widthMm: 400,
heightMm: 620,
weightKg: null,
packageLengthMm: null,
packageWidthMm: null,
packageHeightMm: null,
packageWeightKg: null,
estimatedCost: null,
costCalculatedAt: null,
costSummary: {
material: 0,
processing: 83,
labor: 35,
packaging: 0,
lossIncludedTotal: 118,
unresolvedLineCount: 1,
missingCostGroups: ['PACKAGING'],
},
bomLines: [unresolvedLine],
}],
taskEvents: [],
}
const resolvedDetail = {
...detail,
skus: [{
...detail.skus[0],
estimatedCost: null,
costCalculatedAt: null,
costSummary: {
material: 51.57,
processing: 83,
labor: 35,
packaging: 0,
lossIncludedTotal: 169.57,
unresolvedLineCount: 0,
missingCostGroups: ['PACKAGING'],
},
bomLines: [{
...unresolvedLine,
heightMm: 18,
matchStatus: 'MATCHED',
matchedLibraryType: 'SUBSTRATE',
matchedLibraryRecordId: 'rubberwood-18',
matchedLibraryName: '18ENF级橡胶木',
matchedLibrarySpec: '18mm · ENF',
resolvedUnitPrice: 80.63,
subtotal: 51.57,
}],
}],
}
global.fetch = jest.fn(async (input, init) => {
const url = String(input)
if (url.endsWith('/lines/line-rubberwood') && init?.method === 'PATCH') {
return {
ok: true,
json: async () => ({
success: true,
data: { case: 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 {
ok: true,
json: async () => ({
success: true,
data: { case: detail },
}),
} as Response
}) as jest.Mock
try {
render(
<BomCostingWorkbenchClient
initialCapabilities={{
enabled: true,
analysisConfigured: true,
modelingConfigured: false,
analysisProviderCode: 'market-detail-multimodal',
targetErrorPercent: 10,
fixtureAllowed: false,
}}
initialCases={[{
id: 'case-price',
title: '竞品橡胶木茶几',
sourceUrl: 'https://example.com/product/price',
sourceHost: 'example.com',
status: 'READY',
progress: 100,
statusMessage: '已完成',
sourceCacheHit: true,
storedImageCount: 0,
failedImageCount: 0,
createdById: 'user-1',
createdAt: now,
skuCount: 1,
estimatedCostRange: null,
}]}
/>,
)
expect(await screen.findByText('橡胶木桌面板')).toBeInTheDocument()
expect(screen.getByText('缺板厚')).toBeInTheDocument()
expect(screen.getByText('待匹配')).toBeInTheDocument()
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
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]) =>
String(url).endsWith('/lines/line-rubberwood') &&
init?.method === 'PATCH',
)
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 {
global.fetch = originalFetch
}
})
})

View File

@ -2,6 +2,7 @@
import Image from 'next/image'
import {
Fragment,
useCallback,
useEffect,
useMemo,
@ -35,6 +36,8 @@ import type {
BomCostingCapabilities,
BomCostingCaseDetail,
BomCostingCaseSummary,
BomCostingLineDetail,
BomCostingPriceCandidateDetail,
BomCostingSkuDetail,
} from '@/lib/bom-costing/client-types'
import {
@ -510,10 +513,12 @@ function ResultCanvas({
detail,
selectedSku,
onSelectSku,
onCaseUpdated,
}: {
detail: BomCostingCaseDetail
selectedSku: BomCostingSkuDetail
onSelectSku: (skuId: string) => void
onCaseUpdated: (detail: BomCostingCaseDetail) => void
}) {
const sourceAssets = useMemo(
() =>
@ -547,6 +552,20 @@ function ResultCanvas({
const [selectedAssetIdBySku, setSelectedAssetIdBySku] = useState<
Record<string, string>
>({})
const [editingPriceLineId, setEditingPriceLineId] = useState<string | null>(
null,
)
const [priceCandidatesByLine, setPriceCandidatesByLine] = useState<
Record<string, BomCostingPriceCandidateDetail[]>
>({})
const [priceCandidateQuery, setPriceCandidateQuery] = useState('')
const [loadingPriceLineId, setLoadingPriceLineId] = useState<string | null>(
null,
)
const [savingPriceLineId, setSavingPriceLineId] = useState<string | null>(
null,
)
const [priceEditorError, setPriceEditorError] = useState('')
const assetIdByStoredUrl = new Map<string, string>()
for (const asset of sourceAssets) {
if (asset.storedUrl && !assetIdByStoredUrl.has(asset.storedUrl)) {
@ -591,6 +610,130 @@ function ResultCanvas({
}
}
const openPriceEditor = async (line: BomCostingLineDetail) => {
if (editingPriceLineId === line.id) {
setEditingPriceLineId(null)
setPriceEditorError('')
return
}
setEditingPriceLineId(line.id)
setPriceCandidateQuery('')
setPriceEditorError('')
if (priceCandidatesByLine[line.id]) return
setLoadingPriceLineId(line.id)
try {
const response = await fetch(
`/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`,
{ cache: 'no-store' },
)
const data = await readApiData<{
candidates: BomCostingPriceCandidateDetail[]
}>(response)
setPriceCandidatesByLine((current) => ({
...current,
[line.id]: data.candidates,
}))
} catch (error) {
setPriceEditorError(
error instanceof Error ? error.message : '资料库价格读取失败',
)
} finally {
setLoadingPriceLineId(null)
}
}
const confirmPriceCandidate = async (
line: BomCostingLineDetail,
candidate: BomCostingPriceCandidateDetail,
) => {
if (savingPriceLineId) return
setSavingPriceLineId(line.id)
setPriceEditorError('')
try {
const response = await fetch(
`/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
matchedLibraryRecordId: candidate.recordId,
...(line.category === 'BOARD' &&
line.heightMm === null &&
candidate.thicknessMm !== null
? { heightMm: candidate.thicknessMm }
: {}),
}),
},
)
const data = await readApiData<{ case: BomCostingCaseDetail }>(response)
onCaseUpdated(data.case)
setEditingPriceLineId(null)
} catch (error) {
setPriceEditorError(
error instanceof Error ? error.message : '价格确认失败,请重试',
)
} finally {
setSavingPriceLineId(null)
}
}
const missingCostGroups = new Set(
selectedSku.costSummary.missingCostGroups ?? [],
)
const materialHasUnresolved =
missingCostGroups.has('MATERIAL') ||
selectedSku.bomLines.some(
(line) =>
['BOARD', 'STEEL', 'ACCESSORY'].includes(line.category) &&
line.subtotal === null,
)
const packagingHasUnresolved =
missingCostGroups.has('PACKAGING') ||
selectedSku.bomLines.some(
(line) => line.category === 'PACKAGING' && line.subtotal === null,
)
const missingCostGroupLabels = [
missingCostGroups.has('MATERIAL') ? '材料' : '',
missingCostGroups.has('PROCESSING') ? '加工' : '',
missingCostGroups.has('LABOR') ? '人工' : '',
missingCostGroups.has('PACKAGING') ? '包材' : '',
].filter(Boolean)
const incompleteCostMessage = [
selectedSku.costSummary.unresolvedLineCount > 0
? `${selectedSku.costSummary.unresolvedLineCount} 项价格待确认`
: '',
missingCostGroupLabels.length > 0
? `${missingCostGroupLabels.join('、')}尚未识别`
: '',
].filter(Boolean).join(',')
const costSummaryRows = [
{
label: '材料',
value: selectedSku.costSummary.material,
unresolved: materialHasUnresolved,
missing: missingCostGroups.has('MATERIAL'),
},
{
label: '加工(AI 估算)',
value: selectedSku.costSummary.processing,
unresolved: missingCostGroups.has('PROCESSING'),
missing: missingCostGroups.has('PROCESSING'),
},
{
label: '人工(AI 估算)',
value: selectedSku.costSummary.labor,
unresolved: missingCostGroups.has('LABOR'),
missing: missingCostGroups.has('LABOR'),
},
{
label: '包材',
value: selectedSku.costSummary.packaging,
unresolved: packagingHasUnresolved,
missing: missingCostGroups.has('PACKAGING'),
},
]
return (
<div className="h-full min-h-[580px] overflow-y-auto bg-white">
<div className="mx-auto grid w-full max-w-[1500px] gap-5 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:px-5">
@ -730,63 +873,229 @@ function ResultCanvas({
const evidenceAssetId = evidence?.imageUrl
? assetIdByStoredUrl.get(evidence.imageUrl) ?? null
: null
const canConfirmFromLibrary = [
'BOARD',
'STEEL',
'ACCESSORY',
'PACKAGING',
].includes(line.category)
const editing = editingPriceLineId === line.id
const query = priceCandidateQuery.trim().toLowerCase()
const candidates = priceCandidatesByLine[line.id] ?? []
const visibleCandidates = query
? candidates.filter((candidate) =>
[
candidate.code,
candidate.name,
candidate.spec,
].some((value) =>
value.toLowerCase().includes(query),
),
)
: candidates
return (
<tr key={line.id} className="text-[11px] hover:bg-[#fafbfb]">
<td className="px-4 py-3">
<span className="rounded-[5px] bg-[#edf1f1] px-2 py-1 font-bold text-[#566466]">
{line.categoryLabel}
</span>
</td>
<td className="px-3 py-3">
<div className="font-semibold text-[#353b3d]">
{line.partName}
</div>
<div className="mt-0.5 text-[10px] text-[#878d8f]">
{[line.materialName, line.materialSpec, line.processName]
.filter(Boolean)
.join(' · ') || '--'}
</div>
{evidence?.note ? (
<div className="mt-1 flex max-w-[560px] items-start gap-1.5 text-[9px] leading-4 text-[#758082]">
<ScanSearch size={11} className="mt-0.5 shrink-0" />
<span className="line-clamp-2">{evidence.note}</span>
{evidenceAssetId ? (
<Fragment key={line.id}>
<tr className="text-[11px] hover:bg-[#fafbfb]">
<td className="px-4 py-3">
<span className="rounded-[5px] bg-[#edf1f1] px-2 py-1 font-bold text-[#566466]">
{line.categoryLabel}
</span>
</td>
<td className="px-3 py-3">
<div className="font-semibold text-[#353b3d]">
{line.partName}
</div>
<div className="mt-0.5 text-[10px] text-[#878d8f]">
{[line.materialName, line.materialSpec, line.processName]
.filter(Boolean)
.join(' · ') || '--'}
</div>
{evidence?.note ? (
<div className="mt-1 flex max-w-[560px] items-start gap-1.5 text-[9px] leading-4 text-[#758082]">
<ScanSearch size={11} className="mt-0.5 shrink-0" />
<span className="line-clamp-2">{evidence.note}</span>
{evidenceAssetId ? (
<button
type="button"
onClick={() => selectEvidenceAsset(evidenceAssetId)}
className="shrink-0 rounded-[4px] px-1.5 py-0.5 font-bold text-[#4d6267] hover:bg-[#edf1f1]"
>
查看图像
</button>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-3 text-[#687174]">
{[line.lengthMm, line.widthMm, line.heightMm]
.filter((value) => value !== null)
.join(' × ') || '--'}
{line.category === 'BOARD' && line.heightMm === null ? (
<div className="mt-1 text-[9px] font-semibold text-[#9a6941]">
缺板厚
</div>
) : null}
</td>
<td className="px-3 py-3">
{line.quantity} {line.unit}
</td>
<td className="px-3 py-3">
{line.category === 'PROCESSING' ||
line.category === 'LABOR' ? (
<span className="font-semibold text-[#667274]">
{line.resolvedUnitPrice === null
? 'AI 估算待补'
: 'AI 估算'}
</span>
) : canConfirmFromLibrary ? (
<button
type="button"
onClick={() => void openPriceEditor(line)}
aria-expanded={editing}
className={cn(
'flex max-w-full items-center gap-1 rounded-[4px] px-1.5 py-1 text-left font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-[#40585d]',
line.matchStatus === 'MATCHED'
? 'text-[#4c625f] hover:bg-[#edf1f1]'
: 'text-[#8a5f3f] hover:bg-[#f5eee8]',
)}
>
<span className="truncate">
{line.matchStatus === 'MATCHED'
? line.matchedLibraryName
: line.matchStatus === 'NEEDS_CONFIRMATION'
? '确认价格'
: '匹配价格'}
</span>
<ChevronDown
size={12}
className={cn(
'shrink-0 transition-transform',
editing && 'rotate-180',
)}
/>
</button>
) : null}
{line.resolvedUnitPrice !== null ? (
<div className="mt-0.5 text-[9px] text-[#8a9092]">
{formatMoney(line.resolvedUnitPrice)}/{line.resolvedPriceUnit || line.unit}
</div>
) : null}
</td>
<td className="px-3 py-3 text-right font-bold">
{formatMoney(line.subtotal)}
</td>
</tr>
{editing ? (
<tr className="bg-[#f7f9f8]">
<td colSpan={6} className="px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-[11px] font-bold">
确认资料库价格
</div>
<div className="mt-0.5 text-[9px] text-[#727b7d]">
{line.category === 'PACKAGING'
? '价格只来自资产中心的可用包材规格款'
: line.category === 'BOARD' && line.heightMm === null
? '材种已识别,请确认板厚与对应基材'
: '选择与当前部件规格一致的资料库记录'}
</div>
</div>
{candidates.length > 6 ? (
<Input
value={priceCandidateQuery}
onChange={(event) =>
setPriceCandidateQuery(event.target.value)
}
placeholder="搜索名称、编码或规格"
aria-label="搜索价格候选"
leftIcon={<Search size={13} />}
containerClassName="w-[240px] !space-y-0"
className="!min-h-9 !rounded-[5px] !bg-white !py-1.5 text-[10px]"
/>
) : null}
</div>
{loadingPriceLineId === line.id ? (
<div className="mt-3 flex h-16 items-center gap-2 text-[10px] text-[#687375]">
<span className="h-2 w-2 animate-pulse rounded-full bg-[#536b70] motion-reduce:animate-none" />
正在读取资料库价格
</div>
) : priceEditorError ? (
<div className="mt-3 text-[10px] font-semibold text-[#a14f49]">
{priceEditorError}
</div>
) : candidates.length === 0 ? (
<div className="mt-3 flex h-16 items-center text-[10px] font-semibold text-[#8a5f3f]">
{line.category === 'PACKAGING'
? '资产中心暂无已启用且有价格的包材规格款'
: '基础资料库暂无可用于当前分类的价格'}
</div>
) : visibleCandidates.length === 0 ? (
<div className="mt-3 flex h-16 items-center text-[10px] text-[#747d7f]">
没有匹配的资料库记录
</div>
) : (
<div className="mt-3 grid max-h-[220px] grid-cols-2 gap-1.5 overflow-y-auto pr-1">
{visibleCandidates.map((candidate) => (
<button
key={candidate.recordId}
type="button"
disabled={savingPriceLineId === line.id}
onClick={() =>
void confirmPriceCandidate(line, candidate)
}
className={cn(
'flex min-w-0 items-center gap-3 rounded-[5px] border bg-white px-3 py-2 text-left outline-none transition hover:border-[#60777c]/50 focus-visible:ring-2 focus-visible:ring-[#40585d] disabled:cursor-wait disabled:opacity-60',
candidate.recommended
? 'border-[#60777c]/35'
: 'border-black/[0.07]',
)}
>
<div className="min-w-0 flex-1">
<div className="truncate text-[10px] font-bold">
{candidate.name}
</div>
<div className="mt-0.5 truncate text-[9px] text-[#7a8385]">
{[candidate.code, candidate.spec]
.filter(Boolean)
.join(' · ')}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] font-bold tabular-nums">
{formatMoney(candidate.unitPrice)}
</div>
<div className="text-[8px] text-[#899092]">
/{candidate.unit}
</div>
</div>
{candidate.recommended ? (
<span className="shrink-0 rounded-[3px] bg-[#e5eeeb] px-1.5 py-0.5 text-[8px] font-bold text-[#4e6662]">
建议
</span>
) : null}
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => selectEvidenceAsset(evidenceAssetId)}
className="shrink-0 rounded-[4px] px-1.5 py-0.5 font-bold text-[#4d6267] hover:bg-[#edf1f1]"
onClick={() => setEditingPriceLineId(null)}
aria-label="关闭价格确认"
title="关闭"
className="shrink-0 rounded-[5px] p-1.5 text-[#737d7f] hover:bg-black/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#40585d]"
>
查看图像
<X size={14} />
</button>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-3 text-[#687174]">
{[line.lengthMm, line.widthMm, line.heightMm]
.filter((value) => value !== null)
.join(' × ') || '--'}
</td>
<td className="px-3 py-3">
{line.quantity} {line.unit}
</td>
<td className="px-3 py-3">
{line.matchStatus === 'MATCHED' ? (
<span className="text-[#4c625f]">
{line.matchedLibraryName || '分析估算'}
</span>
) : (
<span className="text-[#9a6941]">
{line.matchStatus === 'NEEDS_CONFIRMATION'
? '存在歧义'
: '待确认'}
</span>
)}
</td>
<td className="px-3 py-3 text-right font-bold">
{formatMoney(line.subtotal)}
</td>
</tr>
</div>
</td>
</tr>
) : null}
</Fragment>
)
})}
</tbody>
@ -818,32 +1127,46 @@ function ResultCanvas({
<section className="mt-5 bg-[#2d3335] px-4 py-4 text-white">
<p className="text-[10px] font-semibold text-white/70">预计出厂成本</p>
<div className="mt-1 text-[30px] font-black leading-none tabular-nums">
{formatMoney(selectedSku.estimatedCost)}
{selectedSku.estimatedCost === null
? '待确认'
: formatMoney(selectedSku.estimatedCost)}
</div>
<p className="mt-2 text-[10px] leading-4 text-white/68">
含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用
{selectedSku.estimatedCost === null
? `${incompleteCostMessage || '成本资料尚未完整'},完整后计算总价`
: '含材料、加工、人工、包材和损耗;不含税费、物流、平台佣金及营销费用'}
</p>
</section>
<section className="border-b border-black/[0.07] py-4">
<div className="grid grid-cols-2 gap-x-5 gap-y-2 text-[10px]">
{[
['材料', selectedSku.costSummary.material],
['加工', selectedSku.costSummary.processing],
['人工', selectedSku.costSummary.labor],
['包材', selectedSku.costSummary.packaging],
].map(([label, value]) => (
<div key={String(label)} className="flex items-center justify-between gap-2">
<span className="text-[#747c7e]">{label}</span>
<span className="font-bold">{formatMoney(value as number | null)}</span>
{costSummaryRows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-2">
<span className="text-[#747c7e]">{row.label}</span>
<span
className={cn(
'font-bold',
row.unresolved && 'text-[#8a5f3f]',
)}
>
{row.missing
? '未识别'
: row.unresolved
? '待匹配'
: formatMoney(row.value)}
</span>
</div>
))}
</div>
{selectedSku.costSummary.unresolvedLineCount > 0 ? (
{incompleteCostMessage ? (
<p className="mt-3 text-[10px] font-semibold text-[#9a6941]">
{selectedSku.costSummary.unresolvedLineCount} 项价格待确认
{incompleteCostMessage}
</p>
) : null}
) : (
<p className="mt-3 text-[9px] leading-4 text-[#7a8385]">
误差 ≤{detail.targetErrorPercent}% 是核算目标,需经真实采购成本回测验证
</p>
)}
</section>
<section className="border-b border-black/[0.07] py-4">
@ -1211,7 +1534,7 @@ export default function BomCostingWorkbenchClient({
</span>
</div>
<p className="mt-0.5 truncate text-[10px] text-[#747c7e]">
目标误差 ≤{initialCapabilities.targetErrorPercent}%
目标误差 ≤{initialCapabilities.targetErrorPercent}% · 待回测
</p>
</div>
</div>
@ -1430,6 +1753,7 @@ export default function BomCostingWorkbenchClient({
detail={selectedCase}
selectedSku={selectedSku}
onSelectSku={setSelectedSkuId}
onCaseUpdated={applyCaseDetail}
/>
) : hasCases ? (
<div className="flex min-h-[320px] items-center justify-center px-6 py-12 xl:h-full xl:min-h-0">

View File

@ -2,6 +2,10 @@
import html2canvas from "html2canvas";
import QRCode from "qrcode";
import {
GUJIA_CERTIFICATE_CANVAS,
getGujiaCertificateFieldStyle,
} from "./barcodePrintGujiaCertificateLayout";
import type { BarcodePrintRow } from "./barcodePrintRows";
type CertificateTemplate = {
@ -428,7 +432,12 @@ function renderGujiaCertificate(
_options: CertificatePrintOptions,
backgroundDataUrl: string,
) {
const container = createCertificateMain("certificate_main_gj", backgroundDataUrl, 1323, 1559);
const container = createCertificateMain(
"certificate_main_gj",
backgroundDataUrl,
GUJIA_CERTIFICATE_CANVAS.width,
GUJIA_CERTIFICATE_CANVAS.height,
);
[
getProductLabel(record),
record.brandProcode || record.procode,
@ -440,14 +449,12 @@ function renderGujiaCertificate(
getLast10DayText(),
"",
].forEach((text, index) => {
appendItem(container, `item_${index + 1} item`, text, {
position: "absolute",
fontSize: "42px",
color: "#000000",
lineHeight: "48px",
left: "455px",
top: `${530 + index * 101}px`,
});
appendItem(
container,
`item_${index + 1} item`,
text,
getGujiaCertificateFieldStyle(index),
);
});
return container;

View File

@ -0,0 +1,33 @@
import {
GUJIA_CERTIFICATE_CANVAS,
getGujiaCertificateFieldStyle,
} from "./barcodePrintGujiaCertificateLayout";
describe("Gujia certificate layout", () => {
it("centers every value inside the preprinted field area", () => {
for (let index = 0; index < 8; index += 1) {
expect(getGujiaCertificateFieldStyle(index)).toEqual(
expect.objectContaining({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "700px",
height: "48px",
lineHeight: "1",
textAlign: "center",
left: "455px",
}),
);
}
});
it("raises only the product name while preserving the remaining row spacing", () => {
expect(getGujiaCertificateFieldStyle(0).top).toBe("520px");
expect(getGujiaCertificateFieldStyle(1).top).toBe("631px");
expect(getGujiaCertificateFieldStyle(2).top).toBe("732px");
});
it("keeps the original Gujia canvas dimensions", () => {
expect(GUJIA_CERTIFICATE_CANVAS).toEqual({ width: 1323, height: 1559 });
});
});

View File

@ -0,0 +1,35 @@
export const GUJIA_CERTIFICATE_CANVAS = {
width: 1323,
height: 1559,
} as const;
const GUJIA_FIELD_LEFT = 455;
const GUJIA_FIELD_WIDTH = 700;
const GUJIA_FIELD_TOP = 530;
const GUJIA_FIELD_HEIGHT = 48;
const GUJIA_FIELD_ROW_GAP = 101;
const GUJIA_PRODUCT_NAME_TOP_OFFSET = -10;
export function getGujiaCertificateFieldStyle(
index: number,
): Partial<CSSStyleDeclaration> {
const top =
GUJIA_FIELD_TOP +
index * GUJIA_FIELD_ROW_GAP +
(index === 0 ? GUJIA_PRODUCT_NAME_TOP_OFFSET : 0);
return {
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: `${GUJIA_FIELD_WIDTH}px`,
height: `${GUJIA_FIELD_HEIGHT}px`,
fontSize: "42px",
color: "#000000",
lineHeight: "1",
textAlign: "center",
left: `${GUJIA_FIELD_LEFT}px`,
top: `${top}px`,
};
}

View File

@ -91,11 +91,24 @@ export interface BomCostingLineDetail {
matchedLibraryName: string
matchedLibrarySpec: string
resolvedUnitPrice: number | null
resolvedPriceUnit?: string
calculatedQuantity: number | null
subtotal: number | null
updatedAt: string
}
export interface BomCostingPriceCandidateDetail {
recordId: string
libraryType: string
code: string
name: string
spec: string
unit: string
unitPrice: number
thicknessMm: number | null
recommended: boolean
}
export interface BomCostingSkuDetail {
id: string
externalSkuId: string
@ -121,6 +134,7 @@ export interface BomCostingSkuDetail {
packaging: number
lossIncludedTotal: number
unresolvedLineCount: number
missingCostGroups?: Array<'MATERIAL' | 'PROCESSING' | 'LABOR' | 'PACKAGING'>
}
}

View File

@ -114,4 +114,47 @@ describe('CompetitorAnalysisResultV1', () => {
product: { ...base.product, imageUrls: ['/uploads/untrusted.webp'] },
}).success).toBe(false)
})
it('结构经验可以进入估算,但不能伪装成高置信度事实', () => {
const base = {
schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
sourceUrl: 'https://example.com/product/1',
product: { title: '带抽屉收纳柜', imageUrls: [] },
skus: [{
externalSkuId: 'sku-1',
name: '基础款',
attributes: {},
bomLines: [{
externalLineId: 'drawer-slide',
category: 'ACCESSORY',
partName: '抽屉导向机构',
quantity: 1,
unit: '套',
evidence: [{
basis: 'STRUCTURAL_INFERENCE',
note: '抽屉活动需要导向机构,具体类型和长度待确认',
confidence: 0.5,
}],
confidence: 0.5,
}],
}],
analyzedAt: new Date().toISOString(),
}
expect(competitorAnalysisResultV1Schema.safeParse(base).success).toBe(true)
expect(competitorAnalysisResultV1Schema.safeParse({
...base,
skus: [{
...base.skus[0],
bomLines: [{
...base.skus[0].bomLines[0],
confidence: 0.8,
evidence: [{
...base.skus[0].bomLines[0].evidence[0],
confidence: 0.8,
}],
}],
}],
}).success).toBe(false)
})
})

View File

@ -18,7 +18,7 @@ export type BomCostingLineCategoryValue = z.infer<typeof bomCostingLineCategoryS
const positiveNumberSchema = z.number().finite().positive()
const optionalPositiveNumberSchema = positiveNumberSchema.optional()
const confidenceSchema = z.number().finite().min(0).max(1)
const competitorResourceUrlSchema = z.string().refine((value) => {
export const competitorResourceUrlSchema = z.string().refine((value) => {
if (z.url().safeParse(value).success) return true
if (!value.startsWith('/api/storage/object?')) return false
const parsed = new URL(value, 'https://bom-costing.local')
@ -30,6 +30,12 @@ const competitorResourceUrlSchema = z.string().refine((value) => {
export const competitorEvidenceSchema = z.object({
sourceUrl: z.url().optional(),
imageUrl: competitorResourceUrlSchema.optional(),
basis: z.enum([
'IMAGE',
'SOURCE_TEXT',
'SKU_METADATA',
'STRUCTURAL_INFERENCE',
]).optional(),
note: z.string().trim().max(1_000).optional(),
confidence: confidenceSchema,
})
@ -53,6 +59,29 @@ export const competitorBomLineSchema = z.object({
suggestedLossFactor: z.number().finite().min(1).max(3).optional(),
evidence: z.array(competitorEvidenceSchema).max(20).default([]),
confidence: confidenceSchema,
}).superRefine((line, context) => {
const inferredEvidence = line.evidence.filter(
(evidence) => evidence.basis === 'STRUCTURAL_INFERENCE',
)
inferredEvidence.forEach((evidence, index) => {
if (evidence.confidence > 0.55) {
context.addIssue({
code: 'custom',
path: ['evidence', index, 'confidence'],
message: '结构推断证据的置信度不能高于 0.55',
})
}
})
const onlyStructuralInference =
inferredEvidence.length > 0 &&
inferredEvidence.length === line.evidence.length
if (onlyStructuralInference && line.confidence > 0.55) {
context.addIssue({
code: 'custom',
path: ['confidence'],
message: '仅由结构推断支持的 BOM 行置信度不能高于 0.55',
})
}
})
export const competitorSkuSchema = z.object({

View File

@ -62,6 +62,23 @@ describe('BOM 核算计算内核', () => {
).toBeNull()
})
it('资产中心包材使用规格款单件成本,不再把单件价重复乘面积', () => {
expect(
calculateBomCostingLine({
category: 'PACKAGING',
packagingKind: 'CARTON',
pricingMode: 'UNIT',
lengthMm: 900,
widthMm: 800,
quantity: 2,
unitPrice: 12.5,
}),
).toMatchObject({
pricingQuantity: 2,
subtotal: 25,
})
})
it('汇总出厂成本并单独标记待确认行', () => {
expect(
summarizeBomCosting([
@ -79,6 +96,21 @@ describe('BOM 核算计算内核', () => {
packaging: 4,
lossIncludedTotal: 47,
unresolvedLineCount: 1,
missingCostGroups: [],
})
})
it('缺少成本组时不能把零元当作完整结果', () => {
expect(
summarizeBomCosting([
{ category: 'BOARD', subtotal: 30 },
{ category: 'PROCESSING', subtotal: 8 },
{ category: 'LABOR', subtotal: 5 },
]),
).toMatchObject({
packaging: 0,
unresolvedLineCount: 0,
missingCostGroups: ['PACKAGING'],
})
})
})

View File

@ -3,14 +3,19 @@ import type { BomCostingLineCategoryValue } from './contracts'
export type BomCostingPackagingKind =
| 'FOAM'
| 'FOAM_CORNER'
| 'EPE'
| 'PAPER_CORNER'
| 'PACKING_STRAP'
| 'CARTON'
| 'PE_BAG'
| 'STRETCH_FILM'
| 'ADHESIVE_TAPE'
| 'OTHER'
export interface BomCostingQuantityInput {
category: BomCostingLineCategoryValue
packagingKind?: BomCostingPackagingKind | null
pricingMode?: 'AREA_SQM' | 'LENGTH_M' | 'VOLUME_CBM' | 'UNIT' | null
lengthMm?: number | null
widthMm?: number | null
heightMm?: number | null
@ -34,8 +39,15 @@ export interface BomCostingCostSummary {
packaging: number
lossIncludedTotal: number
unresolvedLineCount: number
missingCostGroups: BomCostingCostGroup[]
}
export type BomCostingCostGroup =
| 'MATERIAL'
| 'PROCESSING'
| 'LABOR'
| 'PACKAGING'
function finitePositive(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
@ -52,6 +64,35 @@ export function resolveBomCostingQuantity(input: BomCostingQuantityInput) {
const widthMm = finitePositive(input.widthMm)
const heightMm = finitePositive(input.heightMm)
if (input.pricingMode === 'UNIT') {
return quantity
}
if (
input.pricingMode === 'AREA_SQM' &&
lengthMm !== null &&
widthMm !== null &&
quantity !== null
) {
return (lengthMm * widthMm * quantity) / 1_000_000
}
if (
input.pricingMode === 'LENGTH_M' &&
lengthMm !== null &&
quantity !== null
) {
const normalizedLengthMeters = lengthMm > 10 ? lengthMm / 1_000 : lengthMm
return normalizedLengthMeters * quantity
}
if (
input.pricingMode === 'VOLUME_CBM' &&
lengthMm !== null &&
widthMm !== null &&
heightMm !== null &&
quantity !== null
) {
return (lengthMm * widthMm * heightMm * quantity) / 1_000_000_000
}
if (input.category === 'BOARD') {
if (lengthMm !== null && widthMm !== null && quantity !== null) {
return (lengthMm * widthMm * quantity) / 1_000_000
@ -129,9 +170,20 @@ export function summarizeBomCosting(lines: BomCostingSummaryLine[]): BomCostingC
packaging: 0,
lossIncludedTotal: 0,
unresolvedLineCount: 0,
missingCostGroups: [],
}
const presentGroups = new Set<BomCostingCostGroup>()
for (const line of lines) {
if (
line.category === 'BOARD' ||
line.category === 'STEEL' ||
line.category === 'ACCESSORY'
) {
presentGroups.add('MATERIAL')
} else {
presentGroups.add(line.category)
}
if (line.subtotal === null || !Number.isFinite(line.subtotal)) {
result.unresolvedLineCount += 1
continue
@ -149,6 +201,10 @@ export function summarizeBomCosting(lines: BomCostingSummaryLine[]): BomCostingC
}
}
result.missingCostGroups = (
['MATERIAL', 'PROCESSING', 'LABOR', 'PACKAGING'] as const
).filter((group) => !presentGroups.has(group))
result.material = roundBomCostMoney(result.material)
result.processing = roundBomCostMoney(result.processing)
result.labor = roundBomCostMoney(result.labor)

View File

@ -0,0 +1,63 @@
import { z } from 'zod'
import { competitorResourceUrlSchema } from './contracts'
export const BOM_COSTING_FACT_SCHEMA_VERSION = 'competitor-facts/v1' as const
export const BOM_COSTING_FACT_ANALYSIS_STATUS = {
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
READY: 'READY',
FAILED: 'FAILED',
} as const
export const bomCostingImageFactKindSchema = z.enum([
'DIMENSION',
'MATERIAL',
'PART',
'STRUCTURE',
'MECHANISM',
'HARDWARE',
'PACKAGING',
'PROCESS',
'WEIGHT',
'SKU_DIFFERENCE',
'TEXT',
])
export const bomCostingExtractedFactSchema = z.object({
factId: z.string().trim().min(1).max(160),
kind: bomCostingImageFactKindSchema,
subject: z.string().trim().min(1).max(240),
value: z.string().trim().min(1).max(1_000),
numericValue: z.number().finite().optional(),
unit: z.string().trim().max(40).optional(),
relatedSkuIds: z.array(z.string().trim().min(1).max(160)).max(200).default([]),
basis: z.enum(['IMAGE', 'SOURCE_TEXT']),
note: z.string().trim().min(1).max(1_000),
confidence: z.number().finite().min(0).max(1),
})
export const bomCostingImageFactRecordSchema = z.object({
imageId: z.string().trim().min(1).max(80),
sourceImageUrl: competitorResourceUrlSchema,
kind: z.enum(['MAIN', 'SKU', 'DETAIL']),
label: z.string().trim().min(1).max(240),
externalSkuIds: z.array(z.string().trim().min(1).max(160)).max(200).default([]),
observedTexts: z.array(z.string().trim().min(1).max(1_000)).max(100).default([]),
facts: z.array(bomCostingExtractedFactSchema).max(60).default([]),
})
export const bomCostingSourceFactSnapshotSchema = z.object({
schemaVersion: z.literal(BOM_COSTING_FACT_SCHEMA_VERSION),
imageFacts: z.array(bomCostingImageFactRecordSchema).max(5_000),
extractedAt: z.iso.datetime(),
})
export type BomCostingExtractedFact = z.infer<
typeof bomCostingExtractedFactSchema
>
export type BomCostingImageFactRecord = z.infer<
typeof bomCostingImageFactRecordSchema
>
export type BomCostingSourceFactSnapshot = z.infer<
typeof bomCostingSourceFactSnapshotSchema
>

View File

@ -0,0 +1,8 @@
export function buildBomCostingImageIdentity(value: string) {
try {
const parsed = new URL(value)
return `${parsed.origin}${parsed.pathname}`
} catch {
return value
}
}

View File

@ -0,0 +1,80 @@
import { buildBomCostingPackagingCandidate } from './packaging-candidate'
describe('BOM 资产中心包材候选', () => {
it('使用规格款成本价并保留结构化包材类型', () => {
expect(
buildBomCostingPackagingCandidate({
id: 'carton-1',
code: 'PKG-CARTON-1',
name: '五层瓦楞纸箱',
sizeLabel: '900×800×200',
businessData: {
templateType: 'carton',
materialType: '五层瓦楞纸',
templateFields: {
pricingLengthMm: '1800',
pricingWidthMm: '1200',
squareMeterPrice: '4',
},
},
dimensions: {},
costPrice: 12.5,
estimatedCostPrice: 10,
familyName: '外箱',
familyMetadata: null,
}),
).toMatchObject({
libraryType: 'ASSET_PACKAGING_VARIANT',
recordId: 'carton-1',
packagingKind: 'CARTON',
pricingMode: 'UNIT',
unitPrice: 12.5,
})
})
it('没有直接成本价时使用资产中心模板公式,缺价格则不进入候选', () => {
expect(
buildBomCostingPackagingCandidate({
id: 'estimate-1',
code: 'PKG-ESTIMATE-1',
name: '估算价纸箱',
businessData: { templateType: 'carton', templateFields: {} },
dimensions: {},
costPrice: 0,
estimatedCostPrice: 9.8,
familyName: '外箱',
}),
).toMatchObject({ unitPrice: 9.8 })
expect(
buildBomCostingPackagingCandidate({
id: 'epe-1',
code: 'PKG-EPE-1',
name: 'EPE 片材',
businessData: {
templateType: 'epeSheet',
templateFields: {
lengthMm: '1000',
widthMm: '500',
thicknessMm: '20',
pricePerCubicMeter: '600',
},
},
dimensions: {},
familyName: '缓冲防护',
}),
).toMatchObject({
packagingKind: 'EPE',
unitPrice: 6,
})
expect(
buildBomCostingPackagingCandidate({
id: 'empty-1',
code: 'PKG-EMPTY-1',
name: '未定价包材',
businessData: { templateType: 'peBag', templateFields: {} },
dimensions: {},
familyName: '外包装',
}),
).toBeNull()
})
})

View File

@ -0,0 +1,154 @@
import {
PACKAGING_TEMPLATE_MAP,
type PackagingTemplateKey,
} from '@/lib/packaging-materials/catalog'
import { computePackagingMaterialCost } from '@/lib/packaging-materials/pricing'
import type { BomCostingLibraryCandidate } from './price-matcher'
interface AssetPackagingCandidateSource {
id: string
code: string
name: string
sizeLabel?: string | null
businessData?: unknown
dimensions?: unknown
costPrice?: number | null
estimatedCostPrice?: number | null
familyName: string
familyMetadata?: unknown
}
function readRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
function readTemplateType(
businessDataValue: unknown,
familyMetadataValue: unknown,
): PackagingTemplateKey | null {
const businessData = readRecord(businessDataValue)
const familyMetadata = readRecord(familyMetadataValue)
for (const candidate of [
businessData.templateType,
familyMetadata.categoryLevel2,
]) {
if (
typeof candidate === 'string' &&
PACKAGING_TEMPLATE_MAP.has(candidate as PackagingTemplateKey)
) {
return candidate as PackagingTemplateKey
}
}
return null
}
function readTemplateFields(value: unknown): Record<string, string> {
const rawFields = readRecord(value).templateFields
if (!rawFields || typeof rawFields !== 'object' || Array.isArray(rawFields)) {
return {}
}
return Object.fromEntries(
Object.entries(rawFields).flatMap(([key, item]) =>
typeof item === 'string' || typeof item === 'number'
? [[key, String(item)]]
: [],
),
)
}
function readDimensions(
value: unknown,
): Record<string, string | number | null | undefined> {
return Object.fromEntries(
Object.entries(readRecord(value)).flatMap(([key, item]) =>
typeof item === 'string' || typeof item === 'number'
? [[key, item]]
: [],
),
)
}
function readPositivePrice(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: null
}
function mapPackagingKind(
templateType: PackagingTemplateKey | null,
): string {
switch (templateType) {
case 'carton':
return 'CARTON'
case 'foamVolume':
return 'FOAM'
case 'epeProfile':
case 'epeSheet':
return 'EPE'
case 'paperCorner':
return 'PAPER_CORNER'
case 'strappingBand':
return 'PACKING_STRAP'
case 'peBag':
return 'PE_BAG'
case 'stretchFilm':
return 'STRETCH_FILM'
case 'adhesiveTape':
return 'ADHESIVE_TAPE'
default:
return 'OTHER'
}
}
export function buildBomCostingPackagingCandidate(
source: AssetPackagingCandidateSource,
): BomCostingLibraryCandidate | null {
const templateType = readTemplateType(
source.businessData,
source.familyMetadata,
)
const templateFields = readTemplateFields(source.businessData)
const calculatedCost = templateType
? computePackagingMaterialCost(
templateType,
templateFields,
readDimensions(source.dimensions),
)
: null
const unitPrice =
readPositivePrice(source.costPrice) ??
readPositivePrice(source.estimatedCostPrice) ??
readPositivePrice(calculatedCost)
if (unitPrice === null) return null
const businessData = readRecord(source.businessData)
const materialType =
typeof businessData.materialType === 'string'
? businessData.materialType.trim()
: ''
const template = templateType
? PACKAGING_TEMPLATE_MAP.get(templateType)
: null
return {
libraryType: 'ASSET_PACKAGING_VARIANT',
recordId: source.id,
code: source.code,
name: source.name,
aliases: [
source.familyName,
template?.label ?? '',
materialType,
].filter(Boolean),
spec: [
template?.label,
materialType,
source.sizeLabel,
].filter(Boolean).join(' · '),
unit: '件',
unitPrice,
packagingKind: mapPackagingKind(templateType),
pricingMode: 'UNIT',
}
}

View File

@ -12,6 +12,8 @@ const candidates: BomCostingLibraryCandidate[] = [
spec: '18mm',
unit: '㎡',
unitPrice: 21,
materialFamily: '刨花板',
thicknessMm: 18,
},
{
libraryType: 'SUBSTRATE',
@ -21,6 +23,8 @@ const candidates: BomCostingLibraryCandidate[] = [
spec: '25mm',
unit: '㎡',
unitPrice: 29,
materialFamily: '刨花板',
thicknessMm: 25,
},
]
@ -45,4 +49,77 @@ describe('BOM 资料库价格匹配', () => {
}),
).toMatchObject({ status: 'MATCHED', candidate: { recordId: 'board-1' } })
})
it('按材质族识别外部描述,但缺少厚度时保持待确认', () => {
const rubberwoodCandidates: BomCostingLibraryCandidate[] = [
{
...candidates[0],
recordId: 'rubberwood-18',
code: 'RW-18',
name: '18ENF级橡胶木',
materialFamily: '橡胶木',
aliases: ['ENF级橡胶木'],
},
{
...candidates[1],
recordId: 'rubberwood-25',
code: 'RW-25',
name: '25ENF级橡胶木',
materialFamily: '橡胶木',
aliases: ['ENF级橡胶木'],
},
]
expect(
matchBomCostingLibraryCandidate(rubberwoodCandidates, {
name: 'FAS级橡胶木实木板',
}),
).toMatchObject({ status: 'NEEDS_CONFIRMATION' })
expect(
matchBomCostingLibraryCandidate(rubberwoodCandidates, {
name: 'FAS级橡胶木实木板',
thicknessMm: 18,
}),
).toMatchObject({
status: 'MATCHED',
candidate: { recordId: 'rubberwood-18' },
})
})
it('包材按结构化类型收窄候选,不用模糊名称猜价格', () => {
const packagingCandidates: BomCostingLibraryCandidate[] = [
{
libraryType: 'ASSET_PACKAGING_VARIANT',
recordId: 'carton-1',
code: 'PKG-CARTON-1',
name: '五层瓦楞纸箱',
spec: '纸箱',
unit: '件',
unitPrice: 12,
packagingKind: 'CARTON',
pricingMode: 'UNIT',
},
{
libraryType: 'ASSET_PACKAGING_VARIANT',
recordId: 'foam-1',
code: 'PKG-FOAM-1',
name: 'EPS 泡沫',
spec: '泡沫',
unit: '件',
unitPrice: 8,
packagingKind: 'FOAM',
pricingMode: 'UNIT',
},
]
expect(
matchBomCostingLibraryCandidate(packagingCandidates, {
name: '外箱',
packagingKind: 'CARTON',
}),
).toMatchObject({
status: 'MATCHED',
candidate: { recordId: 'carton-1' },
})
})
})

View File

@ -6,6 +6,11 @@ export interface BomCostingLibraryCandidate {
spec: string
unit: string
unitPrice: number
aliases?: string[]
materialFamily?: string
thicknessMm?: number | null
packagingKind?: string | null
pricingMode?: BomCostingPricingMode
}
export interface BomCostingLibraryLookup {
@ -13,8 +18,16 @@ export interface BomCostingLibraryLookup {
code?: string | null
name?: string | null
spec?: string | null
thicknessMm?: number | null
packagingKind?: string | null
}
export type BomCostingPricingMode =
| 'AREA_SQM'
| 'LENGTH_M'
| 'VOLUME_CBM'
| 'UNIT'
export type BomCostingLibraryMatchResult =
| { status: 'MATCHED'; candidate: BomCostingLibraryCandidate }
| { status: 'NEEDS_CONFIRMATION'; candidates: BomCostingLibraryCandidate[] }
@ -28,6 +41,24 @@ export function normalizeBomCostingIdentity(value?: string | null) {
.replace(/\s+/g, '')
}
export function normalizeBomCostingMaterialFamily(value?: string | null) {
return normalizeBomCostingIdentity(value)
.replace(/\d+(?:\.\d+)?(?:mm|毫米)/g, '')
.replace(/(?:enf|e0|e1|fas|fsc|carbp2|carb)/g, '')
.replace(/(?:环保)?等级?|级/g, '')
.replace(/(?:实木台面板?|实木板材?|实木板|基材|板材|木板)$/g, '')
.replace(/^厚度\d+(?:\.\d+)?/g, '')
}
export function readBomCostingThicknessMm(
value?: string | null,
): number | null {
const match = value?.match(/(\d+(?:\.\d+)?)\s*(?:mm|毫米)/i)
if (!match) return null
const parsed = Number(match[1])
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function resolveCandidates(candidates: BomCostingLibraryCandidate[]) {
if (candidates.length === 1) {
return { status: 'MATCHED' as const, candidate: candidates[0] }
@ -61,17 +92,63 @@ export function matchBomCostingLibraryCandidate(
}
const name = normalizeBomCostingIdentity(lookup.name)
if (!name) return { status: 'UNMATCHED', candidates: [] }
const spec = normalizeBomCostingIdentity(lookup.spec)
const namedCandidates = candidates.filter(
(candidate) => normalizeBomCostingIdentity(candidate.name) === name,
)
const candidatesWithSpec = spec
? namedCandidates.filter(
(candidate) => normalizeBomCostingIdentity(candidate.spec) === spec,
)
: namedCandidates
if (name) {
const namedCandidates = candidates.filter(
(candidate) =>
normalizeBomCostingIdentity(candidate.name) === name ||
candidate.aliases?.some(
(alias) => normalizeBomCostingIdentity(alias) === name,
),
)
const candidatesWithSpec = spec
? namedCandidates.filter(
(candidate) => normalizeBomCostingIdentity(candidate.spec) === spec,
)
: namedCandidates
const exactResult = resolveCandidates(candidatesWithSpec)
if (exactResult.status !== 'UNMATCHED') return exactResult
}
return resolveCandidates(candidatesWithSpec)
const packagingKind = normalizeBomCostingIdentity(lookup.packagingKind)
if (packagingKind) {
const packagingCandidates = candidates.filter(
(candidate) =>
normalizeBomCostingIdentity(candidate.packagingKind) === packagingKind,
)
const packagingResult = resolveCandidates(packagingCandidates)
if (packagingResult.status !== 'UNMATCHED') return packagingResult
}
const materialFamily = normalizeBomCostingMaterialFamily(lookup.name)
if (!materialFamily) return { status: 'UNMATCHED', candidates: [] }
const semanticCandidates = candidates.filter((candidate) => {
const identities = [
candidate.materialFamily,
candidate.name,
...(candidate.aliases ?? []),
]
.map(normalizeBomCostingMaterialFamily)
.filter(Boolean)
return identities.some(
(identity) =>
identity === materialFamily ||
(identity.length >= 2 &&
materialFamily.length >= 2 &&
(identity.includes(materialFamily) ||
materialFamily.includes(identity))),
)
})
const thicknessMm =
lookup.thicknessMm ?? readBomCostingThicknessMm(lookup.spec)
const candidatesWithThickness = thicknessMm
? semanticCandidates.filter(
(candidate) =>
candidate.thicknessMm != null &&
Math.abs(candidate.thicknessMm - thicknessMm) < 0.01,
)
: semanticCandidates
return resolveCandidates(candidatesWithThickness)
}

View File

@ -0,0 +1,122 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findMany: jest.fn(),
},
intakeNonIntegratedProduct: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
import { IntakeAiAssetRepository } from '../intake-ai-asset.repository'
import {
buildDevelopmentKindWhere,
buildProductKindWhere,
} from '../intake-product-list-filters'
const mockPrisma = prisma as unknown as {
user: {
findMany: jest.Mock
}
intakeNonIntegratedProduct: {
findMany: jest.Mock
findFirst: jest.Mock
}
}
describe('IntakeAiAssetRepository catalog scope', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPrisma.user.findMany.mockResolvedValue([
{ id: 'external-intake-1' },
{ id: 'external-intake-2' },
])
mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue([])
mockPrisma.intakeNonIntegratedProduct.findFirst.mockResolvedValue(null)
})
it('lists all product-development SPUs while excluding stock, series, and external intake', async () => {
const repository = new IntakeAiAssetRepository()
await repository.findCatalogProducts('')
expect(mockPrisma.user.findMany).toHaveBeenCalledWith({
where: {
businessRoles: {
has: 'EXTERNAL_INTAKE',
},
},
select: { id: true },
})
expect(mockPrisma.intakeNonIntegratedProduct.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
deletedAt: null,
AND: [
buildDevelopmentKindWhere('product'),
buildProductKindWhere('new'),
{
createdBy: {
notIn: ['external-intake-1', 'external-intake-2'],
},
},
],
},
}),
)
const query = mockPrisma.intakeNonIntegratedProduct.findMany.mock.calls[0][0]
expect(query).not.toHaveProperty('take')
})
it('keeps SPU and barcode search inside the same catalog scope', async () => {
const repository = new IntakeAiAssetRepository()
await repository.findCatalogProducts(' BAR-001 ')
const query = mockPrisma.intakeNonIntegratedProduct.findMany.mock.calls[0][0]
const searchWhere = query.where.AND[3]
expect(searchWhere.OR).toEqual(
expect.arrayContaining([
{ spu: { contains: 'BAR-001', mode: 'insensitive' } },
expect.objectContaining({
skuItems: {
some: {
OR: expect.arrayContaining([
{ barcode: { contains: 'BAR-001', mode: 'insensitive' } },
]),
},
},
}),
]),
)
})
it('applies the same scope when a product workspace is opened directly', async () => {
const repository = new IntakeAiAssetRepository()
await repository.findProductById('product-1')
expect(mockPrisma.intakeNonIntegratedProduct.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'product-1',
deletedAt: null,
AND: [
buildDevelopmentKindWhere('product'),
buildProductKindWhere('new'),
{
createdBy: {
notIn: ['external-intake-1', 'external-intake-2'],
},
},
],
},
}),
)
})
})

View File

@ -12,10 +12,6 @@ import {
buildNonIntegratedBomAccessoryOptions,
decodeAccessoryReferenceLibraryRows,
} from '@/lib/accessory-library'
import {
buildNonIntegratedBomPackagingMaterialOptions,
decodePackagingMaterialReferenceLibraryRows,
} from '@/lib/packaging-material-library'
import {
buildNonIntegratedBomSteelFrameOptions,
} from '@/lib/non-integrated-bom-steel-materials'
@ -29,6 +25,10 @@ import {
import type {
BomCostingLibraryCandidate,
} from '@/lib/bom-costing/price-matcher'
import { buildBomCostingPackagingCandidate } from '@/lib/bom-costing/packaging-candidate'
import {
BOM_COSTING_FACT_ANALYSIS_STATUS,
} from '@/lib/bom-costing/fact-contracts'
const caseDetailInclude = Prisma.validator<Prisma.BomCostingCaseInclude>()({
sourceSnapshot: {
@ -252,7 +252,14 @@ export class BomCostingRepository {
id: true,
externalSkuId: true,
name: true,
estimatedCost: true,
bomLines: {
orderBy: { sortOrder: 'asc' },
select: {
category: true,
matchedLibraryType: true,
subtotal: true,
},
},
},
},
},
@ -331,6 +338,163 @@ export class BomCostingRepository {
})
}
async prepareSourceFactAnalysis(input: {
sourceSnapshotId: string
schemaVersion: string
assetFingerprint: string
model: string
totalImageCount: number
}) {
const record = await prisma.bomCostingSourceFactAnalysis.upsert({
where: {
sourceSnapshotId_schemaVersion: {
sourceSnapshotId: input.sourceSnapshotId,
schemaVersion: input.schemaVersion,
},
},
create: {
sourceSnapshotId: input.sourceSnapshotId,
schemaVersion: input.schemaVersion,
assetFingerprint: input.assetFingerprint,
model: input.model,
totalImageCount: input.totalImageCount,
},
update: {
model: input.model,
totalImageCount: input.totalImageCount,
},
})
if (record.assetFingerprint === input.assetFingerprint) {
if (
record.status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY &&
record.analyzedImageCount !== input.totalImageCount
) {
return prisma.bomCostingSourceFactAnalysis.update({
where: { id: record.id },
data: {
status: BOM_COSTING_FACT_ANALYSIS_STATUS.PENDING,
completedAt: null,
},
})
}
return record
}
return prisma.bomCostingSourceFactAnalysis.update({
where: { id: record.id },
data: {
assetFingerprint: input.assetFingerprint,
status: BOM_COSTING_FACT_ANALYSIS_STATUS.PENDING,
model: input.model,
factSnapshot: Prisma.JsonNull,
analyzedImageCount: 0,
totalImageCount: input.totalImageCount,
batchCount: 0,
promptTokens: 0,
completionTokens: 0,
durationMs: 0,
errorMessage: '',
startedAt: null,
completedAt: null,
},
})
}
async findSourceFactAnalysis(id: string) {
return prisma.bomCostingSourceFactAnalysis.findUnique({
where: { id },
})
}
async claimSourceFactAnalysis(id: string) {
const staleBefore = new Date(Date.now() - 10 * 60_000)
const claimed = await prisma.bomCostingSourceFactAnalysis.updateMany({
where: {
id,
OR: [
{
status: {
in: [
BOM_COSTING_FACT_ANALYSIS_STATUS.PENDING,
BOM_COSTING_FACT_ANALYSIS_STATUS.FAILED,
],
},
},
{
status: BOM_COSTING_FACT_ANALYSIS_STATUS.PROCESSING,
updatedAt: { lt: staleBefore },
},
],
},
data: {
status: BOM_COSTING_FACT_ANALYSIS_STATUS.PROCESSING,
errorMessage: '',
startedAt: new Date(),
completedAt: null,
},
})
return claimed.count === 1
}
async saveSourceFactAnalysisBatch(input: {
id: string
factSnapshot: Prisma.InputJsonValue
analyzedImageCount: number
totalImageCount: number
model: string
promptTokens: number
completionTokens: number
durationMs: number
}) {
return prisma.bomCostingSourceFactAnalysis.update({
where: { id: input.id },
data: {
status: BOM_COSTING_FACT_ANALYSIS_STATUS.PROCESSING,
factSnapshot: input.factSnapshot,
analyzedImageCount: input.analyzedImageCount,
totalImageCount: input.totalImageCount,
model: input.model,
batchCount: { increment: 1 },
promptTokens: { increment: input.promptTokens },
completionTokens: { increment: input.completionTokens },
durationMs: { increment: input.durationMs },
errorMessage: '',
},
})
}
async completeSourceFactAnalysis(input: {
id: string
factSnapshot: Prisma.InputJsonValue
analyzedImageCount: number
totalImageCount: number
model: string
}) {
return prisma.bomCostingSourceFactAnalysis.update({
where: { id: input.id },
data: {
status: BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
factSnapshot: input.factSnapshot,
analyzedImageCount: input.analyzedImageCount,
totalImageCount: input.totalImageCount,
model: input.model,
errorMessage: '',
completedAt: new Date(),
},
})
}
async failSourceFactAnalysis(id: string, message: string) {
return prisma.bomCostingSourceFactAnalysis.update({
where: { id },
data: {
status: BOM_COSTING_FACT_ANALYSIS_STATUS.FAILED,
errorMessage: message,
completedAt: new Date(),
},
})
}
async createQueuedCase(input: CreateQueuedBomCostingCaseInput) {
return prisma.bomCostingCase.create({
data: {
@ -737,7 +901,11 @@ export class BomCostingRepository {
const lines = await tx.bomCostingLine.findMany({
where: { skuId: input.skuId },
select: { subtotal: true },
select: {
category: true,
matchedLibraryType: true,
subtotal: true,
},
})
return lines
})
@ -756,20 +924,41 @@ export class BomCostingRepository {
async listPriceCandidates(): Promise<
Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>
> {
const [substrates, referenceRows] = await Promise.all([
const [substrates, referenceRows, packagingVariants] = await Promise.all([
prisma.substrate.findMany({
where: { isActive: true },
include: { thicknessSpec: true },
include: {
category: true,
environmentGrade: true,
systemNameOption: true,
thicknessSpec: true,
},
}),
prisma.intakeReferenceLibraryItem.findMany({
where: {
isActive: true,
libraryType: {
in: ['STEEL_FRAME', 'ACCESSORY', 'PACKAGING_MATERIAL', 'EDGE_BANDING'],
in: ['STEEL_FRAME', 'ACCESSORY', 'EDGE_BANDING'],
},
},
orderBy: [{ sortOrder: 'asc' }, { updatedAt: 'desc' }],
}),
prisma.assetVariant.findMany({
where: {
assetType: 'PACKAGING',
status: 'AVAILABLE',
deletedAt: null,
assetFamily: {
type: 'PACKAGING',
status: 'AVAILABLE',
deletedAt: null,
},
},
include: {
assetFamily: true,
},
orderBy: [{ updatedAt: 'desc' }, { code: 'asc' }],
}),
])
const boardCandidates = substrates.flatMap<BomCostingLibraryCandidate>((substrate) => {
@ -778,14 +967,28 @@ export class BomCostingRepository {
1_000_000
const fullSheetPrice = substrate.suggestedPrice?.toNumber() ?? null
if (!fullSheetPrice || sheetArea <= 0) return []
const systemName =
readSystemName(substrate.metadata) ||
substrate.systemNameOption?.name ||
substrate.category.name ||
substrate.name
return [{
libraryType: 'SUBSTRATE',
recordId: substrate.id,
code: substrate.code,
name: readSystemName(substrate.metadata) || substrate.name,
spec: `${substrate.thicknessSpec.thicknessMm}mm`,
name: substrate.name,
aliases: [
systemName,
substrate.category.name,
substrate.systemNameOption?.name ?? '',
substrate.environmentGrade.name,
].filter(Boolean),
materialFamily: systemName,
thicknessMm: substrate.thicknessSpec.thicknessMm,
spec: `${substrate.thicknessSpec.thicknessMm}mm · ${substrate.environmentGrade.name}`,
unit: '㎡',
unitPrice: fullSheetPrice / sheetArea,
pricingMode: 'AREA_SQM',
}]
})
@ -845,21 +1048,22 @@ export class BomCostingRepository {
}]
})
const packagingCandidates = buildNonIntegratedBomPackagingMaterialOptions(
decodePackagingMaterialReferenceLibraryRows(rowsByType('PACKAGING_MATERIAL')),
).flatMap<BomCostingLibraryCandidate>((option) => {
const unitPrice = parsePositivePrice(option.unitPrice)
if (unitPrice === null) return []
return [{
libraryType: 'PACKAGING_MATERIAL',
recordId: option.id,
code: option.code,
name: option.name,
spec: '',
unit: '件',
unitPrice,
}]
})
const packagingCandidates =
packagingVariants.flatMap<BomCostingLibraryCandidate>((variant) => {
const candidate = buildBomCostingPackagingCandidate({
id: variant.id,
code: variant.code,
name: variant.name,
sizeLabel: variant.sizeLabel,
businessData: variant.businessData,
dimensions: variant.dimensions,
costPrice: variant.costPrice?.toNumber() ?? null,
estimatedCostPrice: variant.estimatedCostPrice?.toNumber() ?? null,
familyName: variant.assetFamily.name,
familyMetadata: variant.assetFamily.metadata,
})
return candidate ? [candidate] : []
})
return {
BOARD: boardCandidates,

View File

@ -1,5 +1,9 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
buildDevelopmentKindWhere,
buildProductKindWhere,
} from './intake-product-list-filters'
const skuAiProfileInclude = {
currentModelVersion: {
@ -43,14 +47,27 @@ export interface CreateModelVersionInput {
createdBy: string
}
export class IntakeAiAssetRepository {
findCatalogProducts(query: string, take = 200) {
const normalizedQuery = query.trim()
return prisma.intakeNonIntegratedProduct.findMany({
where: {
deletedAt: null,
...(normalizedQuery
? {
function buildCatalogWhere(input: {
query?: string
productId?: string
excludedCreatorIds: string[]
}): Prisma.IntakeNonIntegratedProductWhereInput {
const normalizedQuery = input.query?.trim() ?? ''
const developmentWhere = buildDevelopmentKindWhere('product')
const productKindWhere = buildProductKindWhere('new')
return {
...(input.productId ? { id: input.productId } : {}),
deletedAt: null,
AND: [
...(developmentWhere ? [developmentWhere] : []),
...(productKindWhere ? [productKindWhere] : []),
...(input.excludedCreatorIds.length > 0
? [{ createdBy: { notIn: input.excludedCreatorIds } }]
: []),
...(normalizedQuery
? [
{
OR: [
{ spu: { contains: normalizedQuery, mode: 'insensitive' as const } },
{ spuBase: { contains: normalizedQuery, mode: 'insensitive' as const } },
@ -71,18 +88,40 @@ export class IntakeAiAssetRepository {
},
},
],
}
: {}),
},
]
: []),
],
}
}
export class IntakeAiAssetRepository {
private async findExcludedCatalogCreatorIds() {
const users = await prisma.user.findMany({
where: {
businessRoles: {
has: 'EXTERNAL_INTAKE',
},
},
select: { id: true },
})
return users.map((user) => user.id)
}
async findCatalogProducts(query: string) {
const excludedCreatorIds = await this.findExcludedCatalogCreatorIds()
return prisma.intakeNonIntegratedProduct.findMany({
where: buildCatalogWhere({ query, excludedCreatorIds }),
include: catalogProductInclude,
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
take,
})
}
findProductById(productId: string) {
async findProductById(productId: string) {
const excludedCreatorIds = await this.findExcludedCatalogCreatorIds()
return prisma.intakeNonIntegratedProduct.findFirst({
where: { id: productId, deletedAt: null },
where: buildCatalogWhere({ productId, excludedCreatorIds }),
include: catalogProductInclude,
})
}

View File

@ -0,0 +1,169 @@
import { Prisma } from '@prisma/client'
type ProductDevelopmentKind = 'product' | 'series' | undefined
type ProductKind = 'all' | 'new' | 'stock' | undefined
const JST_SKU_SOURCE_PATHS = [
['source'],
['sourceTable'],
['importSource'],
['externalSource'],
['metadata', 'source'],
['metadata', 'sourceTable'],
['metadata', 'importSource'],
['metadata', 'externalSource'],
] as const
function jsonTextEquals(
path: string[],
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
payload: {
path,
equals: value,
},
}
}
function jsonTextMissingOrNotEquals(
path: string[],
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{
NOT: jsonTextEquals(path, value),
},
{
payload: {
path,
equals: Prisma.AnyNull,
},
},
],
}
}
function profileTextEquals(
field: string,
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
profile: {
is: {
[field]: value,
},
},
} as Prisma.IntakeNonIntegratedProductWhereInput
}
function profileTextNonEmpty(field: string): Prisma.IntakeNonIntegratedProductWhereInput {
return {
profile: {
is: {
[field]: {
not: '',
},
},
},
} as Prisma.IntakeNonIntegratedProductWhereInput
}
function payloadJsonWhenProfileMissing(
condition: Prisma.IntakeNonIntegratedProductWhereInput,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
AND: [
{
profile: null,
},
condition,
],
}
}
function profileTextNotEqualsWhenPresent(
field: string,
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
AND: [
{
NOT: {
profile: null,
},
},
{
OR: [
{
NOT: profileTextEquals(field, value),
},
{
profile: {
is: {
[field]: '',
},
},
} as Prisma.IntakeNonIntegratedProductWhereInput,
],
},
],
}
}
export function buildStockProductWhere(): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{ id: { startsWith: 'jst-stock-' } },
profileTextNonEmpty('sourceSpu'),
...JST_SKU_SOURCE_PATHS.map((path) =>
payloadJsonWhenProfileMissing(jsonTextEquals([...path], 'jst_sku')),
),
],
}
}
export function buildDevelopmentKindWhere(
productDevelopmentKind: ProductDevelopmentKind,
): Prisma.IntakeNonIntegratedProductWhereInput | null {
if (productDevelopmentKind === 'series') {
return {
OR: [
profileTextEquals('productDevelopmentKind', 'series'),
payloadJsonWhenProfileMissing(jsonTextEquals(['productDevelopmentKind'], 'series')),
],
}
}
if (productDevelopmentKind === 'product') {
return {
OR: [
profileTextNotEqualsWhenPresent('productDevelopmentKind', 'series'),
payloadJsonWhenProfileMissing(
jsonTextMissingOrNotEquals(['productDevelopmentKind'], 'series'),
),
],
}
}
return null
}
export function buildProductKindWhere(
productKind: ProductKind,
): Prisma.IntakeNonIntegratedProductWhereInput | null {
const stockWhere = buildStockProductWhere()
if (productKind === 'stock') {
return stockWhere
}
if (productKind === 'new') {
return {
NOT: stockWhere,
}
}
return null
}

View File

@ -0,0 +1,210 @@
/** @jest-environment node */
import {
BOM_COSTING_FACT_SCHEMA_VERSION,
type BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import { buildBomCostingImageIdentity } from '@/lib/bom-costing/image-identity'
import {
buildBomCostingMultimodalImages,
extractBomCostingImageFacts,
} from './bom-costing-image-fact-extractor'
import type { MarketDetailSnapshot } from './market-detail-client'
const snapshot: MarketDetailSnapshot = {
itemId: '1053559401376',
channel: 'taobao',
title: '折叠边几',
shopName: '测试店铺',
detailUrl: 'https://detail.tmall.com/item.htm?id=1053559401376',
mainImages: Array.from(
{ length: 2 },
(_, index) => `https://img.example.com/main-${index + 1}.jpg`,
),
skuImages: Array.from(
{ length: 3 },
(_, index) => `https://img.example.com/sku-${index + 1}.jpg`,
),
detailImages: Array.from(
{ length: 8 },
(_, index) => `https://img.example.com/detail-${index + 1}.jpg`,
),
skus: Array.from({ length: 3 }, (_, index) => ({
externalSkuId: `sku-${index + 1}`,
name: `规格 ${index + 1}`,
size: null,
imageUrl: `https://img.example.com/sku-${index + 1}.jpg`,
attributes: {},
priceRaw: null,
originalPriceRaw: null,
quantity: null,
})),
requestId: 'request-1',
}
const providerConfig = {
baseUrl: 'https://ai.example.com/v1',
apiKey: 'test-ai-key',
model: 'vision-model',
}
function createStreamingResponse(content: string) {
return new Response(
`data: ${JSON.stringify({
id: 'fact-call',
model: 'vision-model',
choices: [{
delta: { content },
finish_reason: 'stop',
}],
})}\n\ndata: [DONE]\n\n`,
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
)
}
function readBatchRequest(init?: RequestInit) {
const body = JSON.parse(String(init?.body)) as {
messages?: Array<{
content?: string | Array<{
type: string
text?: string
image_url?: { url: string }
}>
}>
}
const content = body.messages?.[1]?.content
if (!Array.isArray(content)) throw new Error('缺少图片批次')
const prompt = JSON.parse(content[0]?.text ?? '{}') as {
batchImages?: Array<{ imageId: string }>
}
return {
imageIds: prompt.batchImages?.map((image) => image.imageId) ?? [],
imageUrls: content.flatMap((item) =>
item.type === 'image_url' && item.image_url?.url
? [item.image_url.url]
: [],
),
}
}
function createExistingSnapshot(
imageCount: number,
): BomCostingSourceFactSnapshot {
return {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: buildBomCostingMultimodalImages(snapshot)
.slice(0, imageCount)
.map((image) => ({
imageId: image.imageId,
sourceImageUrl: image.url,
kind: image.kind,
label: image.label,
externalSkuIds: image.externalSkuIds,
observedTexts: [],
facts: [],
})),
extractedAt: new Date().toISOString(),
}
}
describe('BOM 逐图事实提取', () => {
const originalBatchSize = process.env.BOM_COSTING_AI_FACT_BATCH_SIZE
beforeEach(() => {
process.env.BOM_COSTING_AI_FACT_BATCH_SIZE = '6'
})
afterEach(() => {
process.env.BOM_COSTING_AI_FACT_BATCH_SIZE = originalBatchSize
jest.restoreAllMocks()
})
it('按有限批次分析全部图片,并在每批完成后返回可落盘快照', async () => {
const sentUrls: string[] = []
const persistedCounts: number[] = []
const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(
async (_input, init) => {
const batch = readBatchRequest(init)
sentUrls.push(...batch.imageUrls)
return createStreamingResponse(JSON.stringify({
images: batch.imageIds.map((imageId) => ({
imageId,
observedTexts: [],
facts: [],
})),
}))
},
)
const output = await extractBomCostingImageFacts({
config: providerConfig,
snapshot,
onBatchCompleted: async (progress) => {
persistedCounts.push(progress.snapshot.imageFacts.length)
},
})
expect(fetchSpy).toHaveBeenCalledTimes(3)
expect(persistedCounts).toEqual([6, 12, 13])
expect(output.snapshot.imageFacts).toHaveLength(13)
expect(output.trace).toMatchObject({
batchCount: 3,
reusedImageCount: 0,
analyzedImageCount: 13,
})
expect(sentUrls).toHaveLength(13)
expect(new Set(sentUrls).size).toBe(13)
})
it('断点续跑只发送尚未保存事实的图片', async () => {
const sentUrls: string[] = []
const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(
async (_input, init) => {
const batch = readBatchRequest(init)
sentUrls.push(...batch.imageUrls)
return createStreamingResponse(JSON.stringify({
images: batch.imageIds.map((imageId) => ({
imageId,
observedTexts: [],
facts: [],
})),
}))
},
)
const existingSnapshot = createExistingSnapshot(6)
existingSnapshot.imageFacts = existingSnapshot.imageFacts.map((image) => ({
...image,
sourceImageUrl:
`${image.sourceImageUrl}?e=1785400000&token=old-signature`,
}))
const reusedIdentities = new Set(
existingSnapshot.imageFacts.map((image) =>
buildBomCostingImageIdentity(image.sourceImageUrl),
),
)
const output = await extractBomCostingImageFacts({
config: providerConfig,
snapshot,
existingSnapshot,
})
expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(output.trace).toMatchObject({
batchCount: 2,
reusedImageCount: 6,
analyzedImageCount: 7,
})
expect(sentUrls).toHaveLength(7)
expect(
sentUrls.every(
(url) => !reusedIdentities.has(buildBomCostingImageIdentity(url)),
),
).toBe(true)
expect(new Set(sentUrls).size).toBe(7)
expect(output.snapshot.imageFacts).toHaveLength(13)
})
})

View File

@ -0,0 +1,481 @@
import 'server-only'
import { z } from 'zod'
import {
BOM_COSTING_FACT_SCHEMA_VERSION,
bomCostingExtractedFactSchema,
bomCostingSourceFactSnapshotSchema,
type BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import { buildBomCostingImageIdentity } from '@/lib/bom-costing/image-identity'
import { AppError, ErrorCode } from '@/server/core/errors'
import { buildBomCostingInstallationKnowledge } from './bom-costing-installation-knowledge'
import {
callBomCostingMultimodalModel,
parseBomCostingJsonObject,
readBomCostingBoundedInteger,
type BomCostingMultimodalCallResult,
type BomCostingMultimodalProviderConfig,
} from './bom-costing-multimodal-gateway'
import type {
MarketDetailSnapshot,
MarketSkuSnapshot,
} from './market-detail-client'
const FACT_EXTRACTION_SYSTEM_PROMPT = `你是家具竞品资料事实提取员。你需要逐张读取图片,为后续 BOM 工程师保存直接证据。
硬规则:
1. 只输出一个 JSON 对象,不要 Markdown、解释、代码围栏或额外文字。
2. 输入中的每个 imageId 必须返回且只返回一次;即使图片没有成本信息,也要返回空 facts。
3. 本阶段只提取图片直接可见事实和图片中的文字,不生成 BOM,不推断隐藏部件,不估算价格。
4. 图片直接可见的形状、部件、结构或机构使用 basis=IMAGE;尺寸标注、材质说明、功能说明、参数表等图片文字使用 basis=SOURCE_TEXT。
5. 必须检查局部小图、红框区域、剖面图、折叠或运动示意、产品参数图和包装说明,不得把它们当成装饰。
6. 尺寸保留原始文字,同时把明确数值规范为毫米;重量规范为千克。无法确认单位时只保留文字。
7. relatedSkuIds 只能使用输入中的真实 SKU ID;无法绑定具体 SKU 时返回空数组。
8. 不得根据市场售价倒推成本,不得把颜色或营销等级当作基材。
9. confidence 必须在 0 到 1 之间;模糊、遮挡或仅能看见局部时应降低置信度并在 note 说明。
10. 同一图片中的每个 factId 必须唯一,使用简短稳定的英文或数字标识。`
const aiImageFactResultSchema = z.object({
imageId: z.string().trim().min(1).max(80),
observedTexts: z.array(z.string().trim().min(1).max(1_000)).max(100).default([]),
facts: z.array(bomCostingExtractedFactSchema).max(60).default([]),
})
const aiFactBatchPayloadSchema = z.object({
images: z.array(aiImageFactResultSchema).min(1).max(8),
})
export interface BomCostingMultimodalInputImage {
imageId: string
kind: 'MAIN' | 'SKU' | 'DETAIL'
label: string
url: string
externalSkuIds: string[]
}
export interface BomCostingMultimodalInputStats {
imageCount: number
inputSkuCount: number
images: BomCostingMultimodalInputImage[]
}
export interface BomCostingFactBatchProgress {
batchIndex: number
totalBatches: number
batchImages: BomCostingMultimodalInputImage[]
completedImageCount: number
totalImageCount: number
snapshot: BomCostingSourceFactSnapshot
call: BomCostingMultimodalCallResult
}
export interface BomCostingFactExtractionTrace {
model: string
callIds: string[]
batchCount: number
reusedImageCount: number
analyzedImageCount: number
durationMs: number
promptTokens: number | null
completionTokens: number | null
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values.filter(Boolean)))
}
function buildSkuIdsByImage(skus: MarketSkuSnapshot[]) {
const result = new Map<string, string[]>()
for (const sku of skus) {
if (!sku.imageUrl) continue
const ids = result.get(sku.imageUrl) ?? []
ids.push(sku.externalSkuId)
result.set(sku.imageUrl, uniqueStrings(ids))
}
return result
}
export function buildBomCostingMultimodalImages(
snapshot: MarketDetailSnapshot,
): BomCostingMultimodalInputImage[] {
const entries = new Map<
string,
Omit<BomCostingMultimodalInputImage, 'imageId'>
>()
const skuIdsByImage = buildSkuIdsByImage(snapshot.skus)
const add = (
kind: BomCostingMultimodalInputImage['kind'],
label: string,
url: string,
externalSkuIds: string[] = [],
) => {
const existing = entries.get(url)
if (existing) {
existing.externalSkuIds = uniqueStrings([
...existing.externalSkuIds,
...externalSkuIds,
])
return
}
entries.set(url, {
kind,
label,
url,
externalSkuIds: uniqueStrings(externalSkuIds),
})
}
snapshot.mainImages.forEach((url, index) => {
add('MAIN', `商品主图 ${index + 1}`, url)
})
snapshot.skuImages.forEach((url, index) => {
const skuIds = skuIdsByImage.get(url) ?? []
add(
'SKU',
skuIds.length > 0
? `SKU 图:${skuIds.join('、')}`
: `SKU 图 ${index + 1}`,
url,
skuIds,
)
})
snapshot.skus.forEach((sku) => {
if (!sku.imageUrl) return
add(
'SKU',
`SKU 图:${sku.externalSkuId}`,
sku.imageUrl,
[sku.externalSkuId],
)
})
snapshot.detailImages.forEach((url, index) => {
add('DETAIL', `商品详情图 ${index + 1}`, url)
})
return Array.from(entries.values(), (image, index) => ({
imageId: `IMG_${String(index + 1).padStart(4, '0')}`,
...image,
}))
}
export function getBomCostingMultimodalInputStats(
snapshot: MarketDetailSnapshot,
): BomCostingMultimodalInputStats {
const images = buildBomCostingMultimodalImages(snapshot)
return {
imageCount: images.length,
inputSkuCount: snapshot.skus.length,
images,
}
}
function serializeSkuForFactExtraction(sku: MarketSkuSnapshot) {
return {
externalSkuId: sku.externalSkuId,
name: sku.name,
size: sku.size,
attributes: sku.attributes,
imageUrl: sku.imageUrl,
}
}
function buildFactBatchUserText(
snapshot: MarketDetailSnapshot,
images: BomCostingMultimodalInputImage[],
) {
return JSON.stringify({
task: '逐张提取所有与家具结构、用料、尺寸、五金、机构、加工和包材有关的直接事实',
requiredOutputShape: {
images: [{
imageId: '必须来自本批输入',
observedTexts: ['图片中与产品和成本有关的原文'],
facts: [{
factId: '当前图片内唯一',
kind: 'DIMENSION | MATERIAL | PART | STRUCTURE | MECHANISM | HARDWARE | PACKAGING | PROCESS | WEIGHT | SKU_DIFFERENCE | TEXT',
subject: '事实主体',
value: '事实内容',
numericValue: '明确数值 optional',
unit: 'mm | kg | 个 | 件等 optional',
relatedSkuIds: ['只能来自 SKU 真相'],
basis: 'IMAGE | SOURCE_TEXT',
note: '证据位置、可见程度或待确认说明',
confidence: '0 to 1',
}],
}],
},
productTruth: {
itemId: snapshot.itemId,
channel: snapshot.channel,
title: snapshot.title,
shopName: snapshot.shopName,
detailUrl: snapshot.detailUrl,
},
skuTruth: snapshot.skus.map(serializeSkuForFactExtraction),
installationKnowledge: buildBomCostingInstallationKnowledge(snapshot),
batchImages: images.map((image) => ({
imageId: image.imageId,
kind: image.kind,
label: image.label,
externalSkuIds: image.externalSkuIds,
})),
})
}
function assertExactImageCoverage(
expectedImages: BomCostingMultimodalInputImage[],
actualImages: z.infer<typeof aiImageFactResultSchema>[],
) {
const expectedIds = new Set(expectedImages.map((image) => image.imageId))
const actualIds = new Set(actualImages.map((image) => image.imageId))
if (actualIds.size !== actualImages.length) {
throw new Error('模型返回了重复图片事实')
}
const missing = Array.from(expectedIds).filter((id) => !actualIds.has(id))
const unexpected = Array.from(actualIds).filter((id) => !expectedIds.has(id))
if (missing.length > 0 || unexpected.length > 0) {
throw new Error(
`图片事实覆盖不一致,缺少 ${missing.length} 张,多出 ${unexpected.length} 张`,
)
}
}
function assertFactSkuReferences(
snapshot: MarketDetailSnapshot,
images: z.infer<typeof aiImageFactResultSchema>[],
) {
const skuIds = new Set(snapshot.skus.map((sku) => sku.externalSkuId))
for (const image of images) {
for (const fact of image.facts) {
const unexpected = fact.relatedSkuIds.find((id) => !skuIds.has(id))
if (unexpected) {
throw new Error(`图片事实引用了不存在的 SKU ${unexpected}`)
}
}
}
}
function createTrustedImageFacts(
expectedImages: BomCostingMultimodalInputImage[],
actualImages: z.infer<typeof aiImageFactResultSchema>[],
) {
const actualById = new Map(
actualImages.map((image) => [image.imageId, image] as const),
)
return expectedImages.map((expected) => {
const actual = actualById.get(expected.imageId)
if (!actual) throw new Error(`模型未返回 ${expected.imageId} 的图片事实`)
const factIds = new Set<string>()
actual.facts.forEach((fact) => {
if (factIds.has(fact.factId)) {
throw new Error(`${expected.imageId} 返回了重复 factId`)
}
factIds.add(fact.factId)
})
return {
imageId: expected.imageId,
sourceImageUrl: expected.url,
kind: expected.kind,
label: expected.label,
externalSkuIds: expected.externalSkuIds,
observedTexts: actual.observedTexts,
facts: actual.facts,
}
})
}
function chunk<T>(items: T[], size: number): T[][] {
const result: T[][] = []
for (let index = 0; index < items.length; index += size) {
result.push(items.slice(index, index + size))
}
return result
}
function addNullableNumber(left: number | null, right: number | null) {
if (left === null && right === null) return null
return (left ?? 0) + (right ?? 0)
}
export async function extractBomCostingImageFacts(input: {
config: BomCostingMultimodalProviderConfig
snapshot: MarketDetailSnapshot
existingSnapshot?: BomCostingSourceFactSnapshot | null
onBatchStarted?: (progress: Omit<BomCostingFactBatchProgress, 'snapshot' | 'call'>) => Promise<void>
onBatchCompleted?: (progress: BomCostingFactBatchProgress) => Promise<void>
}) {
const images = buildBomCostingMultimodalImages(input.snapshot)
if (images.length === 0) {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'竞品没有可供多模态分析的图片',
)
}
const currentIdentities = new Set(
images.map((image) => buildBomCostingImageIdentity(image.url)),
)
const existingByIdentity = new Map(
(input.existingSnapshot?.imageFacts ?? [])
.filter((image) =>
currentIdentities.has(
buildBomCostingImageIdentity(image.sourceImageUrl),
),
)
.map((image) => [
buildBomCostingImageIdentity(image.sourceImageUrl),
image,
] as const),
)
const completedByIdentity = new Map(
images.flatMap((image) => {
const identity = buildBomCostingImageIdentity(image.url)
const existing = existingByIdentity.get(identity)
if (!existing) return []
return [[identity, {
...existing,
imageId: image.imageId,
sourceImageUrl: image.url,
kind: image.kind,
label: image.label,
externalSkuIds: image.externalSkuIds,
}] as const]
}),
)
const missingImages = images.filter(
(image) =>
!completedByIdentity.has(buildBomCostingImageIdentity(image.url)),
)
const batchSize = readBomCostingBoundedInteger(
process.env.BOM_COSTING_AI_FACT_BATCH_SIZE,
6,
1,
6,
)
const batches = chunk(missingImages, batchSize)
const timeoutMs = readBomCostingBoundedInteger(
process.env.BOM_COSTING_AI_FACT_TIMEOUT_MS ||
process.env.BOM_COSTING_AI_TIMEOUT_MS,
180_000,
30_000,
300_000,
)
const maxTokens = readBomCostingBoundedInteger(
process.env.BOM_COSTING_AI_FACT_MAX_TOKENS,
8_000,
2_000,
16_000,
)
const startedAt = Date.now()
const callIds: string[] = []
let promptTokens: number | null = null
let completionTokens: number | null = null
let analyzedImageCount = 0
for (const [batchOffset, batchImages] of batches.entries()) {
const batchIndex = batchOffset + 1
await input.onBatchStarted?.({
batchIndex,
totalBatches: batches.length,
batchImages,
completedImageCount: completedByIdentity.size,
totalImageCount: images.length,
})
const userContent: Array<
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }
> = [{
type: 'text',
text: buildFactBatchUserText(input.snapshot, batchImages),
}]
for (const image of batchImages) {
userContent.push({
type: 'text',
text: `${image.imageId}|${image.label}`,
})
userContent.push({
type: 'image_url',
image_url: { url: image.url },
})
}
const call = await callBomCostingMultimodalModel({
config: input.config,
systemPrompt: FACT_EXTRACTION_SYSTEM_PROMPT,
userContent,
maxTokens,
timeoutMs,
})
let parsed: z.infer<typeof aiFactBatchPayloadSchema>
try {
parsed = aiFactBatchPayloadSchema.parse(
parseBomCostingJsonObject(call.content),
)
assertExactImageCoverage(batchImages, parsed.images)
assertFactSkuReferences(input.snapshot, parsed.images)
} catch {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
`第 ${batchIndex} 批图片事实返回不完整,已完成批次不会重复分析`,
)
}
const trustedFacts = createTrustedImageFacts(batchImages, parsed.images)
trustedFacts.forEach((image) => {
completedByIdentity.set(
buildBomCostingImageIdentity(image.sourceImageUrl),
image,
)
})
analyzedImageCount += batchImages.length
if (call.id) callIds.push(call.id)
promptTokens = addNullableNumber(promptTokens, call.promptTokens)
completionTokens = addNullableNumber(
completionTokens,
call.completionTokens,
)
const snapshot = bomCostingSourceFactSnapshotSchema.parse({
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: images
.map((image) =>
completedByIdentity.get(buildBomCostingImageIdentity(image.url)),
)
.filter(Boolean),
extractedAt: new Date().toISOString(),
})
await input.onBatchCompleted?.({
batchIndex,
totalBatches: batches.length,
batchImages,
completedImageCount: completedByIdentity.size,
totalImageCount: images.length,
snapshot,
call,
})
}
const snapshot = bomCostingSourceFactSnapshotSchema.parse({
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: images.map((image) =>
completedByIdentity.get(buildBomCostingImageIdentity(image.url)),
),
extractedAt:
batches.length > 0
? new Date().toISOString()
: input.existingSnapshot?.extractedAt ?? new Date().toISOString(),
})
return {
snapshot,
trace: {
model: input.config.model,
callIds,
batchCount: batches.length,
reusedImageCount: images.length - missingImages.length,
analyzedImageCount,
durationMs: Date.now() - startedAt,
promptTokens,
completionTokens,
} satisfies BomCostingFactExtractionTrace,
}
}

View File

@ -0,0 +1,87 @@
/** @jest-environment node */
import { buildBomCostingInstallationKnowledge } from './bom-costing-installation-knowledge'
import type { MarketDetailSnapshot } from './market-detail-client'
function createSnapshot(overrides: Partial<MarketDetailSnapshot> = {}): MarketDetailSnapshot {
return {
itemId: '1053559401376',
channel: 'taobao',
title: '浅熏折叠边几 原木色 360°移动万向轮',
shopName: '测试店铺',
detailUrl: 'https://detail.tmall.com/item.htm?id=1053559401376',
mainImages: ['https://img.example.com/main.jpg'],
detailImages: ['https://img.example.com/detail.jpg'],
skuImages: ['https://img.example.com/sku.jpg'],
skus: [{
externalSkuId: 'sku-1',
name: 'Bauhaus折叠边几 胡桃色74*80*62',
size: '74*80*62',
imageUrl: 'https://img.example.com/sku.jpg',
attributes: {
功能: '可折叠可展开',
脚轮: '360°静音万向轮',
},
priceRaw: null,
originalPriceRaw: null,
quantity: null,
}],
requestId: 'request-1',
...overrides,
}
}
describe('BOM 安装经验知识', () => {
it('按商品与 SKU 文字命中产品族和活动机构', () => {
const knowledge = buildBomCostingInstallationKnowledge(createSnapshot())
expect(knowledge.productFamilies.map((item) => item.code)).toContain('TABLE_DESK')
expect(knowledge.mechanisms.map((item) => item.code)).toEqual(
expect.arrayContaining(['FOLDING', 'CASTER']),
)
})
it('抽屉和玻璃移门只生成检查规则,不把五金声明为已确认事实', () => {
const knowledge = buildBomCostingInstallationKnowledge(createSnapshot({
title: '玻璃推拉门抽屉收纳柜',
skus: [{
externalSkuId: 'sku-2',
name: '玻璃移门双抽款',
size: null,
imageUrl: null,
attributes: {},
priceRaw: null,
originalPriceRaw: null,
quantity: null,
}],
}))
expect(knowledge.productFamilies.map((item) => item.code)).toContain('CABINET_STORAGE')
expect(knowledge.mechanisms.map((item) => item.code)).toEqual(
expect.arrayContaining(['DRAWER', 'SLIDING_DOOR', 'GLASS_PANEL']),
)
expect(knowledge.usagePolicy.join(' ')).toContain('不是当前商品的已确认事实')
expect(knowledge.usagePolicy.join(' ')).toContain('STRUCTURAL_INFERENCE')
expect(knowledge.coverageLimits.join(' ')).toContain('样本较少')
})
it('普通无关文案不会命中具体家具机构', () => {
const knowledge = buildBomCostingInstallationKnowledge(createSnapshot({
title: '家居新品',
skus: [{
externalSkuId: 'sku-3',
name: '基础款',
size: null,
imageUrl: null,
attributes: { 颜色: '白色' },
priceRaw: null,
originalPriceRaw: null,
quantity: null,
}],
}))
expect(knowledge.productFamilies).toEqual([])
expect(knowledge.mechanisms).toEqual([])
expect(knowledge.universalInspection.length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,477 @@
import 'server-only'
import type { MarketDetailSnapshot } from './market-detail-client'
export const BOM_COSTING_INSTALLATION_KNOWLEDGE_VERSION =
'installation-priors/2026-07-30-v1' as const
type KnowledgeRuleKind = 'PRODUCT_FAMILY' | 'MECHANISM'
interface KnowledgeRuleDefinition {
code: string
kind: KnowledgeRuleKind
label: string
keywords: string[]
inspectFor: string[]
costCandidates: string[]
cautions: string[]
}
export interface BomCostingInstallationKnowledgeMatch {
code: string
label: string
matchedTerms: string[]
inspectFor: string[]
costCandidates: string[]
cautions: string[]
}
export interface BomCostingInstallationKnowledge {
version: typeof BOM_COSTING_INSTALLATION_KNOWLEDGE_VERSION
usagePolicy: string[]
coverageLimits: string[]
universalInspection: string[]
productFamilies: BomCostingInstallationKnowledgeMatch[]
mechanisms: BomCostingInstallationKnowledgeMatch[]
}
const PRODUCT_FAMILY_RULES: KnowledgeRuleDefinition[] = [
{
code: 'TABLE_DESK',
kind: 'PRODUCT_FAMILY',
label: '桌台类',
keywords: ['桌', '茶几', '边几', '书桌', '餐桌', '电脑桌', '梳妆台', '工作台'],
inspectFor: [
'顶板、侧板或桌腿、横撑、层板、背板和底座是否为独立部件',
'板件封边范围、圆角或异形加工、承重连接位置',
'展开前后尺寸是否不同,是否存在活动桌板或隐藏支撑',
],
costCandidates: [
'板件或台面',
'支撑框架或桌腿',
'连接件、角码、螺钉和木榫',
'封边、开孔和异形加工',
],
cautions: [
'不得用整机长宽高直接当作每块板件尺寸',
'看不到连接方式时只能估算连接件,不得指定确切型号',
],
},
{
code: 'CABINET_STORAGE',
kind: 'PRODUCT_FAMILY',
label: '柜体收纳类',
keywords: [
'柜',
'斗柜',
'鞋柜',
'餐边柜',
'书柜',
'床头柜',
'电视柜',
'文件柜',
'橱柜',
],
inspectFor: [
'顶底板、侧板、隔板、背板、门板、抽屉和踢脚或脚座',
'活动层板孔位、门板开启方式、抽屉和背板安装方式',
'高柜是否需要防倾倒件或上墙固定件',
],
costCandidates: [
'柜体板件与背板',
'层板托、三合一连接件、木榫和螺钉',
'门铰链、抽屉导轨、拉手或反弹器',
'脚垫、调节脚或防倾倒配件',
],
cautions: [
'铰链、导轨和反弹器的具体类型必须由文字或清晰图片确认',
'背板厚度与安装槽结构不可凭整机外观确定',
],
},
{
code: 'RACK_SHELF',
kind: 'PRODUCT_FAMILY',
label: '架类',
keywords: ['置物架', '书架', '花架', '货架', '展示架', '层架'],
inspectFor: [
'立柱、层板、横撑、斜撑、底脚和层板固定方式',
'是否可拆装、可调层高、折叠或带脚轮',
'金属管截面、板件数量和承重结构',
],
costCandidates: [
'层板、立柱和横撑',
'层板托、连接片、螺钉和脚垫',
'焊接、喷涂、冲孔和折弯加工',
],
cautions: [
'管材壁厚和板材厚度没有证据时必须待确认',
'承重等级不得反推为精确材料规格',
],
},
{
code: 'BED_FRAME',
kind: 'PRODUCT_FAMILY',
label: '床架类',
keywords: ['床架', '床板', '排骨架', '床头', '床尾', '双人床', '单人床'],
inspectFor: [
'床头、床尾、侧围、排骨条、中撑梁和中撑脚',
'围板与床头床尾的角连接、排骨架承托方式',
'储物、折叠或升降结构是否改变五金和加工',
],
costCandidates: [
'床围、排骨条、中撑梁和支撑脚',
'床角码、挂码、螺栓、螺母和排骨架固定件',
'软包、海绵、面料或皮革',
],
cautions: [
'排骨条数量应以图片或尺寸估算并标记待确认',
'软包内部结构不可仅凭外观判定',
],
},
{
code: 'SEATING',
kind: 'PRODUCT_FAMILY',
label: '椅凳类',
keywords: ['椅', '凳', '座椅', '吧椅'],
inspectFor: [
'座面、靠背、腿架、横撑、扶手和连接节点',
'座面与靠背是否软包,框架是木质、金属还是塑料',
'旋转、升降、折叠或脚轮机构',
],
costCandidates: [
'座背板、腿架和横撑',
'角码、螺栓、螺钉和脚垫',
'海绵、面料、皮革和软包加工',
],
cautions: [
'内部框架、海绵密度和面料克重没有剖面证据时不得确认',
'气杆或转盘等级必须待确认',
],
},
{
code: 'UPHOLSTERED_SEATING',
kind: 'PRODUCT_FAMILY',
label: '沙发软体类',
keywords: ['沙发', '软包', '布艺', '真皮', '科技布', '猫抓皮'],
inspectFor: [
'内框架、蛇形簧或绷带、海绵层、面套、扶手和脚架',
'可拆洗、折叠、储物、伸展或功能位机构',
'不同 SKU 的面料、尺寸和功能结构差异',
],
costCandidates: [
'木或金属内框架',
'弹簧、绷带、海绵、喷胶和衬布',
'面料或皮革、拉链、魔术贴和车缝加工',
'脚架、连接件和功能五金',
],
cautions: [
'未展示内部结构时只能列为结构推断,置信度必须低',
'面料等级、海绵密度和弹簧规格不得由营销名称直接确定',
],
},
{
code: 'WARDROBE',
kind: 'PRODUCT_FAMILY',
label: '衣柜类',
keywords: ['衣柜', '衣橱', '移门柜'],
inspectFor: [
'柜体板、背板、隔板、挂衣杆、门板和底脚',
'平开、推拉或折叠门机构及其轨道空间',
'高柜防倾倒、调平和上墙固定方式',
],
costCandidates: [
'柜体板件、挂衣杆和层板',
'铰链或移门轨道与滑轮',
'拉手、层板托、连接件和防倾倒件',
],
cautions: [
'移门轨道、滑轮和缓冲器规格需有明确证据',
'柜内布局应按 SKU 区分,不能用一张图覆盖全部规格',
],
},
]
const MECHANISM_RULES: KnowledgeRuleDefinition[] = [
{
code: 'DRAWER',
kind: 'MECHANISM',
label: '抽屉机构',
keywords: ['抽屉', '储物斗'],
inspectFor: [
'抽屉前板、侧板、后板、底板和拉手',
'左右导轨是否可见,属于托底、滚轮还是钢珠导轨',
'是否存在反弹器、阻尼或限位件',
],
costCandidates: [
'抽屉盒板件',
'成对导轨及安装螺钉',
'拉手、反弹器、缓冲器或限位件',
],
cautions: [
'有抽屉可推断需要导向或滑动机构,但导轨类型、长度和承重必须待确认',
'没有清晰证据时不得默认静音、阻尼或三节轨',
],
},
{
code: 'HINGED_DOOR',
kind: 'MECHANISM',
label: '平开门机构',
keywords: ['平开门', '对开门', '单开门', '双开门', '铰链', '合页'],
inspectFor: [
'门板数量、开启方向、铰链位置和拉手',
'磁吸、反弹器、阻尼或限位结构',
],
costCandidates: [
'铰链或合页及安装螺钉',
'拉手、磁吸、反弹器或缓冲件',
],
cautions: [
'铰链数量可按门板高度估算,但必须标注结构推断和待确认',
'不可默认使用阻尼铰链',
],
},
{
code: 'SLIDING_DOOR',
kind: 'MECHANISM',
label: '移门滑动机构',
keywords: ['推拉门', '移门', '滑门', '滑轨门', '滑动门'],
inspectFor: [
'上轨、下轨、滑轮、导向件、限位和防跳结构',
'门板重叠量、轨道长度和门板材质',
],
costCandidates: [
'上下轨道、滑轮和导向件',
'限位器、防跳件、缓冲件和安装螺钉',
],
cautions: [
'从功能可推断滑动组件存在,但截面、材质和缓冲方案必须待确认',
'玻璃移门还需单独检查玻璃固定和防护包材',
],
},
{
code: 'FOLDING',
kind: 'MECHANISM',
label: '折叠展开机构',
keywords: ['折叠', '可折', '翻折', '展开', '折叠边几', '折叠桌'],
inspectFor: [
'折叠轴线、铰链、滑动件、连杆、支撑件和锁止位置',
'折叠态与展开态尺寸、承重面和活动板数量',
'活动部件下方或内侧是否有隐藏支架与导轨',
],
costCandidates: [
'折叠铰链、转轴、连杆或滑轨',
'支撑架、锁止件、限位件和安装紧固件',
'活动板额外封边与开孔加工',
],
cautions: [
'“折叠”只证明存在活动机构,具体实现必须结合局部图判断',
'SKU 文案中的展开尺寸与商品主尺寸必须分别保存',
],
},
{
code: 'LIFTING',
kind: 'MECHANISM',
label: '升降翻盖机构',
keywords: ['升降', '翻盖', '气压', '液压', '抬升'],
inspectFor: [
'升降支架、气弹簧、连杆、转轴、锁止和限位',
'是否为手动、气动、液压或电动方案',
],
costCandidates: [
'升降架、气弹簧、连杆和转轴',
'锁止件、限位件和安装紧固件',
],
cautions: [
'没有清晰证据时不得指定气弹簧压力或机构型号',
'电动升降需另计电机、控制器、电源和线束',
],
},
{
code: 'CASTER',
kind: 'MECHANISM',
label: '脚轮机构',
keywords: ['万向轮', '脚轮', '静音轮', '刹车轮', '移动轮', '带轮', '轮子'],
inspectFor: [
'脚轮数量、直径、安装板或插杆形式',
'是否带刹车、是否所有轮子同规格',
],
costCandidates: [
'脚轮、安装板、螺栓和螺母',
'刹车机构或脚轮限位件',
],
cautions: [
'刹车功能只有文字或清晰图片确认后才能计入',
'轮径、承重和材质不可仅凭整体图精确判断',
],
},
{
code: 'GLASS_PANEL',
kind: 'MECHANISM',
label: '玻璃部件',
keywords: ['玻璃', '钢化玻璃', '玻璃门'],
inspectFor: [
'玻璃尺寸、厚度、磨边或钢化信息',
'玻璃槽、压条、卡扣、铰链或滑轨固定方式',
'玻璃四角、边缘和整件包装防护',
],
costCandidates: [
'玻璃板及磨边或钢化加工',
'玻璃槽、压条、卡扣、铰链或滑轨',
'护角、珍珠棉、泡沫和隔离垫',
],
cautions: [
'图片能确认玻璃存在,不代表能确认厚度或钢化等级',
'固定五金应随开门或移门机构判断',
],
},
{
code: 'ADJUSTABLE_SHELF',
kind: 'MECHANISM',
label: '可调层板',
keywords: ['可调层板', '层板可调', '自由调节', '调节层高'],
inspectFor: [
'排孔、层板托、可调范围和层板数量',
'孔位加工是否覆盖多个高度',
],
costCandidates: [
'层板托或插销',
'排孔或定位孔加工',
],
cautions: [
'层板托数量需按实际层板和支撑点估算',
],
},
{
code: 'ROTATING',
kind: 'MECHANISM',
label: '旋转机构',
keywords: ['旋转', '转盘', '可转动'],
inspectFor: [
'旋转轴、转盘、轴承、限位和承重连接',
],
costCandidates: [
'转盘轴承、转轴、限位件和安装紧固件',
],
cautions: [
'旋转角度、承重和轴承规格必须待确认',
],
},
{
code: 'EXTENSION',
kind: 'MECHANISM',
label: '伸缩机构',
keywords: ['伸缩', '延伸', '拉伸', '可扩展'],
inspectFor: [
'伸缩轨道、套管、联动件、锁止和展开支撑',
'收拢态与展开态尺寸',
],
costCandidates: [
'伸缩轨、套管、联动件、锁止和限位件',
],
cautions: [
'不得把伸缩结构与普通抽屉导轨混为同一种五金',
],
},
{
code: 'ELECTRICAL',
kind: 'MECHANISM',
label: '电气功能',
keywords: ['电动', '插座', '充电', '灯带', '照明', 'USB', '控制器', '电机'],
inspectFor: [
'电机、控制器、按键、电源、线束、插座和走线固定',
'电气件安装孔、线槽、保护罩和认证信息',
],
costCandidates: [
'电机、控制器、电源、按键和线束',
'插座、灯带、线卡、护套和安装紧固件',
'开孔、布线和电气装配人工',
],
cautions: [
'电气件型号、功率和认证必须由文字或铭牌确认',
'不得仅因出现线孔就推断存在完整电气系统',
],
},
{
code: 'ANTI_TIP',
kind: 'MECHANISM',
label: '防倾倒上墙',
keywords: ['防倾倒', '防倒', '上墙', '固定墙面'],
inspectFor: [
'防倾倒带、墙角码、膨胀件和柜体固定点',
],
costCandidates: [
'防倾倒带、墙角码、膨胀螺栓和安装螺钉',
],
cautions: [
'墙体安装件需区分随货包材与现场安装耗材',
],
},
]
function buildSearchText(snapshot: MarketDetailSnapshot) {
return [
snapshot.title,
snapshot.shopName,
...snapshot.skus.flatMap((sku) => [
sku.name,
sku.size,
...Object.entries(sku.attributes).flatMap(([name, value]) => [
name,
value,
]),
]),
]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n')
.toLocaleLowerCase('zh-CN')
}
function matchRules(
rules: KnowledgeRuleDefinition[],
searchText: string,
): BomCostingInstallationKnowledgeMatch[] {
return rules.flatMap((rule) => {
const matchedTerms = rule.keywords.filter((keyword) =>
searchText.includes(keyword.toLocaleLowerCase('zh-CN')),
)
if (matchedTerms.length === 0) return []
return [{
code: rule.code,
label: rule.label,
matchedTerms: Array.from(new Set(matchedTerms)),
inspectFor: rule.inspectFor,
costCandidates: rule.costCandidates,
cautions: rule.cautions,
}]
})
}
export function buildBomCostingInstallationKnowledge(
snapshot: MarketDetailSnapshot,
): BomCostingInstallationKnowledge {
const searchText = buildSearchText(snapshot)
return {
version: BOM_COSTING_INSTALLATION_KNOWLEDGE_VERSION,
usagePolicy: [
'这些规则来自公司安装案例总结,只是检查清单与成本候选,不是当前商品的已确认事实。',
'图片、图片文字、商品文字或 SKU 元数据能直接证明的内容,分别标记对应证据来源。',
'由功能必需性推断的隐藏五金可以进入估算,但必须标记 STRUCTURAL_INFERENCE,写清推断原因与未知项,confidence 不得高于 0.55。',
'没有证据时不得臆造具体品牌、型号、材质等级、板厚、导轨长度或五金规格。',
'同类产品可能采用不同结构;必须以当前商品和当前 SKU 的证据覆盖这些经验。',
],
coverageLimits: [
'现有安装案例以桌台、柜体、架类为主,床、沙发软体、衣柜移门和复杂电气机构的样本较少。',
'安装视频通常不能完整证明纸箱、泡沫、珍珠棉、护角等出厂包材,包材仍需商品包装图或文字证据。',
'成品图经常遮挡导轨、铰链、连接件和螺钉,只能估算实现结构所必需的类别,不能确认具体型号。',
],
universalInspection: [
'逐张检查尺寸标注、材质说明、局部放大图、剖面图、折叠或运动示意和包装说明。',
'检查板件或管件交界处,识别为实现装配必需但在成品图中可能被遮挡的连接件。',
'区分固定部件与活动部件,并检查活动路径对应的导轨、铰链、转轴、连杆、支撑、锁止和限位。',
'按 SKU 名称、规格、属性和 SKU 图识别尺寸、颜色、材质或机构差异,不得把一个 SKU 的结构覆盖到全部 SKU。',
'包装只在有包装文字、包装图或可解释的易损部件证据时估算,并明确直接证据与保护性推断。',
],
productFamilies: matchRules(PRODUCT_FAMILY_RULES, searchText),
mechanisms: matchRules(MECHANISM_RULES, searchText),
}
}

View File

@ -1,6 +1,10 @@
/** @jest-environment node */
import { providerConfigService } from '@/server/services/provider-config.service'
import {
BOM_COSTING_FACT_SCHEMA_VERSION,
type BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import {
BomCostingMultimodalAnalyzer,
buildBomCostingMultimodalImages,
@ -108,6 +112,55 @@ function createStreamingResponse(content: string) {
)
}
function createFactBatchPayload(
inputSnapshot: MarketDetailSnapshot,
imageIds?: string[],
) {
const selectedIds = imageIds ? new Set(imageIds) : null
return {
images: buildBomCostingMultimodalImages(inputSnapshot)
.filter((image) => !selectedIds || selectedIds.has(image.imageId))
.map((image) => ({
imageId: image.imageId,
observedTexts: [],
facts: [],
})),
}
}
function createCompleteFactSnapshot(
inputSnapshot: MarketDetailSnapshot,
): BomCostingSourceFactSnapshot {
return {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: buildBomCostingMultimodalImages(inputSnapshot).map((image) => ({
imageId: image.imageId,
sourceImageUrl: image.url,
kind: image.kind,
label: image.label,
externalSkuIds: image.externalSkuIds,
observedTexts: [],
facts: [],
})),
extractedAt: new Date().toISOString(),
}
}
function mockFactExtractionThenAnalysis(
inputSnapshot: MarketDetailSnapshot,
analysisPayload: unknown,
) {
return jest.spyOn(global, 'fetch')
.mockResolvedValueOnce(
createStreamingResponse(
JSON.stringify(createFactBatchPayload(inputSnapshot)),
),
)
.mockResolvedValueOnce(
createStreamingResponse(JSON.stringify(analysisPayload)),
)
}
describe('BOM 多模态分析器', () => {
const originalMaxImages = process.env.BOM_COSTING_AI_MAX_IMAGES
@ -132,7 +185,7 @@ describe('BOM 多模态分析器', () => {
jest.restoreAllMocks()
})
it('即使旧环境仍配置 20,也只选取 8 张分层代表图', () => {
it('分析全部去重后的主图、SKU 图和详情图,不受旧 8 张限制影响', () => {
process.env.BOM_COSTING_AI_MAX_IMAGES = '20'
const largeSnapshot: MarketDetailSnapshot = {
...snapshot,
@ -162,16 +215,15 @@ describe('BOM 多模态分析器', () => {
const images = buildBomCostingMultimodalImages(largeSnapshot)
expect(images).toHaveLength(8)
expect(new Set(images.map((image) => image.url)).size).toBe(8)
expect(images.filter((image) => image.label.startsWith('商品主图'))).toHaveLength(3)
expect(images.filter((image) => image.label.startsWith('SKU 图'))).toHaveLength(3)
expect(images.filter((image) => image.label.startsWith('商品详情图'))).toHaveLength(2)
expect(images).toHaveLength(30)
expect(new Set(images.map((image) => image.url)).size).toBe(30)
expect(images.filter((image) => image.kind === 'MAIN')).toHaveLength(5)
expect(images.filter((image) => image.kind === 'SKU')).toHaveLength(7)
expect(images.filter((image) => image.kind === 'DETAIL')).toHaveLength(18)
})
it('逐 SKU 校验覆盖,并以市场接口的 SKU 身份覆盖模型输出', async () => {
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
createStreamingResponse(JSON.stringify({
const fetchSpy = mockFactExtractionThenAnalysis(snapshot, {
product: {
brand: '测试品牌',
category: '书桌',
@ -179,8 +231,7 @@ describe('BOM 多模态分析器', () => {
},
structureGroups: [analyzedStructureGroup()],
skus: [analyzedSku('sku-white'), analyzedSku('sku-black')],
})),
)
})
const result = await new BomCostingMultimodalAnalyzer().analyze({
sourceUrl: snapshot.detailUrl,
@ -195,10 +246,44 @@ describe('BOM 多模态分析器', () => {
}),
}),
)
const requestBody = JSON.parse(
expect(fetchSpy).toHaveBeenCalledTimes(2)
const factRequestBody = JSON.parse(
String((fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined)?.body),
) as { stream?: boolean }
expect(requestBody.stream).toBe(true)
) as {
stream?: boolean
messages?: Array<{
role: string
content: string | Array<{ type: string; text?: string }>
}>
}
expect(factRequestBody.stream).toBe(true)
const userContent = factRequestBody.messages?.[1]?.content
expect(Array.isArray(userContent)).toBe(true)
const promptPayload = JSON.parse(
(userContent as Array<{ type: string; text?: string }>)[0]?.text ?? '{}',
) as {
installationKnowledge?: {
version?: string
productFamilies?: Array<{ code: string }>
}
}
expect(promptPayload.installationKnowledge?.version).toBe(
'installation-priors/2026-07-30-v1',
)
expect(promptPayload.installationKnowledge?.productFamilies).toEqual([
expect.objectContaining({ code: 'TABLE_DESK' }),
])
const synthesisRequestBody = JSON.parse(
String((fetchSpy.mock.calls[1]?.[1] as RequestInit | undefined)?.body),
) as {
messages?: Array<{ role: string; content: string }>
}
expect(synthesisRequestBody.messages?.[0]?.content).toEqual(
expect.stringContaining('STRUCTURAL_INFERENCE'),
)
expect(synthesisRequestBody.messages?.[1]?.content).toEqual(
expect.stringContaining('"imageFacts"'),
)
expect(result.product).toMatchObject({
title: '测试书桌',
brand: '测试品牌',
@ -219,9 +304,41 @@ describe('BOM 多模态分析器', () => {
})
})
it('保留模型返回的结构推断证据类型,供后续人工确认', async () => {
const structureGroup = analyzedStructureGroup()
structureGroup.bomLines.push({
externalLineId: 'desk-120-connectors',
category: 'ACCESSORY',
partName: '板件连接件',
materialName: '连接件',
quantity: 8,
unit: '个',
evidence: [{
basis: 'STRUCTURAL_INFERENCE',
note: '板件装配界面需要连接件,具体类型和数量待确认',
confidence: 0.45,
}],
confidence: 0.45,
})
mockFactExtractionThenAnalysis(snapshot, {
product: { category: '书桌' },
structureGroups: [structureGroup],
skus: [analyzedSku('sku-white'), analyzedSku('sku-black')],
})
const result = await new BomCostingMultimodalAnalyzer().analyze({
sourceUrl: snapshot.detailUrl,
snapshot,
})
expect(result.skus[0]?.bomLines[1]?.evidence[0]).toMatchObject({
basis: 'STRUCTURAL_INFERENCE',
confidence: 0.45,
})
})
it('模型省略无证据的 SKU 尺寸对象时仍生成规范结果', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(
createStreamingResponse(JSON.stringify({
mockFactExtractionThenAnalysis(snapshot, {
product: { category: '书桌' },
structureGroups: [analyzedStructureGroup()],
skus: [
@ -236,8 +353,7 @@ describe('BOM 多模态分析器', () => {
attributes: {},
},
],
})),
)
})
const result = await new BomCostingMultimodalAnalyzer().analyze({
sourceUrl: snapshot.detailUrl,
@ -248,16 +364,14 @@ describe('BOM 多模态分析器', () => {
})
it('模型返回其他不完整 SKU 信息时不暴露内部校验细节', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(
createStreamingResponse(JSON.stringify({
mockFactExtractionThenAnalysis(snapshot, {
product: { category: '书桌' },
structureGroups: [analyzedStructureGroup()],
skus: [
{ externalSkuId: 'sku-white' },
analyzedSku('sku-black'),
],
})),
)
})
await expect(
new BomCostingMultimodalAnalyzer().analyze({
@ -268,13 +382,11 @@ describe('BOM 多模态分析器', () => {
})
it('模型漏掉 SKU 时直接失败,不补造结果', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(
createStreamingResponse(JSON.stringify({
mockFactExtractionThenAnalysis(snapshot, {
product: { category: '书桌' },
structureGroups: [analyzedStructureGroup()],
skus: [analyzedSku('sku-white')],
})),
)
})
await expect(
new BomCostingMultimodalAnalyzer().analyze({
@ -284,6 +396,42 @@ describe('BOM 多模态分析器', () => {
).rejects.toThrow('SKU 覆盖不一致,缺少 1 条,多出 0 条')
})
it('已有完整逐图事实时只执行最终合成,不再次发送图片', async () => {
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
createStreamingResponse(JSON.stringify({
product: { category: '书桌' },
structureGroups: [analyzedStructureGroup()],
skus: [analyzedSku('sku-white'), analyzedSku('sku-black')],
})),
)
const output = await new BomCostingMultimodalAnalyzer().analyzeWithTrace({
sourceUrl: snapshot.detailUrl,
snapshot,
existingFactSnapshot: createCompleteFactSnapshot(snapshot),
})
expect(fetchSpy).toHaveBeenCalledTimes(1)
const requestBody = JSON.parse(
String((fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined)?.body),
) as {
messages?: Array<{ content: string | Array<{ type: string }> }>
}
expect(
requestBody.messages?.some(
(message) =>
Array.isArray(message.content) &&
message.content.some((content) => content.type === 'image_url'),
),
).toBe(false)
expect(output.trace).toMatchObject({
imageCount: 4,
factBatchCount: 0,
reusedImageCount: 4,
analyzedImageCount: 0,
})
})
it('模型网关暂时不可用时返回客户可读错误且不自动重试', async () => {
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({

View File

@ -8,14 +8,41 @@ import {
competitorSkuSchema,
type CompetitorAnalysisResultV1,
} from '@/lib/bom-costing/contracts'
import type {
BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import { AppError, ErrorCode } from '@/server/core/errors'
import { providerConfigService } from '@/server/services/provider-config.service'
import type {
MarketDetailSnapshot,
MarketSkuSnapshot,
} from './market-detail-client'
import { buildBomCostingInstallationKnowledge } from './bom-costing-installation-knowledge'
import {
extractBomCostingImageFacts,
type BomCostingFactBatchProgress,
type BomCostingFactExtractionTrace,
} from './bom-costing-image-fact-extractor'
import {
callBomCostingMultimodalModel,
getBomCostingMultimodalProviderConfig,
parseBomCostingJsonObject,
readBomCostingBoundedInteger,
} from './bom-costing-multimodal-gateway'
const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务是根据市场商品图片、商品标题和真实 SKU 明细,输出供成本核算系统使用的结构化 BOM。
export {
buildBomCostingMultimodalImages,
getBomCostingMultimodalInputStats,
} from './bom-costing-image-fact-extractor'
export type {
BomCostingMultimodalInputImage,
BomCostingMultimodalInputStats,
} from './bom-costing-image-fact-extractor'
export {
getBomCostingMultimodalModelName,
isBomCostingMultimodalConfigured,
} from './bom-costing-multimodal-gateway'
const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务是根据已经逐图提取并保存的事实、商品标题和真实 SKU 明细,输出供成本核算系统使用的结构化 BOM。
硬规则:
1. 只输出一个 JSON 对象,不要 Markdown、解释、代码围栏或额外文字。
@ -26,8 +53,17 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务
6. 尺寸统一使用毫米,重量统一使用千克;每条 SKU 必须返回 dimensions 对象,没有尺寸证据时返回空对象 {},对象内没有证据的可选字段直接省略,不得编造精确数值。
7. 同结构 SKU 使用相同 structureGroupKey;每个结构组只在 structureGroups 中返回一次 bomLines,SKU 记录只绑定结构组,不得重复输出 BOM。
8. BOM 分类只能是 BOARD、STEEL、ACCESSORY、PACKAGING、PROCESSING、LABOR。
9. evidence 必须说明依据;引用图片时使用输入中真实的图片 URL。
10. confidence 必须在 0 到 1 之间。`
9. evidence 必须说明依据;引用图片时只能使用逐图事实中真实的 sourceImageUrl。
10. confidence 必须在 0 到 1 之间。
11. BOARD 的 lengthMm/widthMm 表示单块板件长宽,heightMm 只能表示板厚;没有板厚证据时必须省略 heightMm,并在 evidence.note 写明“板厚待确认”,不能把整机高度或部件外形高度填成板厚。
12. PACKAGING 必须按纸箱、泡沫、珍珠棉、护角、打包带等实际包材逐行拆分;packagingKind 必须与部件一致,尺寸表示单件包材规格,quantity 表示单套产品实际用量。看不出包材时不要编造。
13. materialName 使用可用于资料库匹配的材种或材料名称,例如“橡胶木”“刨花板”“碳钢”“瓦楞纸箱”,不要把颜色、营销等级或整段商品描述当作材种。
14. 每条 evidence 必须设置 basis:图片直接可见为 IMAGE;图片标注、详情文字或商品文案为 SOURCE_TEXT;SKU 名称、规格或属性为 SKU_METADATA;由装配或机构功能必需性推断的隐藏部件为 STRUCTURAL_INFERENCE。
15. STRUCTURAL_INFERENCE 只允许用于实现已确认结构或功能所必需的隐藏五金、连接件或保护包材;必须写明推断原因、数量估算和待确认项,evidence.confidence 与 BOM 行 confidence 都不得高于 0.55。
16. 安装知识只用于提醒检查,不是当前商品的事实。不得因为知识库列出了某种五金,就在缺少当前商品结构或功能依据时把它写进 BOM。
17. 逐图事实已经覆盖尺寸标注、局部小图、剖面图、结构示意、折叠前后状态和产品信息图;合并时不得遗漏这些事实。
18. SKU 名称或属性中的“折叠、升降、推拉、抽屉、脚轮、电动”等功能词必须触发对应机构检查;同时区分收拢态、展开态与整机尺寸。
19. 对板件之间、玻璃与框架、抽屉与柜体、门板与柜体等装配界面,必须检查连接方式;看不清时可以给出低置信度的必需连接件估算,但不得编造具体型号。`
const aiSkuAnalysisSchema = competitorSkuSchema
.omit({
@ -58,17 +94,14 @@ const aiAnalysisPayloadSchema = z.object({
skus: z.array(aiSkuAnalysisSchema).min(1).max(200),
})
interface MultimodalProviderConfig {
baseUrl: string
apiKey: string
model: string
}
export interface BomCostingMultimodalTrace {
model: string
callId: string | null
imageCount: number
inputSkuCount: number
factBatchCount: number
reusedImageCount: number
analyzedImageCount: number
durationMs: number
promptTokens: number | null
completionTokens: number | null
@ -79,133 +112,6 @@ export interface BomCostingMultimodalOutput {
trace: BomCostingMultimodalTrace
}
export interface BomCostingMultimodalInputImage {
kind: 'MAIN' | 'SKU' | 'DETAIL'
label: string
url: string
}
export interface BomCostingMultimodalInputStats {
imageCount: number
inputSkuCount: number
images: BomCostingMultimodalInputImage[]
}
interface StreamingChatCompletion {
id: string | null
model: string | null
content: string
promptTokens: number | null
completionTokens: number | null
}
const BOM_COSTING_MULTIMODAL_IMAGE_HARD_LIMIT = 8
function readBoundedInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum
? parsed
: fallback
}
function getAiServiceType() {
return process.env.BOM_COSTING_AI_SERVICE_TYPE?.trim() || 'prompt-optimizer'
}
async function getMultimodalProviderConfig(): Promise<MultimodalProviderConfig | null> {
const active = await providerConfigService.getActive(getAiServiceType())
if (!active?.baseUrl || !active.apiKey || !active.model) return null
return {
baseUrl: active.baseUrl,
apiKey: active.apiKey,
model: active.model,
}
}
export async function isBomCostingMultimodalConfigured() {
return (await getMultimodalProviderConfig()) !== null
}
export async function getBomCostingMultimodalModelName() {
return (await getMultimodalProviderConfig())?.model ?? null
}
function takeEvenlyDistributed<T>(items: T[], limit: number) {
if (limit <= 0 || items.length === 0) return []
if (items.length <= limit) return [...items]
if (limit === 1) return [items[0]!]
return Array.from({ length: limit }, (_, index) => {
const sourceIndex = Math.round(
(index * (items.length - 1)) / (limit - 1),
)
return items[sourceIndex]!
})
}
export function buildBomCostingMultimodalImages(
snapshot: MarketDetailSnapshot,
): BomCostingMultimodalInputImage[] {
const maxImages = readBoundedInteger(
process.env.BOM_COSTING_AI_MAX_IMAGES,
BOM_COSTING_MULTIMODAL_IMAGE_HARD_LIMIT,
1,
BOM_COSTING_MULTIMODAL_IMAGE_HARD_LIMIT,
)
const mainImages = snapshot.mainImages.map((url, index) => ({
kind: 'MAIN' as const,
label: `商品主图 ${index + 1}`,
url,
}))
const skuIdsByImage = new Map<string, string[]>()
for (const sku of snapshot.skus) {
if (!sku.imageUrl) continue
const ids = skuIdsByImage.get(sku.imageUrl) ?? []
ids.push(sku.externalSkuId)
skuIdsByImage.set(sku.imageUrl, ids)
}
const skuImages = Array.from(skuIdsByImage, ([url, skuIds]) => ({
kind: 'SKU' as const,
label: `SKU 图:${skuIds.join('、')}`,
url,
}))
const detailImages = snapshot.detailImages.map((url, index) => ({
kind: 'DETAIL' as const,
label: `商品详情图 ${index + 1}`,
url,
}))
const preferred = [
...takeEvenlyDistributed(mainImages, 3),
...takeEvenlyDistributed(skuImages, 3),
...takeEvenlyDistributed(detailImages, 2),
]
const remaining = [...mainImages, ...skuImages, ...detailImages]
const selected = new Map<string, BomCostingMultimodalInputImage>()
for (const image of [...preferred, ...remaining]) {
if (!selected.has(image.url)) selected.set(image.url, image)
if (selected.size >= maxImages) break
}
return Array.from(selected.values())
}
export function getBomCostingMultimodalInputStats(
snapshot: MarketDetailSnapshot,
): BomCostingMultimodalInputStats {
const images = buildBomCostingMultimodalImages(snapshot)
return {
imageCount: images.length,
inputSkuCount: snapshot.skus.length,
images,
}
}
function serializeSkuForPrompt(sku: MarketSkuSnapshot) {
return {
externalSkuId: sku.externalSkuId,
@ -221,10 +127,10 @@ function serializeSkuForPrompt(sku: MarketSkuSnapshot) {
function buildUserText(
snapshot: MarketDetailSnapshot,
images: BomCostingMultimodalInputImage[],
factSnapshot: BomCostingSourceFactSnapshot,
) {
return JSON.stringify({
task: '根据商品图片和 SKU 真相生成结构组级家具 BOM,并把每个 SKU 绑定到对应结构组',
task: '合并全部逐图事实与 SKU 真相,生成结构组级家具 BOM,并把每个 SKU 绑定到对应结构组',
requiredOutputShape: {
product: {
brand: 'string optional',
@ -232,7 +138,7 @@ function buildUserText(
dimensions: {
lengthMm: 'number optional',
widthMm: 'number optional',
heightMm: 'number optional',
heightMm: 'BOARD 时仅表示板厚;其他分类表示单件高度,number optional',
},
},
structureGroups: [{
@ -244,7 +150,7 @@ function buildUserText(
materialName: 'string optional',
materialSpec: 'string optional',
processName: 'string optional',
packagingKind: 'FOAM | FOAM_CORNER | PAPER_CORNER | PACKING_STRAP | CARTON | OTHER optional',
packagingKind: 'FOAM | FOAM_CORNER | EPE | PAPER_CORNER | PACKING_STRAP | CARTON | PE_BAG | STRETCH_FILM | ADHESIVE_TAPE | OTHER optional',
lengthMm: 'number optional',
widthMm: 'number optional',
heightMm: 'number optional',
@ -256,6 +162,7 @@ function buildUserText(
evidence: [{
sourceUrl: snapshot.detailUrl,
imageUrl: '输入中的图片 URL optional',
basis: 'IMAGE | SOURCE_TEXT | SKU_METADATA | STRUCTURAL_INFERENCE',
note: '依据说明 optional',
confidence: '0 to 1',
}],
@ -288,47 +195,11 @@ function buildUserText(
skuCount: snapshot.skus.length,
},
skuTruth: snapshot.skus.map(serializeSkuForPrompt),
imageLabels: images,
installationKnowledge: buildBomCostingInstallationKnowledge(snapshot),
extractedImageFacts: factSnapshot,
})
}
function parseJsonObject(content: string): unknown {
const trimmed = content.trim()
const withoutFence = trimmed
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/, '')
.trim()
try {
return JSON.parse(withoutFence)
} catch {
const start = withoutFence.indexOf('{')
const end = withoutFence.lastIndexOf('}')
if (start >= 0 && end > start) {
return JSON.parse(withoutFence.slice(start, end + 1))
}
throw new Error('模型响应不是 JSON 对象')
}
}
function describeMultimodalHttpError(status: number): string {
if (status === 401 || status === 403) {
return '多模态分析服务鉴权失败,请联系管理员检查模型配置'
}
if (status === 404) {
return '多模态分析接口或模型配置不正确,请联系管理员检查'
}
if (status === 429) {
return '多模态分析服务当前请求较多,请稍后重新发起'
}
if (status === 502 || status === 503 || status === 504) {
return '多模态分析服务暂时不可用,请稍后重新发起'
}
if (status === 524) {
return '多模态分析服务处理超时(524),本次任务未产生核算结果'
}
return `多模态分析服务返回 ${status}`
}
function describeAnalysisPayloadError(error: unknown): string {
if (error instanceof z.ZodError) {
const hasSkuIssue = error.issues.some((issue) => issue.path[0] === 'skus')
@ -341,87 +212,6 @@ function describeAnalysisPayloadError(error: unknown): string {
: '多模态分析返回的信息不完整,请重新处理'
}
async function readStreamingChatCompletion(
response: Response,
): Promise<StreamingChatCompletion> {
if (!response.body) {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'多模态分析服务未返回可读取的结果流',
)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let content = ''
let callId: string | null = null
let model: string | null = null
let promptTokens: number | null = null
let completionTokens: number | null = null
const consumeLine = (line: string) => {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) return
const data = trimmed.slice(5).trim()
if (!data || data === '[DONE]') return
let chunk: {
id?: string
model?: string
choices?: Array<{ delta?: { content?: string } }>
usage?: {
prompt_tokens?: number
completion_tokens?: number
}
}
try {
chunk = JSON.parse(data) as typeof chunk
} catch {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'多模态分析服务返回了无法读取的流式数据',
)
}
if (chunk.id?.trim()) callId = chunk.id.trim()
if (chunk.model?.trim()) model = chunk.model.trim()
const delta = chunk.choices?.[0]?.delta?.content
if (typeof delta === 'string') content += delta
if (Number.isFinite(chunk.usage?.prompt_tokens)) {
promptTokens = chunk.usage!.prompt_tokens!
}
if (Number.isFinite(chunk.usage?.completion_tokens)) {
completionTokens = chunk.usage!.completion_tokens!
}
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() ?? ''
lines.forEach(consumeLine)
}
buffer += decoder.decode()
if (buffer.trim()) {
buffer.split(/\r?\n/).forEach(consumeLine)
}
} finally {
reader.releaseLock()
}
return {
id: callId,
model,
content: content.trim(),
promptTokens,
completionTokens,
}
}
function assertExactSkuCoverage(
sourceSkus: MarketSkuSnapshot[],
analyzedSkus: z.infer<typeof competitorSkuSchema>[],
@ -502,19 +292,38 @@ function mergeSourceSkuTruth(
})
}
function addNullableNumber(left: number | null, right: number | null) {
if (left === null && right === null) return null
return (left ?? 0) + (right ?? 0)
}
export interface BomCostingMultimodalAnalyzeInput {
sourceUrl: string
snapshot: MarketDetailSnapshot
existingFactSnapshot?: BomCostingSourceFactSnapshot | null
onFactBatchStarted?: (
progress: Omit<BomCostingFactBatchProgress, 'snapshot' | 'call'>,
) => Promise<void>
onFactBatchCompleted?: (
progress: BomCostingFactBatchProgress,
) => Promise<void>
onFactExtractionCompleted?: (result: {
snapshot: BomCostingSourceFactSnapshot
trace: BomCostingFactExtractionTrace
}) => Promise<void>
}
export class BomCostingMultimodalAnalyzer {
async analyze(input: {
sourceUrl: string
snapshot: MarketDetailSnapshot
}): Promise<CompetitorAnalysisResultV1> {
async analyze(
input: BomCostingMultimodalAnalyzeInput,
): Promise<CompetitorAnalysisResultV1> {
return (await this.analyzeWithTrace(input)).result
}
async analyzeWithTrace(input: {
sourceUrl: string
snapshot: MarketDetailSnapshot
}): Promise<BomCostingMultimodalOutput> {
const config = await getMultimodalProviderConfig()
async analyzeWithTrace(
input: BomCostingMultimodalAnalyzeInput,
): Promise<BomCostingMultimodalOutput> {
const config = await getBomCostingMultimodalProviderConfig()
if (!config) {
throw new AppError(
ErrorCode.BOM_COSTING_ANALYSIS_NOT_CONFIGURED,
@ -522,85 +331,39 @@ export class BomCostingMultimodalAnalyzer {
)
}
const images = buildBomCostingMultimodalImages(input.snapshot)
if (images.length === 0) {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'竞品没有可供多模态分析的图片',
)
}
const factOutput = await extractBomCostingImageFacts({
config,
snapshot: input.snapshot,
existingSnapshot: input.existingFactSnapshot,
onBatchStarted: input.onFactBatchStarted,
onBatchCompleted: input.onFactBatchCompleted,
})
await input.onFactExtractionCompleted?.(factOutput)
const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`
const timeoutMs = readBoundedInteger(
const timeoutMs = readBomCostingBoundedInteger(
process.env.BOM_COSTING_AI_TIMEOUT_MS,
180_000,
30_000,
300_000,
)
const maxTokens = readBoundedInteger(
const maxTokens = readBomCostingBoundedInteger(
process.env.BOM_COSTING_AI_MAX_TOKENS,
24_000,
2_000,
40_000,
)
const userContent: Array<
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }
> = [{ type: 'text', text: buildUserText(input.snapshot, images) }]
for (const image of images) {
userContent.push({ type: 'text', text: image.label })
userContent.push({ type: 'image_url', image_url: { url: image.url } })
}
const startedAt = Date.now()
let response: Response
try {
response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
response_format: { type: 'json_object' },
temperature: 0.1,
max_tokens: maxTokens,
stream: true,
}),
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs),
})
} catch (error) {
const message =
error instanceof Error && error.name === 'TimeoutError'
? '多模态 BOM 分析超时'
: '多模态分析服务暂时无法连接'
throw new AppError(ErrorCode.BOM_COSTING_INVALID_PROVIDER, message)
}
if (!response.ok) {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
describeMultimodalHttpError(response.status),
)
}
const responsePayload = await readStreamingChatCompletion(response)
const content = responsePayload.content
if (!content) {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'多模态分析服务没有返回 BOM 结果',
)
}
const responsePayload = await callBomCostingMultimodalModel({
config,
systemPrompt: SYSTEM_PROMPT,
userContent: buildUserText(input.snapshot, factOutput.snapshot),
maxTokens,
timeoutMs,
})
try {
const parsedPayload = aiAnalysisPayloadSchema.parse(parseJsonObject(content))
const parsedPayload = aiAnalysisPayloadSchema.parse(
parseBomCostingJsonObject(responsePayload.content),
)
const analyzedSkus = expandStructureGroupBom(parsedPayload)
assertExactSkuCoverage(input.snapshot.skus, analyzedSkus)
const imageUrls = Array.from(
@ -627,13 +390,23 @@ export class BomCostingMultimodalAnalyzer {
return {
result,
trace: {
model: responsePayload.model || config.model,
model: responsePayload.model,
callId: responsePayload.id,
imageCount: images.length,
imageCount: factOutput.snapshot.imageFacts.length,
inputSkuCount: input.snapshot.skus.length,
durationMs: Date.now() - startedAt,
promptTokens: responsePayload.promptTokens,
completionTokens: responsePayload.completionTokens,
factBatchCount: factOutput.trace.batchCount,
reusedImageCount: factOutput.trace.reusedImageCount,
analyzedImageCount: factOutput.trace.analyzedImageCount,
durationMs:
factOutput.trace.durationMs + responsePayload.durationMs,
promptTokens: addNullableNumber(
factOutput.trace.promptTokens,
responsePayload.promptTokens,
),
completionTokens: addNullableNumber(
factOutput.trace.completionTokens,
responsePayload.completionTokens,
),
},
}
} catch (error) {

View File

@ -0,0 +1,237 @@
import 'server-only'
import { AppError, ErrorCode } from '@/server/core/errors'
import { providerConfigService } from '@/server/services/provider-config.service'
export interface BomCostingMultimodalProviderConfig {
baseUrl: string
apiKey: string
model: string
}
export type BomCostingMultimodalUserContent =
| string
| Array<
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }
>
export interface BomCostingMultimodalCallResult {
id: string | null
model: string
content: string
promptTokens: number | null
completionTokens: number | null
durationMs: number
}
export function readBomCostingBoundedInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum
? parsed
: fallback
}
function getAiServiceType() {
return process.env.BOM_COSTING_AI_SERVICE_TYPE?.trim() || 'prompt-optimizer'
}
export async function getBomCostingMultimodalProviderConfig():
Promise<BomCostingMultimodalProviderConfig | null> {
const active = await providerConfigService.getActive(getAiServiceType())
if (!active?.baseUrl || !active.apiKey || !active.model) return null
return {
baseUrl: active.baseUrl,
apiKey: active.apiKey,
model: active.model,
}
}
export function parseBomCostingJsonObject(content: string): unknown {
const trimmed = content.trim()
const withoutFence = trimmed
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/, '')
.trim()
try {
return JSON.parse(withoutFence)
} catch {
const start = withoutFence.indexOf('{')
const end = withoutFence.lastIndexOf('}')
if (start >= 0 && end > start) {
return JSON.parse(withoutFence.slice(start, end + 1))
}
throw new Error('模型响应不是 JSON 对象')
}
}
export async function isBomCostingMultimodalConfigured() {
return (await getBomCostingMultimodalProviderConfig()) !== null
}
export async function getBomCostingMultimodalModelName() {
return (await getBomCostingMultimodalProviderConfig())?.model ?? null
}
function describeMultimodalHttpError(status: number): string {
if (status === 401 || status === 403) {
return '多模态分析服务鉴权失败,请联系管理员检查模型配置'
}
if (status === 404) {
return '多模态分析接口或模型配置不正确,请联系管理员检查'
}
if (status === 429) {
return '多模态分析服务当前请求较多,请稍后重新发起'
}
if (status === 502 || status === 503 || status === 504) {
return '多模态分析服务暂时不可用,请稍后重新发起'
}
if (status === 524) {
return '多模态分析服务处理超时(524),本次任务未产生核算结果'
}
return `多模态分析服务返回 ${status}`
}
async function readStreamingChatCompletion(response: Response) {
if (!response.body) {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'多模态分析服务未返回可读取的结果流',
)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let content = ''
let callId: string | null = null
let model: string | null = null
let promptTokens: number | null = null
let completionTokens: number | null = null
const consumeLine = (line: string) => {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) return
const data = trimmed.slice(5).trim()
if (!data || data === '[DONE]') return
let chunk: {
id?: string
model?: string
choices?: Array<{ delta?: { content?: string } }>
usage?: {
prompt_tokens?: number
completion_tokens?: number
}
}
try {
chunk = JSON.parse(data) as typeof chunk
} catch {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'多模态分析服务返回了无法读取的流式数据',
)
}
if (chunk.id?.trim()) callId = chunk.id.trim()
if (chunk.model?.trim()) model = chunk.model.trim()
const delta = chunk.choices?.[0]?.delta?.content
if (typeof delta === 'string') content += delta
if (Number.isFinite(chunk.usage?.prompt_tokens)) {
promptTokens = chunk.usage!.prompt_tokens!
}
if (Number.isFinite(chunk.usage?.completion_tokens)) {
completionTokens = chunk.usage!.completion_tokens!
}
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() ?? ''
lines.forEach(consumeLine)
}
buffer += decoder.decode()
if (buffer.trim()) {
buffer.split(/\r?\n/).forEach(consumeLine)
}
} finally {
reader.releaseLock()
}
return {
id: callId,
model,
content: content.trim(),
promptTokens,
completionTokens,
}
}
export async function callBomCostingMultimodalModel(input: {
config: BomCostingMultimodalProviderConfig
systemPrompt: string
userContent: BomCostingMultimodalUserContent
maxTokens: number
timeoutMs: number
}): Promise<BomCostingMultimodalCallResult> {
const endpoint = `${input.config.baseUrl.replace(/\/+$/, '')}/chat/completions`
const startedAt = Date.now()
let response: Response
try {
response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${input.config.apiKey}`,
},
body: JSON.stringify({
model: input.config.model,
messages: [
{ role: 'system', content: input.systemPrompt },
{ role: 'user', content: input.userContent },
],
response_format: { type: 'json_object' },
temperature: 0.1,
max_tokens: input.maxTokens,
stream: true,
}),
cache: 'no-store',
signal: AbortSignal.timeout(input.timeoutMs),
})
} catch (error) {
const message =
error instanceof Error && error.name === 'TimeoutError'
? '多模态 BOM 分析超时'
: '多模态分析服务暂时无法连接'
throw new AppError(ErrorCode.BOM_COSTING_INVALID_PROVIDER, message)
}
if (!response.ok) {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
describeMultimodalHttpError(response.status),
)
}
const result = await readStreamingChatCompletion(response)
if (!result.content) {
throw new AppError(
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
'多模态分析服务没有返回可读取的结果',
)
}
return {
...result,
model: result.model || input.config.model,
durationMs: Date.now() - startedAt,
}
}

View File

@ -6,6 +6,11 @@ import {
BomCostingSourceAssetStatus,
BomCostingSourceStatus,
} from '@prisma/client'
import {
BOM_COSTING_FACT_ANALYSIS_STATUS,
BOM_COSTING_FACT_SCHEMA_VERSION,
type BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
import { marketDetailClient, type MarketDetailSnapshot } from './market-detail-client'
@ -161,6 +166,60 @@ const analysisResult = {
analyzedAt: new Date().toISOString(),
}
const factSnapshot: BomCostingSourceFactSnapshot = {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: 'IMG_0001',
sourceImageUrl: 'https://storage.example.com/stored-main',
kind: 'MAIN',
label: '商品主图 1',
externalSkuIds: [],
observedTexts: [],
facts: [],
}, {
imageId: 'IMG_0002',
sourceImageUrl: 'https://storage.example.com/stored-sku',
kind: 'SKU',
label: 'SKU 图:sku-1',
externalSkuIds: ['sku-1'],
observedTexts: [],
facts: [],
}],
extractedAt: new Date().toISOString(),
}
function factAnalysisRecord(
status: (typeof BOM_COSTING_FACT_ANALYSIS_STATUS)[
keyof typeof BOM_COSTING_FACT_ANALYSIS_STATUS
] = BOM_COSTING_FACT_ANALYSIS_STATUS.PENDING,
) {
return {
id: 'fact-analysis-1',
sourceSnapshotId: 'source-1',
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint: 'asset-fingerprint',
status,
model: 'grok-4.5',
factSnapshot:
status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY
? factSnapshot
: null,
analyzedImageCount:
status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY ? 2 : 0,
totalImageCount: 2,
batchCount:
status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY ? 1 : 0,
promptTokens: 0,
completionTokens: 0,
durationMs: 0,
errorMessage: '',
startedAt: null,
completedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}
}
function caseRecord() {
return {
id: 'case-1',
@ -197,6 +256,18 @@ describe('BomCostingProcessingService', () => {
jest.spyOn(bomCostingRepository, 'findCaseForProcessing')
.mockResolvedValue(caseRecord() as never)
jest.spyOn(bomCostingRepository, 'updateCaseStage').mockResolvedValue({} as never)
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord() as never)
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord() as never)
jest.spyOn(bomCostingRepository, 'claimSourceFactAnalysis')
.mockResolvedValue(true)
jest.spyOn(bomCostingRepository, 'saveSourceFactAnalysisBatch')
.mockResolvedValue({} as never)
jest.spyOn(bomCostingRepository, 'completeSourceFactAnalysis')
.mockResolvedValue({} as never)
jest.spyOn(bomCostingRepository, 'failSourceFactAnalysis')
.mockResolvedValue({} as never)
jest.spyOn(bomCostingRepository, 'listPriceCandidates').mockResolvedValue({
BOARD: [],
STEEL: [],
@ -217,7 +288,62 @@ describe('BomCostingProcessingService', () => {
it('缓存命中时不再调用市场扒图接口,并完成模型与成本阶段', async () => {
const marketSpy = jest.spyOn(marketDetailClient, 'readProduct')
mockRunAnalysis.mockResolvedValue({
mockRunAnalysis.mockImplementation(async (input) => {
const analysisInput = input as {
onFactBatchStarted?: (progress: unknown) => Promise<void>
onFactBatchCompleted?: (progress: unknown) => Promise<void>
onFactExtractionCompleted?: (result: unknown) => Promise<void>
}
const batchImages = [{
imageId: 'IMG_0001',
kind: 'MAIN',
label: '商品主图 1',
url: 'https://storage.example.com/stored-main',
externalSkuIds: [],
}, {
imageId: 'IMG_0002',
kind: 'SKU',
label: 'SKU 图:sku-1',
url: 'https://storage.example.com/stored-sku',
externalSkuIds: ['sku-1'],
}]
await analysisInput.onFactBatchStarted?.({
batchIndex: 1,
totalBatches: 1,
batchImages,
completedImageCount: 0,
totalImageCount: 2,
})
const call = {
id: 'fact-call-1',
model: 'grok-4.5',
durationMs: 20_000,
promptTokens: 60,
completionTokens: 80,
}
await analysisInput.onFactBatchCompleted?.({
batchIndex: 1,
totalBatches: 1,
batchImages,
completedImageCount: 2,
totalImageCount: 2,
snapshot: factSnapshot,
call,
})
await analysisInput.onFactExtractionCompleted?.({
snapshot: factSnapshot,
trace: {
model: 'grok-4.5',
callIds: ['fact-call-1'],
batchCount: 1,
reusedImageCount: 0,
analyzedImageCount: 2,
durationMs: 20_000,
promptTokens: 60,
completionTokens: 80,
},
})
return {
result: analysisResult,
trace: {
model: 'grok-4.5',
@ -228,6 +354,7 @@ describe('BomCostingProcessingService', () => {
promptTokens: 100,
completionTokens: 200,
},
}
})
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
@ -252,6 +379,20 @@ describe('BomCostingProcessingService', () => {
'case-1',
expect.objectContaining({ eventType: 'MODEL_ANALYSIS_COMPLETED' }),
)
expect(bomCostingRepository.saveSourceFactAnalysisBatch).toHaveBeenCalledWith(
expect.objectContaining({
id: 'fact-analysis-1',
analyzedImageCount: 2,
totalImageCount: 2,
}),
)
expect(bomCostingRepository.completeSourceFactAnalysis).toHaveBeenCalledWith(
expect.objectContaining({
id: 'fact-analysis-1',
analyzedImageCount: 2,
totalImageCount: 2,
}),
)
expect(bomCostingRepository.updateCaseStage).toHaveBeenCalledWith(
'case-1',
expect.objectContaining({
@ -288,6 +429,91 @@ describe('BomCostingProcessingService', () => {
'多模态分析服务处理超时(524)',
)
expect(bomCostingRepository.failSourceSnapshot).not.toHaveBeenCalled()
expect(bomCostingRepository.failSourceFactAnalysis).toHaveBeenCalledWith(
'fact-analysis-1',
'多模态分析服务处理超时(524)',
)
expect(mockRunAnalysis).toHaveBeenCalledTimes(1)
})
it('逐图事实保存完成后最终合成失败,不把事实缓存回退为失败', async () => {
mockRunAnalysis.mockImplementation(async (input) => {
const analysisInput = input as {
onFactExtractionCompleted?: (result: unknown) => Promise<void>
}
await analysisInput.onFactExtractionCompleted?.({
snapshot: factSnapshot,
trace: {
model: 'grok-4.5',
callIds: ['fact-call-1'],
batchCount: 1,
reusedImageCount: 0,
analyzedImageCount: 2,
durationMs: 20_000,
promptTokens: 60,
completionTokens: 80,
},
})
throw new Error('多模态分析服务处理超时(524)')
})
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingRepository.completeSourceFactAnalysis).toHaveBeenCalledWith(
expect.objectContaining({
id: 'fact-analysis-1',
analyzedImageCount: 2,
totalImageCount: 2,
}),
)
expect(bomCostingRepository.failSourceFactAnalysis).not.toHaveBeenCalled()
expect(bomCostingRepository.failCase).toHaveBeenCalledWith(
'case-1',
'多模态分析服务处理超时(524)',
)
})
it('逐图事实已完整时直接复用,只执行最终结构合成', async () => {
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(
BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
) as never)
mockRunAnalysis.mockResolvedValue({
result: analysisResult,
trace: {
model: 'grok-4.5',
callId: 'synthesis-call-1',
imageCount: 2,
inputSkuCount: 1,
factBatchCount: 0,
reusedImageCount: 2,
analyzedImageCount: 0,
durationMs: 10_000,
promptTokens: 80,
completionTokens: 120,
},
})
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingRepository.claimSourceFactAnalysis).not.toHaveBeenCalled()
expect(mockRunAnalysis).toHaveBeenCalledWith(
expect.objectContaining({
existingFactSnapshot: factSnapshot,
onFactBatchStarted: undefined,
onFactBatchCompleted: undefined,
}),
)
expect(bomCostingRepository.updateCaseStage).toHaveBeenCalledWith(
'case-1',
expect.objectContaining({ eventType: 'MODEL_FACT_CACHE_REUSED' }),
)
expect(bomCostingRepository.saveSourceFactAnalysisBatch).not.toHaveBeenCalled()
})
})

View File

@ -1,5 +1,6 @@
import 'server-only'
import { createHash } from 'node:crypto'
import {
BomCostingCaseStatus,
BomCostingMatchStatus,
@ -8,6 +9,13 @@ import {
Prisma,
} from '@prisma/client'
import { competitorAnalysisResultV1Schema } from '@/lib/bom-costing/contracts'
import {
BOM_COSTING_FACT_ANALYSIS_STATUS,
BOM_COSTING_FACT_SCHEMA_VERSION,
bomCostingSourceFactSnapshotSchema,
type BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import { buildBomCostingImageIdentity } from '@/lib/bom-costing/image-identity'
import { readObjectStorageKeyFromUrl } from '@/lib/storage'
import { AppError } from '@/server/core/errors'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
@ -49,6 +57,52 @@ async function waitForSourceReady(sourceSnapshotId: string) {
throw new Error('等待竞品资料归档超时')
}
async function waitForSourceFactAnalysisReady(analysisId: string) {
const deadline = Date.now() + 10 * 60_000
while (Date.now() < deadline) {
const analysis = await bomCostingRepository.findSourceFactAnalysis(
analysisId,
)
if (!analysis) throw new Error('竞品逐图事实记录不存在')
if (analysis.status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY) {
return analysis
}
if (analysis.status === BOM_COSTING_FACT_ANALYSIS_STATUS.FAILED) {
throw new Error(analysis.errorMessage || '竞品逐图事实提取失败')
}
await sleep(1_000)
}
throw new Error('等待竞品逐图事实提取超时')
}
function buildSourceAssetFingerprint(
source: NonNullable<
Awaited<ReturnType<typeof bomCostingRepository.findSourceSnapshotById>>
>,
) {
const input = [
...source.assets
.filter(
(asset) =>
asset.status === BomCostingSourceAssetStatus.STORED &&
asset.storageKey,
)
.map((asset) => [
asset.assetKey,
asset.storageKey,
asset.kind,
JSON.stringify(asset.externalSkuIds),
].join(':'))
.sort(),
].join('\n')
return createHash('sha256').update(input).digest('hex')
}
function parseSourceFactSnapshot(value: Prisma.JsonValue | null) {
if (value === null) return null
return bomCostingSourceFactSnapshotSchema.parse(value)
}
function buildResultStats(
skus: ReturnType<typeof prepareBomCostingAnalysis>,
) {
@ -163,6 +217,8 @@ export class BomCostingProcessingService {
let sourceOwnedByTask = false
let sourceSnapshotId: string | null = null
let factAnalysisOwnedByTask = false
let factAnalysisId: string | null = null
try {
const record = await bomCostingRepository.findCaseForProcessing(caseId)
if (!record) throw new Error('BOM 核算案例不存在')
@ -313,22 +369,226 @@ export class BomCostingProcessingService {
analysisInputStats.images,
archivedSource,
)
const analysisPlanByIdentity = new Map(
analysisInputStats.images.map((image, index) => [
buildBomCostingImageIdentity(image.url),
analysisImages[index],
] as const),
)
let existingFactSnapshot: BomCostingSourceFactSnapshot | null = null
if (
archivedSource &&
marketSnapshot &&
analysisInputStats.imageCount > 0
) {
const factAnalysis = await bomCostingRepository.prepareSourceFactAnalysis({
sourceSnapshotId: archivedSource.id,
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint: buildSourceAssetFingerprint(archivedSource),
model: modelName ?? '',
totalImageCount: analysisInputStats.imageCount,
})
factAnalysisId = factAnalysis.id
if (factAnalysis.status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY) {
existingFactSnapshot = parseSourceFactSnapshot(
factAnalysis.factSnapshot,
)
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_FACT_CACHE_REUSED',
status: BomCostingCaseStatus.ANALYZING,
progress: 76,
message: `已复用 ${factAnalysis.analyzedImageCount} 张图片的分析事实`,
payload: {
factAnalysisId: factAnalysis.id,
analyzedImageCount: factAnalysis.analyzedImageCount,
totalImageCount: factAnalysis.totalImageCount,
batchCount: factAnalysis.batchCount,
model: factAnalysis.model,
},
})
} else {
factAnalysisOwnedByTask =
await bomCostingRepository.claimSourceFactAnalysis(
factAnalysis.id,
)
if (factAnalysisOwnedByTask) {
existingFactSnapshot = parseSourceFactSnapshot(
factAnalysis.factSnapshot,
)
} else {
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_FACT_CACHE_WAITING',
status: BomCostingCaseStatus.ANALYZING,
progress: 50,
message: '同一竞品正在提取逐图事实,等待复用结果',
payload: {
factAnalysisId: factAnalysis.id,
},
})
const readyFactAnalysis =
await waitForSourceFactAnalysisReady(factAnalysis.id)
existingFactSnapshot = parseSourceFactSnapshot(
readyFactAnalysis.factSnapshot,
)
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_FACT_CACHE_REUSED',
status: BomCostingCaseStatus.ANALYZING,
progress: 76,
message: `已复用 ${readyFactAnalysis.analyzedImageCount} 张图片的分析事实`,
payload: {
factAnalysisId: readyFactAnalysis.id,
analyzedImageCount: readyFactAnalysis.analyzedImageCount,
totalImageCount: readyFactAnalysis.totalImageCount,
batchCount: readyFactAnalysis.batchCount,
model: readyFactAnalysis.model,
},
})
}
}
}
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_ANALYSIS_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: 52,
message: '正在识别产品结构、材质、尺寸与包装信息',
progress: existingFactSnapshot ? 76 : 52,
message: existingFactSnapshot
? '逐图事实已就绪,准备合并产品结构与成本信息'
: `准备逐批分析全部 ${analysisInputStats.imageCount} 张竞品图片`,
payload: {
model: modelName,
imageCount: analysisInputStats.imageCount,
archivedImageCount: archivedSource?.storedImageCount ?? 0,
skuCount: analysisInputStats.inputSkuCount,
analysisImages,
reusedAnalysisImages:
existingFactSnapshot?.imageFacts.flatMap((image) => {
const analysisImage = analysisPlanByIdentity.get(
buildBomCostingImageIdentity(image.sourceImageUrl),
)
return analysisImage
? [{
...analysisImage,
imageId: image.imageId,
}]
: []
}) ?? [],
},
})
const output = await provider.runAnalysis({
sourceUrl: record.sourceUrl,
snapshot: marketSnapshot,
existingFactSnapshot,
onFactBatchStarted:
factAnalysisOwnedByTask && factAnalysisId
? async (progress) => {
const caseProgress =
52 +
Math.round(
(progress.completedImageCount /
Math.max(progress.totalImageCount, 1)) *
24,
)
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_FACT_BATCH_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: caseProgress,
message:
`正在分析第 ${progress.batchIndex}/${progress.totalBatches} 批图片` +
`(已完成 ${progress.completedImageCount}/${progress.totalImageCount})`,
payload: {
factAnalysisId,
batchIndex: progress.batchIndex,
totalBatches: progress.totalBatches,
completedImageCount: progress.completedImageCount,
totalImageCount: progress.totalImageCount,
batchImages: progress.batchImages.map((image) => ({
...analysisPlanByIdentity.get(
buildBomCostingImageIdentity(image.url),
),
imageId: image.imageId,
})),
},
})
}
: undefined,
onFactBatchCompleted:
factAnalysisOwnedByTask && factAnalysisId
? async (progress) => {
await bomCostingRepository.saveSourceFactAnalysisBatch({
id: factAnalysisId!,
factSnapshot:
progress.snapshot as unknown as Prisma.InputJsonValue,
analyzedImageCount: progress.completedImageCount,
totalImageCount: progress.totalImageCount,
model: progress.call.model,
promptTokens: progress.call.promptTokens ?? 0,
completionTokens: progress.call.completionTokens ?? 0,
durationMs: progress.call.durationMs,
})
const caseProgress =
52 +
Math.round(
(progress.completedImageCount /
Math.max(progress.totalImageCount, 1)) *
24,
)
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_FACT_BATCH_COMPLETED',
status: BomCostingCaseStatus.ANALYZING,
progress: caseProgress,
message:
`已分析 ${progress.completedImageCount}/${progress.totalImageCount} 张图片`,
payload: {
factAnalysisId,
batchIndex: progress.batchIndex,
totalBatches: progress.totalBatches,
completedImageCount: progress.completedImageCount,
totalImageCount: progress.totalImageCount,
callId: progress.call.id,
model: progress.call.model,
durationMs: progress.call.durationMs,
batchImages: progress.batchImages.map((image) => ({
...analysisPlanByIdentity.get(
buildBomCostingImageIdentity(image.url),
),
imageId: image.imageId,
})),
},
})
}
: undefined,
onFactExtractionCompleted: async (result) => {
if (factAnalysisOwnedByTask && factAnalysisId) {
await bomCostingRepository.completeSourceFactAnalysis({
id: factAnalysisId,
factSnapshot:
result.snapshot as unknown as Prisma.InputJsonValue,
analyzedImageCount: result.snapshot.imageFacts.length,
totalImageCount: analysisInputStats.imageCount,
model: result.trace.model,
})
factAnalysisOwnedByTask = false
}
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_FACT_EXTRACTION_COMPLETED',
status: BomCostingCaseStatus.ANALYZING,
progress: 77,
message:
`全部 ${result.snapshot.imageFacts.length} 张图片事实已就绪`,
payload: {
factAnalysisId,
imageCount: result.snapshot.imageFacts.length,
analyzedImageCount: result.trace.analyzedImageCount,
reusedImageCount: result.trace.reusedImageCount,
batchCount: result.trace.batchCount,
},
})
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_SYNTHESIS_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: 78,
message: '正在合并逐图事实并生成各 SKU 的结构化 BOM',
})
},
})
const persistedResult = archivedSource
? replaceAnalysisImageUrlsWithStableArchive(output.result, archivedSource)
@ -377,6 +637,11 @@ export class BomCostingProcessingService {
})
} catch (error) {
const message = readErrorMessage(error)
if (factAnalysisOwnedByTask && factAnalysisId) {
await bomCostingRepository
.failSourceFactAnalysis(factAnalysisId, message)
.catch(() => undefined)
}
if (sourceOwnedByTask && sourceSnapshotId) {
await bomCostingRepository
.failSourceSnapshot(sourceSnapshotId, message)

View File

@ -7,6 +7,9 @@ import {
type BomCostingCapabilities,
type CompetitorAnalysisResultV1,
} from '@/lib/bom-costing/contracts'
import type {
BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import {
bomCostingMultimodalAnalyzer,
getBomCostingMultimodalInputStats,
@ -15,6 +18,10 @@ import {
type BomCostingMultimodalInputStats,
type BomCostingMultimodalOutput,
} from './bom-costing-multimodal-analyzer'
import type {
BomCostingFactBatchProgress,
BomCostingFactExtractionTrace,
} from './bom-costing-image-fact-extractor'
import type { MarketDetailSnapshot } from './market-detail-client'
export interface CompetitorAnalysisProvider {
@ -27,6 +34,17 @@ export interface CompetitorAnalysisProvider {
runAnalysis(input: {
sourceUrl: string
snapshot: MarketDetailSnapshot | null
existingFactSnapshot?: BomCostingSourceFactSnapshot | null
onFactBatchStarted?: (
progress: Omit<BomCostingFactBatchProgress, 'snapshot' | 'call'>,
) => Promise<void>
onFactBatchCompleted?: (
progress: BomCostingFactBatchProgress,
) => Promise<void>
onFactExtractionCompleted?: (result: {
snapshot: BomCostingSourceFactSnapshot
trace: BomCostingFactExtractionTrace
}) => Promise<void>
}): Promise<BomCostingMultimodalOutput>
}
@ -258,6 +276,9 @@ class FixtureCompetitorAnalysisProvider implements CompetitorAnalysisProvider {
callId: null,
imageCount: 0,
inputSkuCount: result.skus.length,
factBatchCount: 0,
reusedImageCount: 0,
analyzedImageCount: 0,
durationMs: 0,
promptTokens: null,
completionTokens: null,
@ -290,12 +311,23 @@ class MarketDetailMultimodalProvider implements CompetitorAnalysisProvider {
async runAnalysis(input: {
sourceUrl: string
snapshot: MarketDetailSnapshot | null
existingFactSnapshot?: BomCostingSourceFactSnapshot | null
onFactBatchStarted?: (
progress: Omit<BomCostingFactBatchProgress, 'snapshot' | 'call'>,
) => Promise<void>
onFactBatchCompleted?: (
progress: BomCostingFactBatchProgress,
) => Promise<void>
onFactExtractionCompleted?: (result: {
snapshot: BomCostingSourceFactSnapshot
trace: BomCostingFactExtractionTrace
}) => Promise<void>
}) {
if (!input.snapshot) {
throw new Error('竞品资料快照不存在,不能开始多模态分析')
}
return bomCostingMultimodalAnalyzer.analyzeWithTrace({
sourceUrl: input.sourceUrl,
...input,
snapshot: input.snapshot,
})
}

View File

@ -28,9 +28,13 @@ function normalizePackagingKind(value?: string) {
const supported = new Set<BomCostingPackagingKind>([
'FOAM',
'FOAM_CORNER',
'EPE',
'PAPER_CORNER',
'PACKING_STRAP',
'CARTON',
'PE_BAG',
'STRETCH_FILM',
'ADHESIVE_TAPE',
'OTHER',
])
return supported.has(normalized as BomCostingPackagingKind)
@ -70,6 +74,7 @@ function createExternalEstimateCandidate(
spec: line.processName ?? '',
unit: line.unit,
unitPrice: line.estimatedUnitPrice,
pricingMode: 'UNIT',
}
}
@ -87,11 +92,17 @@ function prepareLine(
code: line.materialCode,
name: line.materialName || line.partName,
spec: line.materialSpec,
thicknessMm: line.category === 'BOARD' ? line.heightMm : null,
packagingKind:
line.category === 'PACKAGING'
? normalizePackagingKind(line.packagingKind)
: null,
})
const matchedCandidate = match.status === 'MATCHED' ? match.candidate : null
const calculation = calculateBomCostingLine({
category: line.category,
packagingKind: normalizePackagingKind(line.packagingKind),
pricingMode: matchedCandidate?.pricingMode,
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
@ -155,7 +166,9 @@ export function prepareBomCostingAnalysis(
subtotal: line.subtotal,
})),
)
const allLinesPriced = summary.unresolvedLineCount === 0
const allLinesPriced =
summary.unresolvedLineCount === 0 &&
summary.missingCostGroups.length === 0
return {
externalSkuId: sku.externalSkuId,

View File

@ -1,5 +1,6 @@
/** @jest-environment node */
import { Prisma } from '@prisma/client'
import { ErrorCode } from '@/server/core/errors'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
import { bomCostingService } from './bom-costing.service'
@ -104,4 +105,135 @@ describe('BomCostingService', () => {
resumed: false,
})
})
it('板材缺厚度时返回同材种候选并要求用户确认', async () => {
jest.spyOn(bomCostingRepository, 'findLineForUpdate').mockResolvedValue({
id: 'line-board',
category: 'BOARD',
materialCode: '',
materialName: 'FAS级橡胶木实木板',
materialSpec: '',
partName: '桌面板',
packagingKind: '',
heightMm: null,
sku: {
case: {
id: 'case-1',
createdById: operationsUser.id,
deletedAt: null,
},
},
} as never)
jest.spyOn(bomCostingRepository, 'listPriceCandidates').mockResolvedValue({
BOARD: [
{
libraryType: 'SUBSTRATE',
recordId: 'rubberwood-18',
code: 'RW-18',
name: '18ENF级橡胶木',
aliases: ['橡胶木'],
materialFamily: '橡胶木',
thicknessMm: 18,
spec: '18mm · ENF',
unit: '㎡',
unitPrice: 80.63,
pricingMode: 'AREA_SQM',
},
{
libraryType: 'SUBSTRATE',
recordId: 'rubberwood-30',
code: 'RW-30',
name: '30ENF级橡胶木',
aliases: ['橡胶木'],
materialFamily: '橡胶木',
thicknessMm: 30,
spec: '30mm · ENF',
unit: '㎡',
unitPrice: 117.58,
pricingMode: 'AREA_SQM',
},
],
STEEL: [],
ACCESSORY: [],
PACKAGING: [],
PROCESSING: [],
LABOR: [],
})
const candidates = await bomCostingService.listLinePriceCandidates(
'case-1',
'line-board',
operationsUser,
)
expect(candidates).toEqual([
expect.objectContaining({
recordId: 'rubberwood-18',
thicknessMm: 18,
recommended: true,
}),
expect.objectContaining({
recordId: 'rubberwood-30',
thicknessMm: 30,
recommended: true,
}),
])
})
it('历史包材价格不是资产中心规格款时不再展示完整成本', async () => {
jest.spyOn(bomCostingRepository, 'listCases').mockResolvedValue([
{
id: 'case-legacy-package',
title: '历史案例',
sourceUrl: 'https://example.com/item/1',
sourceHost: 'example.com',
status: 'READY',
progress: 100,
statusMessage: '已完成',
sourceCacheHit: false,
sourceSnapshot: {
status: 'READY',
imageCount: 1,
storedImageCount: 1,
failedImageCount: 0,
},
createdById: operationsUser.id,
createdAt: new Date('2026-07-29T00:00:00.000Z'),
skus: [
{
id: 'sku-legacy-package',
externalSkuId: 'sku-1',
name: '测试规格',
estimatedCost: new Prisma.Decimal(168.4),
bomLines: [
{
category: 'BOARD',
matchedLibraryType: 'SUBSTRATE',
subtotal: new Prisma.Decimal(96.56),
},
{
category: 'PROCESSING',
matchedLibraryType: 'EXTERNAL_ANALYSIS',
subtotal: new Prisma.Decimal(28),
},
{
category: 'LABOR',
matchedLibraryType: 'EXTERNAL_ANALYSIS',
subtotal: new Prisma.Decimal(18),
},
{
category: 'PACKAGING',
matchedLibraryType: 'PACKAGING',
subtotal: new Prisma.Decimal(25.84),
},
],
},
],
},
] as never)
const [summary] = await bomCostingService.listCases(operationsUser)
expect(summary.estimatedCostRange).toBeNull()
})
})

View File

@ -19,6 +19,7 @@ import {
type BomCostingPackagingKind,
} from '@/lib/bom-costing/cost-calculator'
import {
matchBomCostingLibraryCandidate,
type BomCostingLibraryCandidate,
} from '@/lib/bom-costing/price-matcher'
import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
@ -46,18 +47,34 @@ const CATEGORY_LABELS: Record<BomCostingLineCategory, string> = {
LABOR: '人工',
}
const CURRENT_PACKAGING_LIBRARY_TYPE = 'ASSET_PACKAGING_VARIANT'
function decimalToNumber(value: Prisma.Decimal | null | undefined) {
return value === null || value === undefined ? null : value.toNumber()
}
function isLegacyPackagingPriceSource(input: {
category: BomCostingLineCategory
matchedLibraryType: string
}) {
return (
input.category === BomCostingLineCategory.PACKAGING &&
input.matchedLibraryType !== CURRENT_PACKAGING_LIBRARY_TYPE
)
}
function normalizePackagingKind(value?: string) {
const normalized = value?.trim().toUpperCase() ?? ''
const supported = new Set<BomCostingPackagingKind>([
'FOAM',
'FOAM_CORNER',
'EPE',
'PAPER_CORNER',
'PACKING_STRAP',
'CARTON',
'PE_BAG',
'STRETCH_FILM',
'ADHESIVE_TAPE',
'OTHER',
])
return supported.has(normalized as BomCostingPackagingKind)
@ -81,6 +98,7 @@ function createSnapshot(
}
function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLines'][number]) {
const needsPackagingReconfirmation = isLegacyPackagingPriceSource(line)
return {
id: line.id,
externalLineId: line.externalLineId,
@ -101,14 +119,33 @@ function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLine
lossFactor: decimalToNumber(line.lossFactor) ?? 1,
sourceConfidence: decimalToNumber(line.sourceConfidence),
evidence: line.evidence,
matchStatus: line.matchStatus,
matchedLibraryType: line.matchedLibraryType,
matchedLibraryRecordId: line.matchedLibraryRecordId,
matchedLibraryName: line.matchedLibraryName,
matchedLibrarySpec: line.matchedLibrarySpec,
resolvedUnitPrice: decimalToNumber(line.resolvedUnitPrice),
calculatedQuantity: decimalToNumber(line.calculatedQuantity),
subtotal: decimalToNumber(line.subtotal),
matchStatus: needsPackagingReconfirmation
? BomCostingMatchStatus.NEEDS_CONFIRMATION
: line.matchStatus,
matchedLibraryType: needsPackagingReconfirmation
? ''
: line.matchedLibraryType,
matchedLibraryRecordId: needsPackagingReconfirmation
? ''
: line.matchedLibraryRecordId,
matchedLibraryName: needsPackagingReconfirmation
? ''
: line.matchedLibraryName,
matchedLibrarySpec: needsPackagingReconfirmation
? ''
: line.matchedLibrarySpec,
resolvedUnitPrice: needsPackagingReconfirmation
? null
: decimalToNumber(line.resolvedUnitPrice),
resolvedPriceUnit: needsPackagingReconfirmation
? ''
: line.priceSnapshots[0]?.unit || line.unit,
calculatedQuantity: needsPackagingReconfirmation
? null
: decimalToNumber(line.calculatedQuantity),
subtotal: needsPackagingReconfirmation
? null
: decimalToNumber(line.subtotal),
updatedAt: line.updatedAt.toISOString(),
}
}
@ -166,6 +203,15 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
: null,
skus: record.skus.map((sku) => {
const bomLines = sku.bomLines.map(serializeLine)
const costSummary = summarizeBomCosting(
bomLines.map((line) => ({
category: line.category,
subtotal: line.subtotal,
})),
)
const hasCompleteCost =
costSummary.unresolvedLineCount === 0 &&
costSummary.missingCostGroups.length === 0
return {
id: sku.id,
externalSkuId: sku.externalSkuId,
@ -181,15 +227,15 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
packageWidthMm: decimalToNumber(sku.packageWidthMm),
packageHeightMm: decimalToNumber(sku.packageHeightMm),
packageWeightKg: decimalToNumber(sku.packageWeightKg),
estimatedCost: decimalToNumber(sku.estimatedCost),
costCalculatedAt: sku.costCalculatedAt?.toISOString() ?? null,
estimatedCost: hasCompleteCost
? roundBomCostMoney(costSummary.lossIncludedTotal)
: null,
costCalculatedAt:
hasCompleteCost && sku.costCalculatedAt
? sku.costCalculatedAt.toISOString()
: null,
bomLines,
costSummary: summarizeBomCosting(
bomLines.map((line) => ({
category: line.category,
subtotal: line.subtotal,
})),
),
costSummary,
}
}),
taskEvents: [...record.taskEvents]
@ -235,8 +281,21 @@ export class BomCostingService {
skuCount: record.skus.length,
estimatedCostRange: record.skus.reduce<{ min: number; max: number } | null>(
(range, sku) => {
const value = decimalToNumber(sku.estimatedCost)
if (value === null) return range
const summary = summarizeBomCosting(
sku.bomLines.map((line) => ({
category: line.category,
subtotal: isLegacyPackagingPriceSource(line)
? null
: decimalToNumber(line.subtotal),
})),
)
if (
summary.unresolvedLineCount > 0 ||
summary.missingCostGroups.length > 0
) {
return range
}
const value = summary.lossIncludedTotal
return range
? { min: Math.min(range.min, value), max: Math.max(range.max, value) }
: { min: value, max: value }
@ -431,6 +490,7 @@ export class BomCostingService {
spec: line.matchedLibrarySpec,
unit: line.unit,
unitPrice: line.resolvedUnitPrice.toNumber(),
pricingMode: 'UNIT',
}
: null)
} else if (requestedRecordId) {
@ -458,6 +518,7 @@ export class BomCostingService {
const calculation = calculateBomCostingLine({
category: line.category,
packagingKind: normalizePackagingKind(line.packagingKind),
pricingMode: candidate?.pricingMode,
lengthMm,
widthMm,
heightMm,
@ -491,18 +552,84 @@ export class BomCostingService {
},
priceSnapshot: candidate ? createSnapshot(candidate, lossFactor) : null,
})
const allPriced = lineSubtotals.every((item) => item.subtotal !== null)
const summary = summarizeBomCosting(
lineSubtotals.map((item) => ({
category: item.category,
subtotal: isLegacyPackagingPriceSource(item)
? null
: decimalToNumber(item.subtotal),
})),
)
const allPriced =
summary.unresolvedLineCount === 0 &&
summary.missingCostGroups.length === 0
const estimatedCost = allPriced
? roundBomCostMoney(
lineSubtotals.reduce(
(sum, item) => sum + (item.subtotal?.toNumber() ?? 0),
0,
),
)
? roundBomCostMoney(summary.lossIncludedTotal)
: null
await bomCostingRepository.updateSkuEstimatedCost(line.skuId, estimatedCost)
return this.getCase(caseId, user)
}
async listLinePriceCandidates(
caseId: string,
lineId: string,
user: AuthUser,
) {
bomCostingAccessService.assertCanView(user)
const line = await bomCostingRepository.findLineForUpdate(lineId)
if (
!line ||
line.sku.case.id !== caseId ||
line.sku.case.deletedAt ||
(!bomCostingAccessService.canViewAllCases(user) &&
line.sku.case.createdById !== user.id)
) {
throw new AppError(ErrorCode.BOM_COSTING_CASE_NOT_FOUND)
}
const candidates = (
await bomCostingRepository.listPriceCandidates()
)[line.category]
const match = matchBomCostingLibraryCandidate(candidates, {
code: line.materialCode,
name: line.materialName || line.partName,
spec: line.materialSpec,
thicknessMm:
line.category === BomCostingLineCategory.BOARD
? decimalToNumber(line.heightMm)
: null,
packagingKind:
line.category === BomCostingLineCategory.PACKAGING
? normalizePackagingKind(line.packagingKind)
: null,
})
const recommendedIds = new Set(
match.status === 'MATCHED'
? [match.candidate.recordId]
: match.status === 'NEEDS_CONFIRMATION'
? match.candidates.map((candidate) => candidate.recordId)
: [],
)
return candidates
.map((candidate) => ({
recordId: candidate.recordId,
libraryType: candidate.libraryType,
code: candidate.code,
name: candidate.name,
spec: candidate.spec,
unit: candidate.unit,
unitPrice: candidate.unitPrice,
thicknessMm: candidate.thicknessMm ?? null,
recommended: recommendedIds.has(candidate.recordId),
}))
.sort(
(left, right) =>
Number(right.recommended) - Number(left.recommended) ||
left.name.localeCompare(right.name, 'zh-CN') ||
left.spec.localeCompare(right.spec, 'zh-CN'),
)
}
}
export const bomCostingService = new BomCostingService()

View File

@ -51,6 +51,11 @@ import {
intakeNonIntegratedProductRepository,
type IntakeNonIntegratedProductRepositoryClass,
} from '../repositories/intake-non-integrated-product.repository'
import {
buildDevelopmentKindWhere,
buildProductKindWhere,
buildStockProductWhere,
} from '../repositories/intake-product-list-filters'
import { userRepository } from '../repositories/user.repository'
import {
assetSkuGroupRepository,
@ -3066,25 +3071,6 @@ function jsonTextContains(
}
}
function jsonTextMissingOrNotEquals(
path: string[],
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{
NOT: jsonTextEquals(path, value),
},
{
payload: {
path,
equals: Prisma.AnyNull,
},
},
],
}
}
function profileTextEquals(
field: string,
value: string,
@ -3139,35 +3125,6 @@ function payloadJsonWhenProfileMissing(
}
}
function profileTextNotEqualsWhenPresent(
field: string,
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
AND: [
{
NOT: {
profile: null,
},
},
{
OR: [
{
NOT: profileTextEquals(field, value),
},
{
profile: {
is: {
[field]: '',
},
},
} as Prisma.IntakeNonIntegratedProductWhereInput,
],
},
],
}
}
function payloadTextContainsWhenProfileMissing(path: string[], keyword: string) {
return payloadJsonWhenProfileMissing(jsonTextContains(path, keyword))
}
@ -3184,71 +3141,6 @@ function buildTextContainsAny(
}
}
const JST_SKU_SOURCE_PATHS = [
['source'],
['sourceTable'],
['importSource'],
['externalSource'],
['metadata', 'source'],
['metadata', 'sourceTable'],
['metadata', 'importSource'],
['metadata', 'externalSource'],
] as const
function buildStockProductWhere(): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{ id: { startsWith: 'jst-stock-' } },
profileTextNonEmpty('sourceSpu'),
...JST_SKU_SOURCE_PATHS.map((path) =>
payloadJsonWhenProfileMissing(jsonTextEquals([...path], 'jst_sku')),
),
],
}
}
function buildDevelopmentKindWhere(
productDevelopmentKind: IntakeNonIntegratedProductListParams['productDevelopmentKind'],
): Prisma.IntakeNonIntegratedProductWhereInput | null {
if (productDevelopmentKind === 'series') {
return {
OR: [
profileTextEquals('productDevelopmentKind', 'series'),
payloadJsonWhenProfileMissing(jsonTextEquals(['productDevelopmentKind'], 'series')),
],
}
}
if (productDevelopmentKind === 'product') {
return {
OR: [
profileTextNotEqualsWhenPresent('productDevelopmentKind', 'series'),
payloadJsonWhenProfileMissing(jsonTextMissingOrNotEquals(['productDevelopmentKind'], 'series')),
],
}
}
return null
}
function buildProductKindWhere(
productKind: IntakeNonIntegratedProductListParams['productKind'],
): Prisma.IntakeNonIntegratedProductWhereInput | null {
const stockWhere = buildStockProductWhere()
if (productKind === 'stock') {
return stockWhere
}
if (productKind === 'new') {
return {
NOT: stockWhere,
}
}
return null
}
function buildSpuSearchWhere(spu: string): Prisma.IntakeNonIntegratedProductWhereInput | null {
const keyword = trimListFilter(spu)
if (!keyword) return null