feat(bom-costing): 增加异步核算与资源缓存

This commit is contained in:
WuYongJie 2026-07-28 18:50:47 +08:00
parent 6e6e0f5e4b
commit 156bff395d
21 changed files with 3268 additions and 594 deletions

View File

@ -0,0 +1,91 @@
CREATE TYPE "BomCostingSourceStatus" AS ENUM (
'PENDING',
'FETCHING',
'ARCHIVING',
'READY',
'FAILED'
);
CREATE TYPE "BomCostingSourceAssetKind" AS ENUM (
'MAIN',
'DETAIL',
'SKU'
);
CREATE TYPE "BomCostingSourceAssetStatus" AS ENUM (
'PENDING',
'STORED',
'FAILED'
);
CREATE TABLE "BomCostingSourceSnapshot" (
"id" TEXT NOT NULL,
"sourceKey" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"sourceHost" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"externalItemId" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '',
"shopName" TEXT NOT NULL DEFAULT '',
"detailUrl" TEXT,
"status" "BomCostingSourceStatus" NOT NULL DEFAULT 'PENDING',
"skuCount" INTEGER NOT NULL DEFAULT 0,
"imageCount" INTEGER NOT NULL DEFAULT 0,
"storedImageCount" INTEGER NOT NULL DEFAULT 0,
"failedImageCount" INTEGER NOT NULL DEFAULT 0,
"rawSnapshot" JSONB,
"errorMessage" TEXT NOT NULL DEFAULT '',
"fetchedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingSourceSnapshot_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BomCostingSourceAsset" (
"id" TEXT NOT NULL,
"sourceSnapshotId" TEXT NOT NULL,
"assetKey" TEXT NOT NULL,
"kind" "BomCostingSourceAssetKind" NOT NULL,
"status" "BomCostingSourceAssetStatus" NOT NULL DEFAULT 'PENDING',
"originalUrl" TEXT NOT NULL,
"storedUrl" TEXT NOT NULL DEFAULT '',
"storageKey" TEXT NOT NULL DEFAULT '',
"contentType" TEXT NOT NULL DEFAULT '',
"byteSize" INTEGER,
"externalSkuIds" JSONB NOT NULL DEFAULT '[]',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingSourceAsset_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "BomCostingCase"
ADD COLUMN "sourceSnapshotId" TEXT,
ADD COLUMN "sourceCacheHit" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "processingStartedAt" TIMESTAMP(3),
ADD COLUMN "processingFinishedAt" TIMESTAMP(3);
CREATE UNIQUE INDEX "BomCostingSourceSnapshot_sourceKey_key"
ON "BomCostingSourceSnapshot"("sourceKey");
CREATE INDEX "BomCostingSourceSnapshot_status_updatedAt_idx"
ON "BomCostingSourceSnapshot"("status", "updatedAt");
CREATE INDEX "BomCostingSourceSnapshot_channel_externalItemId_idx"
ON "BomCostingSourceSnapshot"("channel", "externalItemId");
CREATE UNIQUE INDEX "BomCostingSourceAsset_sourceSnapshotId_assetKey_key"
ON "BomCostingSourceAsset"("sourceSnapshotId", "assetKey");
CREATE INDEX "BomCostingSourceAsset_sourceSnapshotId_status_sortOrder_idx"
ON "BomCostingSourceAsset"("sourceSnapshotId", "status", "sortOrder");
CREATE INDEX "BomCostingCase_sourceSnapshotId_idx"
ON "BomCostingCase"("sourceSnapshotId");
ALTER TABLE "BomCostingCase"
ADD CONSTRAINT "BomCostingCase_sourceSnapshotId_fkey"
FOREIGN KEY ("sourceSnapshotId") REFERENCES "BomCostingSourceSnapshot"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BomCostingSourceAsset"
ADD CONSTRAINT "BomCostingSourceAsset_sourceSnapshotId_fkey"
FOREIGN KEY ("sourceSnapshotId") REFERENCES "BomCostingSourceSnapshot"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -562,6 +562,9 @@ model BomCostingCase {
id String @id @default(cuid())
sourceUrl String @db.Text
sourceHost String
sourceSnapshotId String?
sourceSnapshot BomCostingSourceSnapshot? @relation(fields: [sourceSnapshotId], references: [id])
sourceCacheHit Boolean @default(false)
title String @default("")
providerCode String @default("")
providerTaskId String @default("")
@ -574,6 +577,8 @@ model BomCostingCase {
rawAnalysisSnapshot Json?
createdById String
creator User @relation("BomCostingCaseCreator", fields: [createdById], references: [id])
processingStartedAt DateTime?
processingFinishedAt DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -583,9 +588,61 @@ model BomCostingCase {
@@index([createdById, deletedAt, createdAt])
@@index([status, deletedAt])
@@index([sourceSnapshotId])
@@index([providerTaskId])
}
model BomCostingSourceSnapshot {
id String @id @default(cuid())
sourceKey String @unique
sourceUrl String @db.Text
sourceHost String
channel String
externalItemId String
title String @default("")
shopName String @default("")
detailUrl String? @db.Text
status BomCostingSourceStatus @default(PENDING)
skuCount Int @default(0)
imageCount Int @default(0)
storedImageCount Int @default(0)
failedImageCount Int @default(0)
rawSnapshot Json?
errorMessage String @default("")
fetchedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cases BomCostingCase[]
assets BomCostingSourceAsset[]
@@index([status, updatedAt])
@@index([channel, externalItemId])
}
model BomCostingSourceAsset {
id String @id @default(cuid())
sourceSnapshotId String
sourceSnapshot BomCostingSourceSnapshot @relation(fields: [sourceSnapshotId], references: [id], onDelete: Cascade)
assetKey String
kind BomCostingSourceAssetKind
status BomCostingSourceAssetStatus @default(PENDING)
originalUrl String @db.Text
storedUrl String @default("") @db.Text
storageKey String @default("") @db.Text
contentType String @default("")
byteSize Int?
externalSkuIds Json @default("[]")
sortOrder Int @default(0)
errorMessage String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sourceSnapshotId, assetKey])
@@index([sourceSnapshotId, status, sortOrder])
}
model BomCostingSku {
id String @id @default(cuid())
caseId String
@ -1651,6 +1708,26 @@ enum BomCostingCaseStatus {
FAILED
}
enum BomCostingSourceStatus {
PENDING
FETCHING
ARCHIVING
READY
FAILED
}
enum BomCostingSourceAssetKind {
MAIN
DETAIL
SKU
}
enum BomCostingSourceAssetStatus {
PENDING
STORED
FAILED
}
enum BomCostingLineCategory {
BOARD
STEEL

View File

@ -5,6 +5,12 @@ const mockJsonError = jest.fn()
const mockJsonSuccess = jest.fn()
const mockListCases = jest.fn()
const mockCreateCase = jest.fn()
const mockProcessCase = jest.fn()
const mockAfter = jest.fn()
jest.mock('next/server', () => ({
after: (callback: () => unknown) => mockAfter(callback),
}))
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
@ -16,6 +22,7 @@ jest.mock('@/server/services/bom-costing/bom-costing.service', () => ({
bomCostingService: {
listCases: (...args: unknown[]) => mockListCases(...args),
createCase: (...args: unknown[]) => mockCreateCase(...args),
processCase: (...args: unknown[]) => mockProcessCase(...args),
},
}))
@ -101,4 +108,30 @@ describe('GET/POST /api/bom-costing/cases', () => {
status: 400,
})
})
it('POST 立即返回 202,并把长任务安排到响应结束后执行', async () => {
mockCreateCase.mockResolvedValue({
case: { id: 'case-queued', status: 'QUEUED' },
shouldSchedule: true,
reusedExistingCase: false,
})
mockProcessCase.mockResolvedValue(undefined)
const response = await POST(
createRequest({ sourceUrl: 'https://item.taobao.com/item.htm?id=123456789' }),
)
expect(response).toMatchObject({
success: true,
status: 202,
data: {
case: { id: 'case-queued', status: 'QUEUED' },
reusedExistingCase: false,
},
})
expect(mockAfter).toHaveBeenCalledTimes(1)
const callback = mockAfter.mock.calls[0][0] as () => Promise<void>
await callback()
expect(mockProcessCase).toHaveBeenCalledWith('case-queued')
})
})

View File

