Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-mainline-todo-unionid-20260729
This commit is contained in:
commit
46a7f25619
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
@ -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`,
|
||||
};
|
||||
}
|
||||
@ -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'>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
63
furniture-3d-system/src/lib/bom-costing/fact-contracts.ts
Normal file
63
furniture-3d-system/src/lib/bom-costing/fact-contracts.ts
Normal 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
|
||||
>
|
||||
@ -0,0 +1,8 @@
|
||||
export function buildBomCostingImageIdentity(value: string) {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
return `${parsed.origin}${parsed.pathname}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
154
furniture-3d-system/src/lib/bom-costing/packaging-candidate.ts
Normal file
154
furniture-3d-system/src/lib/bom-costing/packaging-candidate.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user