feat(bom-costing): 增加异步核算与资源缓存
This commit is contained in:
parent
6e6e0f5e4b
commit
156bff395d
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}>
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user