@ -1,4 +1,4 @@
import { NextRequest } from 'next/server'
import { after, NextRequest } from 'next/server'
import { createBomCostingCaseSchema } from '@/lib/bom-costing/contracts'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
@ -41,9 +41,19 @@ export async function POST(request: NextRequest) {
}
try {
const created = await bomCostingService.createCase(
parsed.data.sourceUrl,
ctx.user,
)
if (created.shouldSchedule) {
after(() => bomCostingService.processCase(created.case.id))
}
return jsonSuccess(
{ case: await bomCostingService.createCase(parsed.data.sourceUrl, ctx.user) },
201,
{
case: created.case,
reusedExistingCase: created.reusedExistingCase,
},
created.shouldSchedule ? 202 : 200,
ctx.requestId,
)
} catch (error) {

View File

@ -29,7 +29,143 @@ describe('BomCostingWorkbenchClient', () => {
expect(screen.queryByText('核算记录')).not.toBeInTheDocument()
expect(screen.queryByText('成本汇总')).not.toBeInTheDocument()
expect(screen.queryByText('概念模型服务未接入')).not.toBeInTheDocument()
expect(screen.queryByTestId('empty-backdrop')).not.toBeInTheDocument()
expect(screen.getByTestId('empty-backdrop')).toBeInTheDocument()
})
it('处理中实时展示已归档图片、阶段进度与模型调用信息', async () => {
const originalFetch = global.fetch
const now = new Date().toISOString()
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({
success: true,
data: {
case: {
id: 'case-processing',
sourceUrl: 'https://item.example.com/item?id=1',
sourceHost: 'item.example.com',
title: '竞品升降桌',
providerCode: 'market-detail-multimodal',
providerTaskId: 'bom-task-processing',
sourceCacheHit: false,
status: 'ANALYZING',
progress: 58,
statusMessage: '正在识别产品结构、材质、尺寸与包装信息',
targetErrorPercent: 10,
currency: 'CNY',
createdById: 'user-1',
processingStartedAt: now,
processingFinishedAt: null,
createdAt: now,
updatedAt: now,
sourceSnapshot: {
id: 'source-1',
status: 'READY',
channel: 'taobao',
externalItemId: '1',
title: '竞品升降桌',
shopName: '竞品店铺',
skuCount: 3,
imageCount: 8,
storedImageCount: 6,
failedImageCount: 0,
errorMessage: '',
fetchedAt: now,
completedAt: now,
assets: [{
id: 'asset-1',
kind: 'MAIN',
status: 'STORED',
storedUrl: '/api/storage/object?key=bom-costing%2Fmain.webp',
contentType: 'image/webp',
byteSize: 1024,
externalSkuIds: [],
sortOrder: 0,
errorMessage: '',
}],
},
skus: [],
taskEvents: [
{
id: 'event-1',
eventType: 'SOURCE_FETCH_COMPLETED',
status: 'ANALYZING',
progress: 18,
message: '已读取竞品资料',
providerTaskId: 'bom-task-processing',
payload: { skuCount: 3, imageCount: 8 },
createdAt: now,
},
{
id: 'event-2',
eventType: 'SOURCE_ARCHIVE_COMPLETED',
status: 'ANALYZING',
progress: 46,
message: '图片归档完成',
providerTaskId: 'bom-task-processing',
payload: { storedImageCount: 6 },
createdAt: now,
},
{
id: 'event-3',
eventType: 'MODEL_ANALYSIS_STARTED',
status: 'ANALYZING',
progress: 52,
message: '正在识别产品结构',
providerTaskId: 'bom-task-processing',
payload: { model: 'grok-4.5', imageCount: 6, skuCount: 3 },
createdAt: now,
},
],
},
},
}),
})) as jest.Mock
try {
render(
<BomCostingWorkbenchClient
initialCapabilities={{
enabled: true,
analysisConfigured: true,
modelingConfigured: false,
analysisProviderCode: 'market-detail-multimodal',
targetErrorPercent: 10,
fixtureAllowed: false,
}}
initialCases={[{
id: 'case-processing',
title: '竞品升降桌',
sourceUrl: 'https://item.example.com/item?id=1',
sourceHost: 'item.example.com',
status: 'ANALYZING',
progress: 58,
statusMessage: '正在识别产品结构',
sourceCacheHit: false,
storedImageCount: 6,
failedImageCount: 0,
createdById: 'user-1',
createdAt: now,
skuCount: 0,
estimatedCostRange: null,
}]}
/>,
)
expect(await screen.findByText('6/8 张图片')).toBeInTheDocument()
expect(screen.getByAltText('主图预览')).toHaveAttribute(
'src',
'/api/storage/object?key=bom-costing%2Fmain.webp',
)
expect(screen.getByText('读取竞品资料')).toBeInTheDocument()
expect(screen.getByText('归档图片资源')).toBeInTheDocument()
expect(screen.getByText('多模态结构分析')).toBeInTheDocument()
expect(screen.getByText('资料库匹配与核算')).toBeInTheDocument()
expect(screen.getByText('grok-4.5')).toBeInTheDocument()
expect(screen.getByText('6 张')).toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
})
it('开发 fixture 结果支持 SKU 切换并联动 BOM 与成本', async () => {

View File

@ -15,12 +15,51 @@ export interface BomCostingCaseSummary {
status: BomCostingCaseStatusValue
progress: number
statusMessage: string
sourceCacheHit: boolean
storedImageCount: number
failedImageCount: number
createdById: string
createdAt: string
skuCount: number
estimatedCostRange: { min: number; max: number } | null
}
export type BomCostingSourceStatusValue =
| 'PENDING'
| 'FETCHING'
| 'ARCHIVING'
| 'READY'
| 'FAILED'
export interface BomCostingSourceAssetDetail {
id: string
kind: 'MAIN' | 'DETAIL' | 'SKU'
status: 'PENDING' | 'STORED' | 'FAILED'
storedUrl: string | null
contentType: string
byteSize: number | null
externalSkuIds: string[]
sortOrder: number
errorMessage: string
}
export interface BomCostingSourceSnapshotDetail {
id: string
status: BomCostingSourceStatusValue
channel: string
externalItemId: string
title: string
shopName: string
skuCount: number
imageCount: number
storedImageCount: number
failedImageCount: number
errorMessage: string
fetchedAt: string | null
completedAt: string | null
assets: BomCostingSourceAssetDetail[]
}
export interface BomCostingLineDetail {
id: string
externalLineId: string
@ -86,14 +125,18 @@ export interface BomCostingCaseDetail {
title: string
providerCode: string
providerTaskId: string
sourceCacheHit: boolean
status: BomCostingCaseStatusValue
progress: number
statusMessage: string
targetErrorPercent: number
currency: string
createdById: string
processingStartedAt: string | null
processingFinishedAt: string | null
createdAt: string
updatedAt: string
sourceSnapshot: BomCostingSourceSnapshotDetail | null
skus: BomCostingSkuDetail[]
taskEvents: Array<{
id: string
@ -101,6 +144,8 @@ export interface BomCostingCaseDetail {
status: string
progress: number
message: string
providerTaskId: string
payload: unknown
createdAt: string
}>
}

View File

@ -53,4 +53,39 @@ describe('CompetitorAnalysisResultV1', () => {
})
expect(invalid.success).toBe(false)
})
it('允许归档后的站内对象地址,但拒绝任意相对路径', () => {
const base = {
schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
sourceUrl: 'https://example.com/product/1',
product: {
title: '竞品',
imageUrls: ['/api/storage/object?key=bom-costing%2Fsources%2Fmain.webp'],
},
skus: [{
externalSkuId: 'sku-1',
name: 'SKU',
imageUrl: '/api/storage/object?key=bom-costing%2Fsources%2Fsku.webp',
attributes: {},
dimensions: {},
bomLines: [{
externalLineId: 'line-1',
category: 'PROCESSING',
partName: '加工',
quantity: 1,
unit: '套',
estimatedUnitPrice: 1,
evidence: [],
confidence: 0.8,
}],
}],
analyzedAt: new Date().toISOString(),
}
expect(competitorAnalysisResultV1Schema.safeParse(base).success).toBe(true)
expect(competitorAnalysisResultV1Schema.safeParse({
...base,
product: { ...base.product, imageUrls: ['/uploads/untrusted.webp'] },
}).success).toBe(false)
})
})

View File

@ -18,10 +18,18 @@ 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) => {
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')
return parsed.pathname === '/api/storage/object' && Boolean(parsed.searchParams.get('key'))
}, {
message: '必须是外部 URL 或站内对象存储地址',
})
export const competitorEvidenceSchema = z.object({
sourceUrl: z.url().optional(),
imageUrl: z.url().optional(),
imageUrl: competitorResourceUrlSchema.optional(),
note: z.string().trim().max(1_000).optional(),
confidence: confidenceSchema,
})
@ -51,7 +59,7 @@ export const competitorSkuSchema = z.object({
externalSkuId: z.string().trim().min(1).max(160),
name: z.string().trim().min(1).max(240),
structureGroupKey: z.string().trim().max(160).optional(),
imageUrl: z.url().optional(),
imageUrl: competitorResourceUrlSchema.optional(),
attributes: z.record(z.string(), z.unknown()).default({}),
dimensions: z.object({
lengthMm: optionalPositiveNumberSchema,
@ -75,7 +83,7 @@ export const competitorAnalysisResultV1Schema = z.object({
title: z.string().trim().min(1).max(500),
brand: z.string().trim().max(200).optional(),
category: z.string().trim().max(200).optional(),
imageUrls: z.array(z.url()).max(30).default([]),
imageUrls: z.array(competitorResourceUrlSchema).max(30).default([]),
dimensions: z.object({
lengthMm: optionalPositiveNumberSchema,
widthMm: optionalPositiveNumberSchema,
@ -93,7 +101,7 @@ export type CompetitorBomLineV1 = z.infer<typeof competitorBomLineSchema>
export const conceptModelSpecV1Schema = z.object({
schemaVersion: z.literal(BOM_COSTING_CONCEPT_MODEL_SCHEMA_VERSION),
structureGroupKey: z.string().trim().min(1),
sourceImageUrls: z.array(z.url()).min(1),
sourceImageUrls: z.array(competitorResourceUrlSchema).min(1),
nodes: z.array(z.object({
nodeId: z.string().trim().min(1),
bomLineExternalId: z.string().trim().min(1),

View File

@ -157,6 +157,13 @@ const DIRECT_REFERENCE_CONFIGS: DirectReferenceConfig[] = [
select: { id: true, fileUrl: true },
orderBy: { updatedAt: 'desc' },
},
{
recordType: 'BomCostingSourceAsset',
delegate: 'bomCostingSourceAsset',
fields: ['storedUrl'],
select: { id: true, storedUrl: true },
orderBy: { updatedAt: 'desc' },
},
{
recordType: 'CommonComponent',
delegate: 'commonComponent',

View File

@ -2,6 +2,9 @@ import {
BomCostingCaseStatus,
BomCostingLineCategory,
BomCostingMatchStatus,
BomCostingSourceAssetKind,
BomCostingSourceAssetStatus,
BomCostingSourceStatus,
Prisma,
} from '@prisma/client'
import { prisma } from '@/lib/prisma'
@ -28,6 +31,13 @@ import type {
} from '@/lib/bom-costing/price-matcher'
const caseDetailInclude = Prisma.validator<Prisma.BomCostingCaseInclude>()({
sourceSnapshot: {
include: {
assets: {
orderBy: { sortOrder: 'asc' },
},
},
},
skus: {
orderBy: { sortOrder: 'asc' },
include: {
@ -44,7 +54,7 @@ const caseDetailInclude = Prisma.validator<Prisma.BomCostingCaseInclude>()({
},
taskEvents: {
orderBy: { createdAt: 'desc' },
take: 30,
take: 120,
},
})
@ -112,19 +122,34 @@ export interface BomCostingPreparedSku {
bomLines: BomCostingPreparedLine[]
}
export interface CreateBomCostingCaseInput {
export interface CreateQueuedBomCostingCaseInput {
sourceUrl: string
sourceHost: string
sourceSnapshotId: string | null
sourceCacheHit: boolean
title: string
providerCode: string
providerTaskId: string
status: BomCostingCaseStatus
progress: number
statusMessage: string
targetErrorPercent: number
rawAnalysisSnapshot?: Prisma.InputJsonValue
createdById: string
skus: BomCostingPreparedSku[]
}
export interface BomCostingSourceAssetSeed {
assetKey: string
kind: BomCostingSourceAssetKind
originalUrl: string
externalSkuIds: string[]
sortOrder: number
}
export interface BomCostingTaskEventInput {
eventType: string
status: BomCostingCaseStatus
progress: number
message: string
providerTaskId?: string
payload?: Prisma.InputJsonValue
}
function parsePositivePrice(value: unknown) {
@ -139,6 +164,70 @@ function readSystemName(metadata: Prisma.JsonValue | null) {
return typeof value === 'string' ? value.trim() : ''
}
function buildSkuCreateData(skus: BomCostingPreparedSku[]) {
return skus.map((sku) => ({
externalSkuId: sku.externalSkuId,
name: sku.name,
structureGroupKey: sku.structureGroupKey,
imageUrl: sku.imageUrl,
attributes: sku.attributes,
lengthMm: sku.lengthMm,
widthMm: sku.widthMm,
heightMm: sku.heightMm,
weightKg: sku.weightKg,
packageLengthMm: sku.packageLengthMm,
packageWidthMm: sku.packageWidthMm,
packageHeightMm: sku.packageHeightMm,
packageWeightKg: sku.packageWeightKg,
estimatedCost: sku.estimatedCost,
costCalculatedAt: sku.costCalculatedAt,
sortOrder: sku.sortOrder,
bomLines: {
create: sku.bomLines.map((line) => ({
externalLineId: line.externalLineId,
category: line.category,
partName: line.partName,
materialCode: line.materialCode,
materialName: line.materialName,
materialSpec: line.materialSpec,
processName: line.processName,
packagingKind: line.packagingKind,
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
quantity: line.quantity,
unit: line.unit,
weightKg: line.weightKg,
lossFactor: line.lossFactor,
sourceConfidence: line.sourceConfidence,
evidence: line.evidence,
matchStatus: line.matchStatus,
matchedLibraryType: line.matchedLibraryType,
matchedLibraryRecordId: line.matchedLibraryRecordId,
matchedLibraryName: line.matchedLibraryName,
matchedLibrarySpec: line.matchedLibrarySpec,
resolvedUnitPrice: line.resolvedUnitPrice,
calculatedQuantity: line.calculatedQuantity,
subtotal: line.subtotal,
sortOrder: line.sortOrder,
priceSnapshots: line.priceSnapshot
? {
create: {
sourceType: line.priceSnapshot.sourceType,
sourceRecordId: line.priceSnapshot.sourceRecordId,
sourceName: line.priceSnapshot.sourceName,
sourceSpec: line.priceSnapshot.sourceSpec,
unitPrice: line.priceSnapshot.unitPrice,
unit: line.priceSnapshot.unit,
lossFactor: line.priceSnapshot.lossFactor,
},
}
: undefined,
})),
},
}))
}
export class BomCostingRepository {
async listCases(input: { createdById?: string; limit: number }) {
return prisma.bomCostingCase.findMany({
@ -149,6 +238,14 @@ export class BomCostingRepository {
orderBy: { createdAt: 'desc' },
take: input.limit,
include: {
sourceSnapshot: {
select: {
status: true,
imageCount: true,
storedImageCount: true,
failedImageCount: true,
},
},
skus: {
orderBy: { sortOrder: 'asc' },
select: {
@ -162,6 +259,27 @@ export class BomCostingRepository {
})
}
async findReusableCase(input: { sourceKey: string; createdById: string }) {
return prisma.bomCostingCase.findFirst({
where: {
createdById: input.createdById,
deletedAt: null,
status: {
in: [
BomCostingCaseStatus.QUEUED,
BomCostingCaseStatus.ANALYZING,
BomCostingCaseStatus.READY,
],
},
sourceSnapshot: {
is: { sourceKey: input.sourceKey },
},
},
orderBy: { createdAt: 'desc' },
include: caseDetailInclude,
})
}
async findCaseById(input: { id: string; createdById?: string }) {
return prisma.bomCostingCase.findFirst({
where: {
@ -173,97 +291,359 @@ export class BomCostingRepository {
})
}
async createCase(input: CreateBomCostingCaseInput) {
async findCaseForProcessing(id: string) {
return prisma.bomCostingCase.findUnique({
where: { id },
include: caseDetailInclude,
})
}
async upsertSourceSnapshot(input: {
sourceKey: string
sourceUrl: string
sourceHost: string
channel: string
externalItemId: string
}) {
return prisma.bomCostingSourceSnapshot.upsert({
where: { sourceKey: input.sourceKey },
create: input,
update: {
sourceUrl: input.sourceUrl,
sourceHost: input.sourceHost,
},
include: {
assets: {
orderBy: { sortOrder: 'asc' },
},
},
})
}
async findSourceSnapshotById(id: string) {
return prisma.bomCostingSourceSnapshot.findUnique({
where: { id },
include: {
assets: {
orderBy: { sortOrder: 'asc' },
},
},
})
}
async createQueuedCase(input: CreateQueuedBomCostingCaseInput) {
return prisma.bomCostingCase.create({
data: {
sourceUrl: input.sourceUrl,
sourceHost: input.sourceHost,
sourceSnapshotId: input.sourceSnapshotId,
sourceCacheHit: input.sourceCacheHit,
title: input.title,
providerCode: input.providerCode,
providerTaskId: input.providerTaskId,
status: input.status,
progress: input.progress,
status: BomCostingCaseStatus.QUEUED,
progress: input.sourceCacheHit ? 42 : 2,
statusMessage: input.statusMessage,
targetErrorPercent: input.targetErrorPercent,
rawAnalysisSnapshot: input.rawAnalysisSnapshot,
createdById: input.createdById,
taskEvents: {
create: {
eventType: input.status === BomCostingCaseStatus.READY ? 'RESULT_SAVED' : 'TASK_CREATED',
status: input.status,
progress: input.progress,
eventType: 'TASK_CREATED',
status: BomCostingCaseStatus.QUEUED,
progress: input.sourceCacheHit ? 42 : 2,
message: input.statusMessage,
providerTaskId: input.providerTaskId,
},
},
skus: {
create: input.skus.map((sku) => ({
externalSkuId: sku.externalSkuId,
name: sku.name,
structureGroupKey: sku.structureGroupKey,
imageUrl: sku.imageUrl,
attributes: sku.attributes,
lengthMm: sku.lengthMm,
widthMm: sku.widthMm,
heightMm: sku.heightMm,
weightKg: sku.weightKg,
packageLengthMm: sku.packageLengthMm,
packageWidthMm: sku.packageWidthMm,
packageHeightMm: sku.packageHeightMm,
packageWeightKg: sku.packageWeightKg,
estimatedCost: sku.estimatedCost,
costCalculatedAt: sku.costCalculatedAt,
sortOrder: sku.sortOrder,
bomLines: {
create: sku.bomLines.map((line) => ({
externalLineId: line.externalLineId,
category: line.category,
partName: line.partName,
materialCode: line.materialCode,
materialName: line.materialName,
materialSpec: line.materialSpec,
processName: line.processName,
packagingKind: line.packagingKind,
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
quantity: line.quantity,
unit: line.unit,
weightKg: line.weightKg,
lossFactor: line.lossFactor,
sourceConfidence: line.sourceConfidence,
evidence: line.evidence,
matchStatus: line.matchStatus,
matchedLibraryType: line.matchedLibraryType,
matchedLibraryRecordId: line.matchedLibraryRecordId,
matchedLibraryName: line.matchedLibraryName,
matchedLibrarySpec: line.matchedLibrarySpec,
resolvedUnitPrice: line.resolvedUnitPrice,
calculatedQuantity: line.calculatedQuantity,
subtotal: line.subtotal,
sortOrder: line.sortOrder,
priceSnapshots: line.priceSnapshot
? {
create: {
sourceType: line.priceSnapshot.sourceType,
sourceRecordId: line.priceSnapshot.sourceRecordId,
sourceName: line.priceSnapshot.sourceName,
sourceSpec: line.priceSnapshot.sourceSpec,
unitPrice: line.priceSnapshot.unitPrice,
unit: line.priceSnapshot.unit,
lossFactor: line.priceSnapshot.lossFactor,
},
}
: undefined,
})),
payload: {
sourceCacheHit: input.sourceCacheHit,
},
})),
},
},
},
include: caseDetailInclude,
})
}
async claimCaseForProcessing(id: string) {
return prisma.$transaction(async (tx) => {
const claimed = await tx.bomCostingCase.updateMany({
where: {
id,
status: BomCostingCaseStatus.QUEUED,
deletedAt: null,
},
data: {
status: BomCostingCaseStatus.ANALYZING,
processingStartedAt: new Date(),
},
})
if (claimed.count !== 1) return false
const record = await tx.bomCostingCase.findUnique({
where: { id },
select: {
providerTaskId: true,
sourceCacheHit: true,
},
})
await tx.bomCostingTaskEvent.create({
data: {
caseId: id,
eventType: 'TASK_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: record?.sourceCacheHit ? 44 : 5,
message: record?.sourceCacheHit
? '已复用竞品资料,开始多模态分析'
: '开始获取竞品资料',
providerTaskId: record?.providerTaskId ?? '',
},
})
return true
})
}
async updateCaseStage(caseId: string, event: BomCostingTaskEventInput) {
return prisma.$transaction(async (tx) => {
await tx.bomCostingCase.update({
where: { id: caseId },
data: {
status: event.status,
progress: event.progress,
statusMessage: event.message,
},
})
return tx.bomCostingTaskEvent.create({
data: {
caseId,
eventType: event.eventType,
status: event.status,
progress: event.progress,
message: event.message,
providerTaskId: event.providerTaskId ?? '',
payload: event.payload,
},
})
})
}
async updateCaseTitle(caseId: string, title: string) {
return prisma.bomCostingCase.update({
where: { id: caseId },
data: { title },
})
}
async claimSourceSnapshot(sourceSnapshotId: string) {
const claimed = await prisma.bomCostingSourceSnapshot.updateMany({
where: {
id: sourceSnapshotId,
status: {
in: [BomCostingSourceStatus.PENDING, BomCostingSourceStatus.FAILED],
},
},
data: {
status: BomCostingSourceStatus.FETCHING,
errorMessage: '',
completedAt: null,
},
})
return claimed.count === 1
}
async saveFetchedSourceSnapshot(input: {
sourceSnapshotId: string
title: string
shopName: string
detailUrl: string
skuCount: number
rawSnapshot: Prisma.InputJsonValue
assets: BomCostingSourceAssetSeed[]
}) {
return prisma.$transaction(async (tx) => {
await tx.bomCostingSourceSnapshot.update({
where: { id: input.sourceSnapshotId },
data: {
title: input.title,
shopName: input.shopName,
detailUrl: input.detailUrl,
status: BomCostingSourceStatus.ARCHIVING,
skuCount: input.skuCount,
imageCount: input.assets.length,
storedImageCount: 0,
failedImageCount: 0,
rawSnapshot: input.rawSnapshot,
fetchedAt: new Date(),
errorMessage: '',
},
})
await tx.bomCostingSourceAsset.deleteMany({
where: { sourceSnapshotId: input.sourceSnapshotId },
})
if (input.assets.length > 0) {
await tx.bomCostingSourceAsset.createMany({
data: input.assets.map((asset) => ({
sourceSnapshotId: input.sourceSnapshotId,
assetKey: asset.assetKey,
kind: asset.kind,
originalUrl: asset.originalUrl,
externalSkuIds: asset.externalSkuIds,
sortOrder: asset.sortOrder,
})),
})
}
return tx.bomCostingSourceSnapshot.findUnique({
where: { id: input.sourceSnapshotId },
include: {
assets: {
orderBy: { sortOrder: 'asc' },
},
},
})
})
}
async markSourceAssetStored(input: {
assetId: string
storedUrl: string
storageKey: string
contentType: string
byteSize: number
}) {
return prisma.$transaction(async (tx) => {
const asset = await tx.bomCostingSourceAsset.findUnique({
where: { id: input.assetId },
select: { sourceSnapshotId: true, status: true },
})
if (!asset || asset.status !== BomCostingSourceAssetStatus.PENDING) return null
await tx.bomCostingSourceAsset.update({
where: { id: input.assetId },
data: {
status: BomCostingSourceAssetStatus.STORED,
storedUrl: input.storedUrl,
storageKey: input.storageKey,
contentType: input.contentType,
byteSize: input.byteSize,
errorMessage: '',
},
})
return tx.bomCostingSourceSnapshot.update({
where: { id: asset.sourceSnapshotId },
data: { storedImageCount: { increment: 1 } },
})
})
}
async markSourceAssetFailed(assetId: string, errorMessage: string) {
return prisma.$transaction(async (tx) => {
const asset = await tx.bomCostingSourceAsset.findUnique({
where: { id: assetId },
select: { sourceSnapshotId: true, status: true },
})
if (!asset || asset.status !== BomCostingSourceAssetStatus.PENDING) return null
await tx.bomCostingSourceAsset.update({
where: { id: assetId },
data: {
status: BomCostingSourceAssetStatus.FAILED,
errorMessage,
},
})
return tx.bomCostingSourceSnapshot.update({
where: { id: asset.sourceSnapshotId },
data: { failedImageCount: { increment: 1 } },
})
})
}
async completeSourceSnapshot(sourceSnapshotId: string) {
return prisma.bomCostingSourceSnapshot.update({
where: { id: sourceSnapshotId },
data: {
status: BomCostingSourceStatus.READY,
completedAt: new Date(),
errorMessage: '',
},
include: {
assets: {
orderBy: { sortOrder: 'asc' },
},
},
})
}
async failSourceSnapshot(sourceSnapshotId: string, message: string) {
return prisma.bomCostingSourceSnapshot.update({
where: { id: sourceSnapshotId },
data: {
status: BomCostingSourceStatus.FAILED,
errorMessage: message,
completedAt: new Date(),
},
})
}
async completeCase(input: {
caseId: string
title: string
analysis: Prisma.InputJsonValue
skus: BomCostingPreparedSku[]
eventPayload: Prisma.InputJsonValue
}) {
return prisma.$transaction(async (tx) => {
await tx.bomCostingSku.deleteMany({
where: { caseId: input.caseId },
})
return tx.bomCostingCase.update({
where: { id: input.caseId },
data: {
title: input.title,
status: BomCostingCaseStatus.READY,
progress: 100,
statusMessage: `核算完成,共 ${input.skus.length} 个 SKU`,
rawAnalysisSnapshot: input.analysis,
processingFinishedAt: new Date(),
skus: {
create: buildSkuCreateData(input.skus),
},
taskEvents: {
create: {
eventType: 'RESULT_SAVED',
status: BomCostingCaseStatus.READY,
progress: 100,
message: `核算完成,共 ${input.skus.length} 个 SKU`,
payload: input.eventPayload,
},
},
},
include: caseDetailInclude,
})
})
}
async failCase(caseId: string, message: string) {
return prisma.$transaction(async (tx) => {
const record = await tx.bomCostingCase.update({
where: { id: caseId },
data: {
status: BomCostingCaseStatus.FAILED,
statusMessage: message,
processingFinishedAt: new Date(),
},
select: {
progress: true,
providerTaskId: true,
},
})
await tx.bomCostingTaskEvent.create({
data: {
caseId,
eventType: 'TASK_FAILED',
status: BomCostingCaseStatus.FAILED,
progress: record.progress,
message,
providerTaskId: record.providerTaskId,
},
})
})
}
async findLineForUpdate(lineId: string) {
return prisma.bomCostingLine.findUnique({
where: { id: lineId },

View File

@ -47,6 +47,21 @@ interface MultimodalProviderConfig {
model: string
}
export interface BomCostingMultimodalTrace {
model: string
callId: string | null
imageCount: number
inputSkuCount: number
durationMs: number
promptTokens: number | null
completionTokens: number | null
}
export interface BomCostingMultimodalOutput {
result: CompetitorAnalysisResultV1
trace: BomCostingMultimodalTrace
}
interface LabeledImage {
label: string
url: string
@ -82,6 +97,10 @@ export async function isBomCostingMultimodalConfigured() {
return (await getMultimodalProviderConfig()) !== null
}
export async function getBomCostingMultimodalModelName() {
return (await getMultimodalProviderConfig())?.model ?? null
}
function buildLabeledImages(snapshot: MarketDetailSnapshot): LabeledImage[] {
const maxImages = readBoundedInteger(
process.env.BOM_COSTING_AI_MAX_IMAGES,
@ -226,6 +245,9 @@ function describeMultimodalHttpError(status: number): string {
if (status === 502 || status === 503 || status === 504) {
return '多模态分析服务暂时不可用,请稍后重新发起'
}
if (status === 524) {
return '多模态分析服务处理超时(524),本次任务未产生核算结果'
}
return `多模态分析服务返回 ${status}`
}
@ -278,6 +300,13 @@ export class BomCostingMultimodalAnalyzer {
sourceUrl: string
snapshot: MarketDetailSnapshot
}): Promise<CompetitorAnalysisResultV1> {
return (await this.analyzeWithTrace(input)).result
}
async analyzeWithTrace(input: {
sourceUrl: string
snapshot: MarketDetailSnapshot
}): Promise<BomCostingMultimodalOutput> {
const config = await getMultimodalProviderConfig()
if (!config) {
throw new AppError(
@ -316,6 +345,7 @@ export class BomCostingMultimodalAnalyzer {
userContent.push({ type: 'image_url', image_url: { url: image.url } })
}
const startedAt = Date.now()
let response: Response
try {
response = await fetch(endpoint, {
@ -354,7 +384,13 @@ export class BomCostingMultimodalAnalyzer {
}
let responsePayload: {
id?: string
model?: string
choices?: Array<{ message?: { content?: string } }>
usage?: {
prompt_tokens?: number
completion_tokens?: number
}
}
try {
responsePayload = JSON.parse(rawBody) as typeof responsePayload
@ -383,7 +419,7 @@ export class BomCostingMultimodalAnalyzer {
]),
).slice(0, 30)
return competitorAnalysisResultV1Schema.parse({
const result = competitorAnalysisResultV1Schema.parse({
schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
sourceUrl: input.sourceUrl,
product: {
@ -396,6 +432,18 @@ export class BomCostingMultimodalAnalyzer {
skus: mergeSourceSkuTruth(input.snapshot.skus, parsedPayload.skus),
analyzedAt: new Date().toISOString(),
})
return {
result,
trace: {
model: responsePayload.model?.trim() || config.model,
callId: responsePayload.id?.trim() || null,
imageCount: images.length,
inputSkuCount: input.snapshot.skus.length,
durationMs: Date.now() - startedAt,
promptTokens: responsePayload.usage?.prompt_tokens ?? null,
completionTokens: responsePayload.usage?.completion_tokens ?? null,
},
}
} catch (error) {
const details = error instanceof Error ? error.message : '结构校验失败'
throw new AppError(

View File

@ -0,0 +1,232 @@
/** @jest-environment node */
import {
BomCostingCaseStatus,
BomCostingSourceAssetKind,
BomCostingSourceAssetStatus,
BomCostingSourceStatus,
} from '@prisma/client'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
import { marketDetailClient, type MarketDetailSnapshot } from './market-detail-client'
const mockRunAnalysis = jest.fn()
const mockBuildStoredSnapshot = jest.fn()
jest.mock('./bom-costing-provider', () => ({
getCompetitorAnalysisProvider: () => ({
code: 'market-detail-multimodal',
requiresMarketSnapshot: true,
getModelName: async () => 'grok-4.5',
runAnalysis: (...args: unknown[]) => mockRunAnalysis(...args),
}),
}))
jest.mock('./bom-costing-source-archive.service', () => ({
buildMarketSnapshotFromStoredSource: (...args: unknown[]) =>
mockBuildStoredSnapshot(...args),
bomCostingSourceArchiveService: {
archive: jest.fn(),
},
}))
const snapshot: MarketDetailSnapshot = {
itemId: '1053559401376',
channel: 'taobao',
title: '竞品桌',
shopName: '竞品店铺',
detailUrl: 'https://item.taobao.com/item.htm?id=1053559401376',
mainImages: ['https://storage.example.com/main.jpg'],
detailImages: [],
skuImages: ['https://storage.example.com/sku.jpg'],
skus: [{
externalSkuId: 'sku-1',
name: '原木色',
size: null,
imageUrl: 'https://storage.example.com/sku.jpg',
attributes: { 颜色: '原木色' },
priceRaw: '19900',
originalPriceRaw: null,
quantity: 10,
}],
requestId: 'request-1',
}
const sourceSnapshot = {
id: 'source-1',
sourceKey: 'taobao:1053559401376',
sourceUrl: snapshot.detailUrl,
sourceHost: 'item.taobao.com',
channel: 'taobao',
externalItemId: snapshot.itemId,
title: snapshot.title,
shopName: snapshot.shopName ?? '',
detailUrl: snapshot.detailUrl,
status: BomCostingSourceStatus.READY,
skuCount: 1,
imageCount: 2,
storedImageCount: 2,
failedImageCount: 0,
rawSnapshot: snapshot,
errorMessage: '',
fetchedAt: new Date(),
completedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
assets: [{
id: 'asset-sku',
sourceSnapshotId: 'source-1',
assetKey: 'asset-key',
kind: BomCostingSourceAssetKind.SKU,
status: BomCostingSourceAssetStatus.STORED,
originalUrl: 'https://img.example.com/sku.jpg',
storedUrl: '/api/storage/object?key=stored-sku',
storageKey: 'stored-sku',
contentType: 'image/jpeg',
byteSize: 100,
externalSkuIds: ['sku-1'],
sortOrder: 0,
errorMessage: '',
createdAt: new Date(),
updatedAt: new Date(),
}],
}
const analysisResult = {
schemaVersion: 'competitor-analysis/v1' as const,
sourceUrl: snapshot.detailUrl,
product: {
title: snapshot.title,
imageUrls: snapshot.mainImages,
dimensions: {},
},
skus: [{
externalSkuId: 'sku-1',
name: '原木色',
structureGroupKey: 'desk',
imageUrl: snapshot.skus[0].imageUrl ?? undefined,
attributes: { 颜色: '原木色' },
dimensions: {},
bomLines: [{
externalLineId: 'line-1',
category: 'PROCESSING' as const,
partName: '加工',
processName: '开料',
quantity: 1,
unit: '套',
estimatedUnitPrice: 20,
evidence: [],
confidence: 0.8,
}],
}],
analyzedAt: new Date().toISOString(),
}
function caseRecord() {
return {
id: 'case-1',
sourceUrl: snapshot.detailUrl,
sourceHost: 'item.taobao.com',
sourceSnapshotId: 'source-1',
sourceSnapshot,
sourceCacheHit: true,
title: snapshot.title,
providerCode: 'market-detail-multimodal',
providerTaskId: 'bom-task-1',
analysisSchemaVersion: 'competitor-analysis/v1',
status: BomCostingCaseStatus.ANALYZING,
progress: 44,
statusMessage: '处理中',
targetErrorPercent: { toNumber: () => 10 },
currency: 'CNY',
rawAnalysisSnapshot: null,
createdById: 'user-1',
processingStartedAt: new Date(),
processingFinishedAt: null,
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
skus: [],
taskEvents: [],
}
}
describe('BomCostingProcessingService', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.spyOn(bomCostingRepository, 'claimCaseForProcessing').mockResolvedValue(true)
jest.spyOn(bomCostingRepository, 'findCaseForProcessing')
.mockResolvedValue(caseRecord() as never)
jest.spyOn(bomCostingRepository, 'updateCaseStage').mockResolvedValue({} as never)
jest.spyOn(bomCostingRepository, 'listPriceCandidates').mockResolvedValue({
BOARD: [],
STEEL: [],
ACCESSORY: [],
PACKAGING: [],
PROCESSING: [],
LABOR: [],
})
jest.spyOn(bomCostingRepository, 'completeCase').mockResolvedValue({} as never)
jest.spyOn(bomCostingRepository, 'failCase').mockResolvedValue(undefined)
jest.spyOn(bomCostingRepository, 'failSourceSnapshot').mockResolvedValue({} as never)
mockBuildStoredSnapshot.mockResolvedValue(snapshot)
})
afterEach(() => {
jest.restoreAllMocks()
})
it('缓存命中时不再调用市场扒图接口,并完成模型与成本阶段', async () => {
const marketSpy = jest.spyOn(marketDetailClient, 'readProduct')
mockRunAnalysis.mockResolvedValue({
result: analysisResult,
trace: {
model: 'grok-4.5',
callId: 'call-1',
imageCount: 2,
inputSkuCount: 1,
durationMs: 42_000,
promptTokens: 100,
completionTokens: 200,
},
})
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(marketSpy).not.toHaveBeenCalled()
expect(mockRunAnalysis).toHaveBeenCalledTimes(1)
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
expect.objectContaining({
caseId: 'case-1',
title: '竞品桌',
}),
)
expect(bomCostingRepository.updateCaseStage).toHaveBeenCalledWith(
'case-1',
expect.objectContaining({ eventType: 'SOURCE_CACHE_REUSED' }),
)
expect(bomCostingRepository.updateCaseStage).toHaveBeenCalledWith(
'case-1',
expect.objectContaining({ eventType: 'MODEL_ANALYSIS_COMPLETED' }),
)
})
it('模型失败时只标记案例失败,不破坏已经归档完成的源资料', async () => {
mockRunAnalysis.mockRejectedValue(new Error('多模态分析服务处理超时(524)'))
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingRepository.failCase).toHaveBeenCalledWith(
'case-1',
'多模态分析服务处理超时(524)',
)
expect(bomCostingRepository.failSourceSnapshot).not.toHaveBeenCalled()
expect(mockRunAnalysis).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,338 @@
import 'server-only'
import {
BomCostingCaseStatus,
BomCostingMatchStatus,
BomCostingSourceAssetStatus,
BomCostingSourceStatus,
Prisma,
} from '@prisma/client'
import { competitorAnalysisResultV1Schema } from '@/lib/bom-costing/contracts'
import { AppError } from '@/server/core/errors'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
import {
buildMarketSnapshotFromStoredSource,
bomCostingSourceArchiveService,
} from './bom-costing-source-archive.service'
import { prepareBomCostingAnalysis } from './bom-costing-result-preparer'
import { getCompetitorAnalysisProvider } from './bom-costing-provider'
import {
marketDetailClient,
type MarketDetailSnapshot,
} from './market-detail-client'
function sleep(milliseconds: number) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function readErrorMessage(error: unknown) {
if (error instanceof AppError) return error.message
if (error instanceof Error && error.message.trim()) return error.message.trim()
return '核算任务处理失败'
}
async function waitForSourceReady(sourceSnapshotId: string) {
const timeoutMs = 240_000
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const source = await bomCostingRepository.findSourceSnapshotById(
sourceSnapshotId,
)
if (!source) throw new Error('竞品资料快照不存在')
if (source.status === BomCostingSourceStatus.READY) return source
if (source.status === BomCostingSourceStatus.FAILED) {
throw new Error(source.errorMessage || '竞品资料归档失败')
}
await sleep(1_000)
}
throw new Error('等待竞品资料归档超时')
}
function buildResultStats(
skus: ReturnType<typeof prepareBomCostingAnalysis>,
) {
const lines = skus.flatMap((sku) => sku.bomLines)
return {
skuCount: skus.length,
bomLineCount: lines.length,
matchedLineCount: lines.filter(
(line) => line.matchStatus === BomCostingMatchStatus.MATCHED,
).length,
needsConfirmationCount: lines.filter(
(line) => line.matchStatus !== BomCostingMatchStatus.MATCHED,
).length,
}
}
function replaceAnalysisImageUrlsWithStableArchive(
result: Awaited<
ReturnType<
NonNullable<
ReturnType<typeof getCompetitorAnalysisProvider>
>['runAnalysis']
>
>['result'],
source: NonNullable<
Awaited<ReturnType<typeof bomCostingRepository.findSourceSnapshotById>>
>,
) {
const storedAssets = source.assets.filter(
(asset) =>
asset.status === BomCostingSourceAssetStatus.STORED &&
asset.storedUrl,
)
const stableSkuImageById = new Map<string, string>()
for (const asset of storedAssets) {
if (!Array.isArray(asset.externalSkuIds)) continue
for (const value of asset.externalSkuIds) {
if (
typeof value === 'string' &&
value &&
!stableSkuImageById.has(value)
) {
stableSkuImageById.set(value, asset.storedUrl)
}
}
}
return competitorAnalysisResultV1Schema.parse({
...result,
product: {
...result.product,
imageUrls: storedAssets.map((asset) => asset.storedUrl).slice(0, 30),
},
skus: result.skus.map((sku) => ({
...sku,
imageUrl: stableSkuImageById.get(sku.externalSkuId),
})),
})
}
export class BomCostingProcessingService {
async processCase(caseId: string) {
const claimed = await bomCostingRepository.claimCaseForProcessing(caseId)
if (!claimed) return
let sourceOwnedByTask = false
let sourceSnapshotId: string | null = null
try {
const record = await bomCostingRepository.findCaseForProcessing(caseId)
if (!record) throw new Error('BOM 核算案例不存在')
sourceSnapshotId = record.sourceSnapshotId
const provider = getCompetitorAnalysisProvider()
if (!provider) throw new Error('竞品分析服务尚未接入')
let marketSnapshot: MarketDetailSnapshot | null = null
let archivedSource: NonNullable<
Awaited<ReturnType<typeof bomCostingRepository.findSourceSnapshotById>>
> | null = null
if (provider.requiresMarketSnapshot) {
if (!record.sourceSnapshotId || !record.sourceSnapshot) {
throw new Error('竞品资料快照不存在')
}
let source = record.sourceSnapshot
if (source.status === BomCostingSourceStatus.READY) {
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'SOURCE_CACHE_REUSED',
status: BomCostingCaseStatus.ANALYZING,
progress: 46,
message: `已复用归档资料:${source.storedImageCount} 张图片、${source.skuCount} 个 SKU`,
payload: {
sourceSnapshotId: source.id,
storedImageCount: source.storedImageCount,
failedImageCount: source.failedImageCount,
skuCount: source.skuCount,
},
})
} else {
sourceOwnedByTask = await bomCostingRepository.claimSourceSnapshot(
source.id,
)
if (sourceOwnedByTask) {
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'SOURCE_FETCH_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: 8,
message: '正在读取竞品商品、SKU 与图片资料',
payload: {
channel: source.channel,
externalItemId: source.externalItemId,
},
})
const fetched = await marketDetailClient.readProduct(record.sourceUrl)
await bomCostingRepository.updateCaseTitle(caseId, fetched.title)
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'SOURCE_FETCH_COMPLETED',
status: BomCostingCaseStatus.ANALYZING,
progress: 18,
message: `已获取 ${fetched.skus.length} 个 SKU,开始归档竞品图片`,
payload: {
skuCount: fetched.skus.length,
mainImageCount: fetched.mainImages.length,
detailImageCount: fetched.detailImages.length,
skuImageCount: fetched.skuImages.length,
requestId: fetched.requestId,
},
})
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'ASSET_ARCHIVE_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: 20,
message: '正在将竞品图片归档到对象存储',
})
source = await bomCostingSourceArchiveService.archive({
sourceSnapshotId: source.id,
snapshot: fetched,
onProgress: async (progress) => {
const settled = progress.storedCount + progress.failedCount
const caseProgress =
20 +
Math.round(
(settled / Math.max(progress.totalCount, 1)) * 24,
)
await bomCostingRepository.updateCaseStage(caseId, {
eventType:
progress.status === BomCostingSourceAssetStatus.STORED
? 'ASSET_STORED'
: 'ASSET_ARCHIVE_FAILED',
status: BomCostingCaseStatus.ANALYZING,
progress: caseProgress,
message: `图片归档 ${settled}/${progress.totalCount}`,
payload: {
assetId: progress.assetId,
kind: progress.kind,
assetStatus: progress.status,
storedUrl: progress.storedUrl,
storedCount: progress.storedCount,
failedCount: progress.failedCount,
totalCount: progress.totalCount,
},
})
},
})
sourceOwnedByTask = false
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'SOURCE_ARCHIVE_COMPLETED',
status: BomCostingCaseStatus.ANALYZING,
progress: 46,
message: `竞品资料已归档:${source.storedImageCount} 张图片`,
payload: {
sourceSnapshotId: source.id,
storedImageCount: source.storedImageCount,
failedImageCount: source.failedImageCount,
skuCount: source.skuCount,
},
})
} else {
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'SOURCE_ARCHIVE_WAITING',
status: BomCostingCaseStatus.ANALYZING,
progress: 12,
message: '同一竞品资料正在归档,等待复用结果',
})
source = await waitForSourceReady(source.id)
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'SOURCE_CACHE_REUSED',
status: BomCostingCaseStatus.ANALYZING,
progress: 46,
message: `已复用归档资料:${source.storedImageCount} 张图片、${source.skuCount} 个 SKU`,
payload: {
sourceSnapshotId: source.id,
storedImageCount: source.storedImageCount,
failedImageCount: source.failedImageCount,
skuCount: source.skuCount,
},
})
}
}
archivedSource = source
marketSnapshot = await buildMarketSnapshotFromStoredSource(source)
}
const modelName = await provider.getModelName()
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_ANALYSIS_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: 52,
message: '正在识别产品结构、材质、尺寸与包装信息',
payload: {
model: modelName,
imageCount: marketSnapshot
? marketSnapshot.mainImages.length +
marketSnapshot.detailImages.length +
marketSnapshot.skuImages.length
: 0,
skuCount: marketSnapshot?.skus.length ?? 0,
},
})
const output = await provider.runAnalysis({
sourceUrl: record.sourceUrl,
snapshot: marketSnapshot,
})
const persistedResult = archivedSource
? replaceAnalysisImageUrlsWithStableArchive(output.result, archivedSource)
: output.result
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'MODEL_ANALYSIS_COMPLETED',
status: BomCostingCaseStatus.ANALYZING,
progress: 80,
message: `结构分析完成,识别到 ${persistedResult.skus.length} 个 SKU`,
payload: {
...output.trace,
bomLineCount: persistedResult.skus.reduce(
(sum, sku) => sum + sku.bomLines.length,
0,
),
} as unknown as Prisma.InputJsonValue,
})
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'PRICE_MATCH_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: 86,
message: '正在匹配基础资料库价格并计算成本',
})
const candidates = await bomCostingRepository.listPriceCandidates()
const preparedSkus = prepareBomCostingAnalysis(persistedResult, candidates)
const stats = buildResultStats(preparedSkus)
await bomCostingRepository.updateCaseStage(caseId, {
eventType: 'PRICE_MATCH_COMPLETED',
status: BomCostingCaseStatus.ANALYZING,
progress: 95,
message: `已完成 ${stats.bomLineCount} 条 BOM 的价格匹配`,
payload: stats,
})
await bomCostingRepository.completeCase({
caseId,
title: persistedResult.product.title,
analysis: persistedResult as unknown as Prisma.InputJsonValue,
skus: preparedSkus,
eventPayload: {
...stats,
model: output.trace.model,
callId: output.trace.callId,
durationMs: output.trace.durationMs,
},
})
} catch (error) {
const message = readErrorMessage(error)
if (sourceOwnedByTask && sourceSnapshotId) {
await bomCostingRepository
.failSourceSnapshot(sourceSnapshotId, message)
.catch(() => undefined)
}
await bomCostingRepository.failCase(caseId, message).catch((persistError) => {
console.error('[BOM costing] failed to persist task failure', {
caseId,
error:
persistError instanceof Error ? persistError.message : String(persistError),
})
})
}
}
}
export const bomCostingProcessingService =
new BomCostingProcessingService()

View File

@ -1,6 +1,5 @@
import 'server-only'
import { nanoid } from 'nanoid'
import {
BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
BOM_COSTING_TARGET_ERROR_PERCENT,
@ -10,24 +9,20 @@ import {
} from '@/lib/bom-costing/contracts'
import {
bomCostingMultimodalAnalyzer,
getBomCostingMultimodalModelName,
isBomCostingMultimodalConfigured,
type BomCostingMultimodalOutput,
} from './bom-costing-multimodal-analyzer'
import { marketDetailClient } from './market-detail-client'
export type CompetitorAnalysisTaskStatus = 'QUEUED' | 'RUNNING' | 'READY' | 'FAILED'
export interface CompetitorAnalysisTask {
taskId: string
status: CompetitorAnalysisTaskStatus
progress: number
message: string
}
import type { MarketDetailSnapshot } from './market-detail-client'
export interface CompetitorAnalysisProvider {
readonly code: string
createTask(input: { sourceUrl: string }): Promise<CompetitorAnalysisTask>
queryTask(taskId: string): Promise<CompetitorAnalysisTask>
readResult(taskId: string): Promise<CompetitorAnalysisResultV1>
readonly requiresMarketSnapshot: boolean
getModelName(): Promise<string | null>
runAnalysis(input: {
sourceUrl: string
snapshot: MarketDetailSnapshot | null
}): Promise<BomCostingMultimodalOutput>
}
function createFixtureResult(sourceUrl: string): CompetitorAnalysisResultV1 {
@ -235,33 +230,26 @@ function createFixtureResult(sourceUrl: string): CompetitorAnalysisResultV1 {
class FixtureCompetitorAnalysisProvider implements CompetitorAnalysisProvider {
readonly code = 'fixture'
private readonly results = new Map<string, CompetitorAnalysisResultV1>()
readonly requiresMarketSnapshot = false
async createTask(input: { sourceUrl: string }): Promise<CompetitorAnalysisTask> {
const taskId = `fixture-${nanoid(12)}`
this.results.set(taskId, createFixtureResult(input.sourceUrl))
return {
taskId,
status: 'READY',
progress: 100,
message: '开发环境示例分析已完成',
}
async getModelName() {
return 'Fixture'
}
async queryTask(taskId: string): Promise<CompetitorAnalysisTask> {
const exists = this.results.has(taskId)
async runAnalysis(input: { sourceUrl: string }) {
const result = createFixtureResult(input.sourceUrl)
return {
taskId,
status: exists ? 'READY' : 'FAILED',
progress: exists ? 100 : 0,
message: exists ? '开发环境示例分析已完成' : '示例任务不存在',
}
}
async readResult(taskId: string) {
const result = this.results.get(taskId)
if (!result) throw new Error('示例任务不存在')
return result
result,
trace: {
model: 'Fixture',
callId: null,
imageCount: 0,
inputSkuCount: result.skus.length,
durationMs: 0,
promptTokens: null,
completionTokens: null,
},
} satisfies BomCostingMultimodalOutput
}
}
@ -269,38 +257,23 @@ const fixtureProvider = new FixtureCompetitorAnalysisProvider()
class MarketDetailMultimodalProvider implements CompetitorAnalysisProvider {
readonly code = 'market-detail-multimodal'
private readonly results = new Map<string, CompetitorAnalysisResultV1>()
readonly requiresMarketSnapshot = true
async createTask(input: { sourceUrl: string }): Promise<CompetitorAnalysisTask> {
const snapshot = await marketDetailClient.readProduct(input.sourceUrl)
const result = await bomCostingMultimodalAnalyzer.analyze({
async getModelName() {
return getBomCostingMultimodalModelName()
}
async runAnalysis(input: {
sourceUrl: string
snapshot: MarketDetailSnapshot | null
}) {
if (!input.snapshot) {
throw new Error('竞品资料快照不存在,不能开始多模态分析')
}
return bomCostingMultimodalAnalyzer.analyzeWithTrace({
sourceUrl: input.sourceUrl,
snapshot,
snapshot: input.snapshot,
})
const taskId = `market-detail-${nanoid(12)}`
this.results.set(taskId, result)
return {
taskId,
status: 'READY',
progress: 100,
message: `已分析 ${result.skus.length} 个 SKU`,
}
}
async queryTask(taskId: string): Promise<CompetitorAnalysisTask> {
const result = this.results.get(taskId)
return {
taskId,
status: result ? 'READY' : 'FAILED',
progress: result ? 100 : 0,
message: result ? `已分析 ${result.skus.length} 个 SKU` : '分析任务不存在',
}
}
async readResult(taskId: string) {
const result = this.results.get(taskId)
if (!result) throw new Error('分析任务不存在')
return result
}
}

View File

@ -0,0 +1,180 @@
import {
BomCostingLineCategory,
BomCostingMatchStatus,
Prisma,
} from '@prisma/client'
import type {
CompetitorAnalysisResultV1,
CompetitorBomLineV1,
} from '@/lib/bom-costing/contracts'
import {
calculateBomCostingLine,
roundBomCostMoney,
summarizeBomCosting,
type BomCostingPackagingKind,
} from '@/lib/bom-costing/cost-calculator'
import {
matchBomCostingLibraryCandidate,
type BomCostingLibraryCandidate,
} from '@/lib/bom-costing/price-matcher'
import type {
BomCostingPreparedLine,
BomCostingPreparedPriceSnapshot,
BomCostingPreparedSku,
} from '@/server/repositories/bom-costing.repository'
function normalizePackagingKind(value?: string) {
const normalized = value?.trim().toUpperCase() ?? ''
const supported = new Set<BomCostingPackagingKind>([
'FOAM',
'FOAM_CORNER',
'PAPER_CORNER',
'PACKING_STRAP',
'CARTON',
'OTHER',
])
return supported.has(normalized as BomCostingPackagingKind)
? (normalized as BomCostingPackagingKind)
: 'OTHER'
}
function createSnapshot(
candidate: BomCostingLibraryCandidate,
lossFactor: number,
): BomCostingPreparedPriceSnapshot {
return {
sourceType: candidate.libraryType,
sourceRecordId: candidate.recordId,
sourceName: candidate.name,
sourceSpec: candidate.spec,
unitPrice: candidate.unitPrice,
unit: candidate.unit,
lossFactor,
}
}
function createExternalEstimateCandidate(
line: CompetitorBomLineV1,
): BomCostingLibraryCandidate | null {
if (
(line.category !== 'PROCESSING' && line.category !== 'LABOR') ||
!line.estimatedUnitPrice
) {
return null
}
return {
libraryType: 'EXTERNAL_ANALYSIS',
recordId: line.externalLineId,
code: line.externalLineId,
name: line.partName,
spec: line.processName ?? '',
unit: line.unit,
unitPrice: line.estimatedUnitPrice,
}
}
function prepareLine(
line: CompetitorBomLineV1,
candidates: Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>,
sortOrder: number,
): BomCostingPreparedLine {
const category = line.category as BomCostingLineCategory
const lossFactor = line.suggestedLossFactor ?? 1
const externalEstimate = createExternalEstimateCandidate(line)
const match = externalEstimate
? { status: 'MATCHED' as const, candidate: externalEstimate }
: matchBomCostingLibraryCandidate(candidates[category], {
code: line.materialCode,
name: line.materialName || line.partName,
spec: line.materialSpec,
})
const matchedCandidate = match.status === 'MATCHED' ? match.candidate : null
const calculation = calculateBomCostingLine({
category: line.category,
packagingKind: normalizePackagingKind(line.packagingKind),
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
quantity: line.quantity,
unitPrice: matchedCandidate?.unitPrice,
lossFactor,
})
return {
externalLineId: line.externalLineId,
category,
partName: line.partName,
materialCode: line.materialCode ?? '',
materialName: line.materialName ?? '',
materialSpec: line.materialSpec ?? '',
processName: line.processName ?? '',
packagingKind: normalizePackagingKind(line.packagingKind),
lengthMm: line.lengthMm ?? null,
widthMm: line.widthMm ?? null,
heightMm: line.heightMm ?? null,
quantity: line.quantity,
unit: line.unit,
weightKg: line.weightKg ?? null,
lossFactor,
sourceConfidence: line.confidence,
evidence: line.evidence as Prisma.InputJsonValue,
matchStatus:
match.status === 'MATCHED'
? BomCostingMatchStatus.MATCHED
: match.status === 'NEEDS_CONFIRMATION'
? BomCostingMatchStatus.NEEDS_CONFIRMATION
: BomCostingMatchStatus.UNMATCHED,
matchedLibraryType: matchedCandidate?.libraryType ?? '',
matchedLibraryRecordId: matchedCandidate?.recordId ?? '',
matchedLibraryName: matchedCandidate?.name ?? '',
matchedLibrarySpec: matchedCandidate?.spec ?? '',
resolvedUnitPrice: matchedCandidate?.unitPrice ?? null,
calculatedQuantity: calculation.pricingQuantity,
subtotal:
calculation.subtotal === null
? null
: roundBomCostMoney(calculation.subtotal),
sortOrder,
priceSnapshot: matchedCandidate
? createSnapshot(matchedCandidate, lossFactor)
: null,
}
}
export function prepareBomCostingAnalysis(
analysis: CompetitorAnalysisResultV1,
candidates: Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>,
) {
return analysis.skus.map<BomCostingPreparedSku>((sku, skuIndex) => {
const bomLines = sku.bomLines.map((line, lineIndex) =>
prepareLine(line, candidates, lineIndex),
)
const summary = summarizeBomCosting(
bomLines.map((line) => ({
category: line.category,
subtotal: line.subtotal,
})),
)
const allLinesPriced = summary.unresolvedLineCount === 0
return {
externalSkuId: sku.externalSkuId,
name: sku.name,
structureGroupKey: sku.structureGroupKey ?? '',
imageUrl: sku.imageUrl ?? null,
attributes: sku.attributes as Prisma.InputJsonValue,
lengthMm: sku.dimensions.lengthMm ?? null,
widthMm: sku.dimensions.widthMm ?? null,
heightMm: sku.dimensions.heightMm ?? null,
weightKg: sku.weightKg ?? null,
packageLengthMm: sku.packaging?.lengthMm ?? null,
packageWidthMm: sku.packaging?.widthMm ?? null,
packageHeightMm: sku.packaging?.heightMm ?? null,
packageWeightKg: sku.packaging?.weightKg ?? null,
estimatedCost: allLinesPriced ? summary.lossIncludedTotal : null,
costCalculatedAt: allLinesPriced ? new Date() : null,
sortOrder: skuIndex,
bomLines,
}
})
}

View File

@ -0,0 +1,212 @@
/** @jest-environment node */
import {
BomCostingSourceAssetKind,
BomCostingSourceAssetStatus,
BomCostingSourceStatus,
} from '@prisma/client'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
import {
BomCostingSourceArchiveService,
buildMarketSnapshotFromStoredSource,
} from './bom-costing-source-archive.service'
import type { MarketDetailSnapshot } from './market-detail-client'
const mockUpload = jest.fn()
const mockDelete = jest.fn()
const mockCreateReadUrl = jest.fn()
jest.mock('@/lib/storage', () => ({
uploadBufferToObjectStorageWithMetadata: (...args: unknown[]) =>
mockUpload(...args),
deleteObjectWithResult: (...args: unknown[]) => mockDelete(...args),
createObjectReadUrl: (...args: unknown[]) => mockCreateReadUrl(...args),
}))
const snapshot: MarketDetailSnapshot = {
itemId: '1053559401376',
channel: 'taobao',
title: '竞品桌',
shopName: '竞品店铺',
detailUrl: 'https://item.taobao.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: '原木色',
size: null,
imageUrl: 'https://img.example.com/sku.jpg',
attributes: { 颜色: '原木色' },
priceRaw: '19900',
originalPriceRaw: null,
quantity: 10,
}],
requestId: 'market-request-1',
}
function createAsset(
id: string,
kind: BomCostingSourceAssetKind,
originalUrl: string,
sortOrder: number,
) {
return {
id,
sourceSnapshotId: 'source-1',
assetKey: `key-${id}`,
kind,
status: BomCostingSourceAssetStatus.PENDING,
originalUrl,
storedUrl: '',
storageKey: '',
contentType: '',
byteSize: null,
externalSkuIds: kind === BomCostingSourceAssetKind.SKU ? ['sku-1'] : [],
sortOrder,
errorMessage: '',
createdAt: new Date(),
updatedAt: new Date(),
}
}
function createSource(assets: ReturnType<typeof createAsset>[]) {
return {
id: 'source-1',
sourceKey: 'taobao:1053559401376',
sourceUrl: snapshot.detailUrl,
sourceHost: 'item.taobao.com',
channel: 'taobao',
externalItemId: snapshot.itemId,
title: snapshot.title,
shopName: snapshot.shopName ?? '',
detailUrl: snapshot.detailUrl,
status: BomCostingSourceStatus.ARCHIVING,
skuCount: 1,
imageCount: assets.length,
storedImageCount: 0,
failedImageCount: 0,
rawSnapshot: snapshot,
errorMessage: '',
fetchedAt: new Date(),
completedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
assets,
}
}
describe('BomCostingSourceArchiveService', () => {
afterEach(() => {
jest.restoreAllMocks()
jest.clearAllMocks()
})
it('把竞品图片逐张归档,并通过进度回调暴露真实数量', async () => {
const assets = [
createAsset('asset-main', BomCostingSourceAssetKind.MAIN, snapshot.mainImages[0], 0),
createAsset('asset-sku', BomCostingSourceAssetKind.SKU, snapshot.skuImages[0], 1),
createAsset('asset-detail', BomCostingSourceAssetKind.DETAIL, snapshot.detailImages[0], 2),
]
const source = createSource(assets)
jest.spyOn(bomCostingRepository, 'saveFetchedSourceSnapshot').mockResolvedValue(
source as never,
)
let storedCount = 0
jest.spyOn(bomCostingRepository, 'markSourceAssetStored').mockImplementation(
async () => {
storedCount += 1
return {
...source,
storedImageCount: storedCount,
} as never
},
)
jest.spyOn(bomCostingRepository, 'markSourceAssetFailed')
.mockResolvedValue(null)
jest.spyOn(bomCostingRepository, 'findSourceSnapshotById').mockResolvedValue({
...source,
storedImageCount: 3,
assets: assets.map((asset, index) => ({
...asset,
status: BomCostingSourceAssetStatus.STORED,
storedUrl: `/api/storage/object?key=stored-${index}`,
storageKey: `stored-${index}`,
})),
} as never)
jest.spyOn(bomCostingRepository, 'completeSourceSnapshot').mockResolvedValue({
...source,
status: BomCostingSourceStatus.READY,
storedImageCount: 3,
} as never)
jest.spyOn(global, 'fetch').mockImplementation(async () =>
new Response(Buffer.from('image-bytes'), {
status: 200,
headers: { 'content-type': 'image/jpeg' },
}),
)
mockUpload.mockImplementation(async (_buffer, key) => ({
url: `/api/storage/object?key=${encodeURIComponent(String(key))}`,
storageKey: key,
}))
const progress = jest.fn()
await new BomCostingSourceArchiveService().archive({
sourceSnapshotId: 'source-1',
snapshot,
onProgress: progress,
})
expect(global.fetch).toHaveBeenCalledTimes(3)
expect(mockUpload).toHaveBeenCalledTimes(3)
expect(mockDelete).not.toHaveBeenCalled()
expect(progress).toHaveBeenCalledTimes(3)
expect(progress.mock.calls.at(-1)?.[0]).toMatchObject({
status: BomCostingSourceAssetStatus.STORED,
storedCount: 3,
totalCount: 3,
})
})
it('复用资料时只向模型提供对象存储直读地址', async () => {
const assets = [
{
...createAsset(
'asset-main',
BomCostingSourceAssetKind.MAIN,
snapshot.mainImages[0],
0,
),
status: BomCostingSourceAssetStatus.STORED,
storedUrl: '/api/storage/object?key=stored-main',
storageKey: 'stored-main',
},
{
...createAsset(
'asset-sku',
BomCostingSourceAssetKind.SKU,
snapshot.skuImages[0],
1,
),
status: BomCostingSourceAssetStatus.STORED,
storedUrl: '/api/storage/object?key=stored-sku',
storageKey: 'stored-sku',
},
]
mockCreateReadUrl.mockImplementation(
(key: string) => `https://storage.example.com/${key}?signed=1`,
)
const restored = await buildMarketSnapshotFromStoredSource(
createSource(assets) as never,
)
expect(restored.mainImages).toEqual([
'https://storage.example.com/stored-main?signed=1',
])
expect(restored.skus[0].imageUrl).toBe(
'https://storage.example.com/stored-sku?signed=1',
)
expect(restored.detailImages).toEqual([])
})
})

View File

@ -0,0 +1,336 @@
import 'server-only'
import { createHash } from 'node:crypto'
import {
BomCostingSourceAssetKind,
BomCostingSourceAssetStatus,
type BomCostingSourceAsset,
type BomCostingSourceSnapshot,
type Prisma,
} from '@prisma/client'
import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
import {
bomCostingRepository,
type BomCostingSourceAssetSeed,
} from '@/server/repositories/bom-costing.repository'
import {
marketDetailSnapshotSchema,
type MarketDetailSnapshot,
} from './market-detail-client'
const DEFAULT_IMAGE_LIMIT_BYTES = 16 * 1024 * 1024
type SourceWithAssets = BomCostingSourceSnapshot & {
assets: BomCostingSourceAsset[]
}
export interface BomCostingArchiveProgress {
assetId: string
kind: BomCostingSourceAssetKind
status: BomCostingSourceAssetStatus
storedUrl: string | null
storedCount: number
failedCount: number
totalCount: number
}
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 hashUrl(url: string) {
return createHash('sha256').update(url).digest('hex')
}
function buildAssetSeeds(snapshot: MarketDetailSnapshot): BomCostingSourceAssetSeed[] {
const entries = new Map<string, BomCostingSourceAssetSeed>()
const add = (
kind: BomCostingSourceAssetKind,
url: string,
externalSkuId?: string,
) => {
const existing = entries.get(url)
if (existing) {
if (
externalSkuId &&
!existing.externalSkuIds.includes(externalSkuId)
) {
existing.externalSkuIds.push(externalSkuId)
}
return
}
entries.set(url, {
assetKey: hashUrl(url),
kind,
originalUrl: url,
externalSkuIds: externalSkuId ? [externalSkuId] : [],
sortOrder: entries.size,
})
}
snapshot.mainImages.forEach((url) => add(BomCostingSourceAssetKind.MAIN, url))
snapshot.skus.forEach((sku) => {
if (sku.imageUrl) {
add(BomCostingSourceAssetKind.SKU, sku.imageUrl, sku.externalSkuId)
}
})
snapshot.skuImages.forEach((url) => add(BomCostingSourceAssetKind.SKU, url))
snapshot.detailImages.forEach((url) => add(BomCostingSourceAssetKind.DETAIL, url))
return Array.from(entries.values())
}
function extensionFromContentType(contentType: string) {
const normalized = contentType.toLowerCase()
if (normalized === 'image/jpeg') return 'jpg'
if (normalized === 'image/png') return 'png'
if (normalized === 'image/webp') return 'webp'
if (normalized === 'image/gif') return 'gif'
if (normalized === 'image/avif') return 'avif'
return 'img'
}
async function readBodyWithLimit(response: Response, maxBytes: number) {
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error('图片文件超过归档大小限制')
}
if (!response.body) {
const buffer = Buffer.from(await response.arrayBuffer())
if (buffer.length > maxBytes) throw new Error('图片文件超过归档大小限制')
return buffer
}
const reader = response.body.getReader()
const chunks: Buffer[] = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel()
throw new Error('图片文件超过归档大小限制')
}
chunks.push(Buffer.from(value))
}
return Buffer.concat(chunks, total)
}
async function downloadSourceImage(url: string) {
const parsed = parseSafeCompetitorUrl(url)
const timeoutMs = readBoundedInteger(
process.env.BOM_COSTING_IMAGE_ARCHIVE_TIMEOUT_MS,
30_000,
5_000,
120_000,
)
const response = await fetch(parsed, {
method: 'GET',
headers: {
Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8',
},
cache: 'no-store',
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
})
if (!response.ok) {
throw new Error(`图片读取失败(${response.status})`)
}
if (response.url) parseSafeCompetitorUrl(response.url)
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
if (!contentType.startsWith('image/')) {
throw new Error('资源不是可归档的图片')
}
const maxBytes = readBoundedInteger(
process.env.BOM_COSTING_IMAGE_ARCHIVE_MAX_BYTES,
DEFAULT_IMAGE_LIMIT_BYTES,
1_048_576,
64 * 1024 * 1024,
)
return {
contentType,
buffer: await readBodyWithLimit(response, maxBytes),
}
}
async function runWithConcurrency<T>(
entries: T[],
concurrency: number,
worker: (entry: T) => Promise<void>,
) {
let cursor = 0
const runners = Array.from(
{ length: Math.min(concurrency, entries.length) },
async () => {
while (cursor < entries.length) {
const index = cursor
cursor += 1
await worker(entries[index])
}
},
)
await Promise.all(runners)
}
function mapStoredImageUrl(
sourceUrl: string | null,
storedByOriginalUrl: Map<string, string>,
) {
if (!sourceUrl) return null
return storedByOriginalUrl.get(sourceUrl) ?? null
}
export async function buildMarketSnapshotFromStoredSource(
source: SourceWithAssets,
): Promise<MarketDetailSnapshot> {
const { createObjectReadUrl } = await import('@/lib/storage')
const original = marketDetailSnapshotSchema.parse(source.rawSnapshot)
const storedByOriginalUrl = new Map(
source.assets
.filter(
(asset) =>
asset.status === BomCostingSourceAssetStatus.STORED &&
asset.storageKey,
)
.map((asset) => [
asset.originalUrl,
createObjectReadUrl(asset.storageKey),
] as const),
)
const mapUrls = (urls: string[]) =>
urls
.map((url) => mapStoredImageUrl(url, storedByOriginalUrl))
.filter((url): url is string => Boolean(url))
const snapshot = marketDetailSnapshotSchema.parse({
...original,
mainImages: mapUrls(original.mainImages),
detailImages: mapUrls(original.detailImages),
skuImages: mapUrls(original.skuImages),
skus: original.skus.map((sku) => ({
...sku,
imageUrl: mapStoredImageUrl(sku.imageUrl, storedByOriginalUrl),
})),
})
if (
snapshot.mainImages.length +
snapshot.detailImages.length +
snapshot.skuImages.length ===
0
) {
throw new Error('竞品图片尚未成功归档,不能开始多模态分析')
}
return snapshot
}
export class BomCostingSourceArchiveService {
async archive(input: {
sourceSnapshotId: string
snapshot: MarketDetailSnapshot
onProgress?: (progress: BomCostingArchiveProgress) => Promise<void>
}) {
const {
deleteObjectWithResult,
uploadBufferToObjectStorageWithMetadata,
} = await import('@/lib/storage')
const seeds = buildAssetSeeds(input.snapshot)
if (seeds.length === 0) {
throw new Error('竞品资料没有可归档的图片')
}
const source = await bomCostingRepository.saveFetchedSourceSnapshot({
sourceSnapshotId: input.sourceSnapshotId,
title: input.snapshot.title,
shopName: input.snapshot.shopName ?? '',
detailUrl: input.snapshot.detailUrl,
skuCount: input.snapshot.skus.length,
rawSnapshot: input.snapshot as unknown as Prisma.InputJsonValue,
assets: seeds,
})
if (!source) throw new Error('竞品资料快照不存在')
const concurrency = readBoundedInteger(
process.env.BOM_COSTING_IMAGE_ARCHIVE_CONCURRENCY,
5,
1,
8,
)
await runWithConcurrency(source.assets, concurrency, async (asset) => {
let uploaded: { url: string; storageKey: string } | null = null
try {
const image = await downloadSourceImage(asset.originalUrl)
const extension = extensionFromContentType(image.contentType)
const key = [
'bom-costing',
'sources',
source.channel,
source.externalItemId,
`${asset.assetKey.slice(0, 24)}.${extension}`,
].join('/')
uploaded = await uploadBufferToObjectStorageWithMetadata(
image.buffer,
key,
image.contentType,
)
const updated = await bomCostingRepository.markSourceAssetStored({
assetId: asset.id,
storedUrl: uploaded.url,
storageKey: uploaded.storageKey,
contentType: image.contentType,
byteSize: image.buffer.length,
})
if (!updated) throw new Error('图片归档状态已变化')
await input.onProgress?.({
assetId: asset.id,
kind: asset.kind,
status: BomCostingSourceAssetStatus.STORED,
storedUrl: uploaded.url,
storedCount: updated.storedImageCount,
failedCount: updated.failedImageCount,
totalCount: updated.imageCount,
})
} catch (error) {
if (uploaded) {
await deleteObjectWithResult(uploaded.storageKey)
}
const message = error instanceof Error ? error.message : '图片归档失败'
const updated = await bomCostingRepository.markSourceAssetFailed(
asset.id,
message,
)
if (updated) {
await input.onProgress?.({
assetId: asset.id,
kind: asset.kind,
status: BomCostingSourceAssetStatus.FAILED,
storedUrl: null,
storedCount: updated.storedImageCount,
failedCount: updated.failedImageCount,
totalCount: updated.imageCount,
})
}
}
})
const latest = await bomCostingRepository.findSourceSnapshotById(
input.sourceSnapshotId,
)
if (!latest || latest.storedImageCount === 0) {
throw new Error('竞品图片全部归档失败,未调用多模态分析')
}
return bomCostingRepository.completeSourceSnapshot(input.sourceSnapshotId)
}
}
export const bomCostingSourceArchiveService =
new BomCostingSourceArchiveService()

View File

@ -23,7 +23,7 @@ describe('BomCostingService', () => {
it('未配置分析服务时返回标准化 503,且不落失败案例', async () => {
process.env.BOM_COSTING_ANALYSIS_PROVIDER = ''
const createSpy = jest.spyOn(bomCostingRepository, 'createCase')
const createSpy = jest.spyOn(bomCostingRepository, 'createQueuedCase')
await expect(
bomCostingService.createCase('https://example.com/item/1', operationsUser),

View File

@ -1,16 +1,14 @@
import 'server-only'
import {
BomCostingCaseStatus,
BomCostingLineCategory,
BomCostingMatchStatus,
BomCostingSourceStatus,
Prisma,
} from '@prisma/client'
import { nanoid } from 'nanoid'
import {
BOM_COSTING_TARGET_ERROR_PERCENT,
competitorAnalysisResultV1Schema,
type CompetitorAnalysisResultV1,
type CompetitorBomLineV1,
type UpdateBomCostingLineInput,
} from '@/lib/bom-costing/contracts'
import {
@ -20,7 +18,6 @@ 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'
@ -29,15 +26,15 @@ import type { AuthUser } from '@/server/core/types'
import {
bomCostingRepository,
type BomCostingCaseDetailRecord,
type BomCostingPreparedLine,
type BomCostingPreparedPriceSnapshot,
type BomCostingPreparedSku,
} from '@/server/repositories/bom-costing.repository'
import { bomCostingAccessService } from './bom-costing-access.service'
import {
getBomCostingCapabilities,
getCompetitorAnalysisProvider,
} from './bom-costing-provider'
import { bomCostingProcessingService } from './bom-costing-processing.service'
import { parseMarketProductIdentity } from './market-detail-client'
const CATEGORY_LABELS: Record<BomCostingLineCategory, string> = {
BOARD: '板材',
@ -82,133 +79,6 @@ function createSnapshot(
}
}
function createExternalEstimateCandidate(
line: CompetitorBomLineV1,
): BomCostingLibraryCandidate | null {
if (
(line.category !== 'PROCESSING' && line.category !== 'LABOR') ||
!line.estimatedUnitPrice
) {
return null
}
return {
libraryType: 'EXTERNAL_ANALYSIS',
recordId: line.externalLineId,
code: line.externalLineId,
name: line.partName,
spec: line.processName ?? '',
unit: line.unit,
unitPrice: line.estimatedUnitPrice,
}
}
function prepareLine(
line: CompetitorBomLineV1,
candidates: Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>,
sortOrder: number,
): BomCostingPreparedLine {
const category = line.category as BomCostingLineCategory
const lossFactor = line.suggestedLossFactor ?? 1
const externalEstimate = createExternalEstimateCandidate(line)
const match = externalEstimate
? { status: 'MATCHED' as const, candidate: externalEstimate }
: matchBomCostingLibraryCandidate(candidates[category], {
code: line.materialCode,
name: line.materialName || line.partName,
spec: line.materialSpec,
})
const matchedCandidate = match.status === 'MATCHED' ? match.candidate : null
const calculation = calculateBomCostingLine({
category: line.category,
packagingKind: normalizePackagingKind(line.packagingKind),
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
quantity: line.quantity,
unitPrice: matchedCandidate?.unitPrice,
lossFactor,
})
const snapshot = matchedCandidate
? createSnapshot(matchedCandidate, lossFactor)
: null
return {
externalLineId: line.externalLineId,
category,
partName: line.partName,
materialCode: line.materialCode ?? '',
materialName: line.materialName ?? '',
materialSpec: line.materialSpec ?? '',
processName: line.processName ?? '',
packagingKind: normalizePackagingKind(line.packagingKind),
lengthMm: line.lengthMm ?? null,
widthMm: line.widthMm ?? null,
heightMm: line.heightMm ?? null,
quantity: line.quantity,
unit: line.unit,
weightKg: line.weightKg ?? null,
lossFactor,
sourceConfidence: line.confidence,
evidence: line.evidence as Prisma.InputJsonValue,
matchStatus:
match.status === 'MATCHED'
? BomCostingMatchStatus.MATCHED
: match.status === 'NEEDS_CONFIRMATION'
? BomCostingMatchStatus.NEEDS_CONFIRMATION
: BomCostingMatchStatus.UNMATCHED,
matchedLibraryType: matchedCandidate?.libraryType ?? '',
matchedLibraryRecordId: matchedCandidate?.recordId ?? '',
matchedLibraryName: matchedCandidate?.name ?? '',
matchedLibrarySpec: matchedCandidate?.spec ?? '',
resolvedUnitPrice: matchedCandidate?.unitPrice ?? null,
calculatedQuantity: calculation.pricingQuantity,
subtotal:
calculation.subtotal === null
? null
: roundBomCostMoney(calculation.subtotal),
sortOrder,
priceSnapshot: snapshot,
}
}
function prepareAnalysis(
analysis: CompetitorAnalysisResultV1,
candidates: Record<BomCostingLineCategory, BomCostingLibraryCandidate[]>,
) {
return analysis.skus.map<BomCostingPreparedSku>((sku, skuIndex) => {
const bomLines = sku.bomLines.map((line, lineIndex) =>
prepareLine(line, candidates, lineIndex),
)
const summary = summarizeBomCosting(
bomLines.map((line) => ({
category: line.category,
subtotal: line.subtotal,
})),
)
const allLinesPriced = summary.unresolvedLineCount === 0
return {
externalSkuId: sku.externalSkuId,
name: sku.name,
structureGroupKey: sku.structureGroupKey ?? '',
imageUrl: sku.imageUrl ?? null,
attributes: sku.attributes as Prisma.InputJsonValue,
lengthMm: sku.dimensions.lengthMm ?? null,
widthMm: sku.dimensions.widthMm ?? null,
heightMm: sku.dimensions.heightMm ?? null,
weightKg: sku.weightKg ?? null,
packageLengthMm: sku.packaging?.lengthMm ?? null,
packageWidthMm: sku.packaging?.widthMm ?? null,
packageHeightMm: sku.packaging?.heightMm ?? null,
packageWeightKg: sku.packaging?.weightKg ?? null,
estimatedCost: allLinesPriced ? summary.lossIncludedTotal : null,
costCalculatedAt: allLinesPriced ? new Date() : null,
sortOrder: skuIndex,
bomLines,
}
})
}
function serializeLine(line: BomCostingCaseDetailRecord['skus'][number]['bomLines'][number]) {
return {
id: line.id,
@ -250,14 +120,49 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
title: record.title,
providerCode: record.providerCode,
providerTaskId: record.providerTaskId,
sourceCacheHit: record.sourceCacheHit,
status: record.status,
progress: record.progress,
statusMessage: record.statusMessage,
targetErrorPercent: decimalToNumber(record.targetErrorPercent) ?? BOM_COSTING_TARGET_ERROR_PERCENT,
currency: record.currency,
createdById: record.createdById,
processingStartedAt: record.processingStartedAt?.toISOString() ?? null,
processingFinishedAt: record.processingFinishedAt?.toISOString() ?? null,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
sourceSnapshot: record.sourceSnapshot
? {
id: record.sourceSnapshot.id,
status: record.sourceSnapshot.status,
channel: record.sourceSnapshot.channel,
externalItemId: record.sourceSnapshot.externalItemId,
title: record.sourceSnapshot.title,
shopName: record.sourceSnapshot.shopName,
skuCount: record.sourceSnapshot.skuCount,
imageCount: record.sourceSnapshot.imageCount,
storedImageCount: record.sourceSnapshot.storedImageCount,
failedImageCount: record.sourceSnapshot.failedImageCount,
errorMessage: record.sourceSnapshot.errorMessage,
fetchedAt: record.sourceSnapshot.fetchedAt?.toISOString() ?? null,
completedAt: record.sourceSnapshot.completedAt?.toISOString() ?? null,
assets: record.sourceSnapshot.assets.map((asset) => ({
id: asset.id,
kind: asset.kind,
status: asset.status,
storedUrl: asset.storedUrl || null,
contentType: asset.contentType,
byteSize: asset.byteSize,
externalSkuIds: Array.isArray(asset.externalSkuIds)
? asset.externalSkuIds.filter(
(value): value is string => typeof value === 'string',
)
: [],
sortOrder: asset.sortOrder,
errorMessage: asset.errorMessage,
})),
}
: null,
skus: record.skus.map((sku) => {
const bomLines = sku.bomLines.map(serializeLine)
return {
@ -286,14 +191,18 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
),
}
}),
taskEvents: record.taskEvents.map((event) => ({
id: event.id,
eventType: event.eventType,
status: event.status,
progress: event.progress,
message: event.message,
createdAt: event.createdAt.toISOString(),
})),
taskEvents: [...record.taskEvents]
.reverse()
.map((event) => ({
id: event.id,
eventType: event.eventType,
status: event.status,
progress: event.progress,
message: event.message,
providerTaskId: event.providerTaskId,
payload: event.payload,
createdAt: event.createdAt.toISOString(),
})),
}
}
@ -317,6 +226,9 @@ export class BomCostingService {
status: record.status,
progress: record.progress,
statusMessage: record.statusMessage,
sourceCacheHit: record.sourceCacheHit,
storedImageCount: record.sourceSnapshot?.storedImageCount ?? 0,
failedImageCount: record.sourceSnapshot?.failedImageCount ?? 0,
createdById: record.createdById,
createdAt: record.createdAt.toISOString(),
skuCount: record.skus.length,
@ -356,34 +268,58 @@ export class BomCostingService {
)
}
const task = await provider.createTask({ sourceUrl: parsedUrl.toString() })
const analysis =
task.status === 'READY'
? competitorAnalysisResultV1Schema.parse(await provider.readResult(task.taskId))
: null
const candidates = analysis
? await bomCostingRepository.listPriceCandidates()
: null
const record = await bomCostingRepository.createCase({
let sourceSnapshot: Awaited<
ReturnType<typeof bomCostingRepository.upsertSourceSnapshot>
> | null = null
if (provider.requiresMarketSnapshot) {
const identity = parseMarketProductIdentity(parsedUrl.toString())
const sourceKey = `${identity.channel}:${identity.itemId}`
sourceSnapshot = await bomCostingRepository.upsertSourceSnapshot({
sourceKey,
sourceUrl: parsedUrl.toString(),
sourceHost: parsedUrl.hostname,
channel: identity.channel,
externalItemId: identity.itemId,
})
const reusable = await bomCostingRepository.findReusableCase({
sourceKey,
createdById: user.id,
})
if (reusable) {
return {
case: serializeCaseDetail(reusable),
shouldSchedule: false,
reusedExistingCase: true,
}
}
}
const sourceCacheHit =
sourceSnapshot?.status === BomCostingSourceStatus.READY
const providerTaskId = `bom-${nanoid(16)}`
const record = await bomCostingRepository.createQueuedCase({
sourceUrl: parsedUrl.toString(),
sourceHost: parsedUrl.hostname,
title: analysis?.product.title ?? parsedUrl.hostname,
sourceSnapshotId: sourceSnapshot?.id ?? null,
sourceCacheHit,
title: sourceSnapshot?.title || parsedUrl.hostname,
providerCode: provider.code,
providerTaskId: task.taskId,
status:
task.status === 'READY'
? BomCostingCaseStatus.READY
: task.status === 'RUNNING'
? BomCostingCaseStatus.ANALYZING
: BomCostingCaseStatus.QUEUED,
progress: task.progress,
statusMessage: task.message,
providerTaskId,
statusMessage: sourceCacheHit
? '已找到归档资料,等待多模态分析'
: '核算任务已创建,等待读取竞品资料',
targetErrorPercent: BOM_COSTING_TARGET_ERROR_PERCENT,
rawAnalysisSnapshot: analysis as Prisma.InputJsonValue | undefined,
createdById: user.id,
skus: analysis && candidates ? prepareAnalysis(analysis, candidates) : [],
})
return serializeCaseDetail(record)
return {
case: serializeCaseDetail(record),
shouldSchedule: true,
reusedExistingCase: false,
}
}
async processCase(caseId: string) {
await bomCostingProcessingService.processCase(caseId)
}
async updateLine(

View File

@ -34,6 +34,28 @@ export interface MarketProductIdentity {
channel: MarketChannel
}
export const marketDetailSnapshotSchema = z.object({
itemId: z.string().trim().min(1),
channel: z.enum(['taobao', '1688', 'douyin', 'jd']),
title: z.string().trim().min(1),
shopName: z.string().nullable(),
detailUrl: z.string().url(),
mainImages: z.array(z.string().url()),
detailImages: z.array(z.string().url()),
skuImages: z.array(z.string().url()),
skus: z.array(z.object({
externalSkuId: z.string().trim().min(1),
name: z.string(),
size: z.string().nullable(),
imageUrl: z.string().url().nullable(),
attributes: z.record(z.string(), z.string()),
priceRaw: z.string().nullable(),
originalPriceRaw: z.string().nullable(),
quantity: z.number().nullable(),
})).min(1),
requestId: z.string().nullable(),
})
const stringLikeSchema = z.union([z.string(), z.number()]).transform(String)
const upstreamSkuAttributeSchema = z.object({