feat(bom-costing): 按业务类型开放并留档人工核算

This commit is contained in:
WuYongJie 2026-07-31 11:33:31 +08:00
parent d9a7ac208a
commit 2d3d5f2ef0
28 changed files with 3525 additions and 443 deletions

View File

@ -0,0 +1,140 @@
CREATE TYPE "BomCostingFactAnalysisOrigin" AS ENUM (
'INITIAL',
'SELECTED_REANALYSIS',
'FULL_REANALYSIS'
);
CREATE TYPE "BomCostingAnalysisRunMode" AS ENUM (
'INITIAL',
'REUSE_FACTS',
'REANALYZE_SELECTED',
'REANALYZE_ALL'
);
CREATE TYPE "BomCostingAnalysisRunStatus" AS ENUM (
'PENDING',
'PROCESSING',
'READY',
'FAILED'
);
ALTER TABLE "BomCostingSourceFactAnalysis"
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "origin" "BomCostingFactAnalysisOrigin" NOT NULL DEFAULT 'INITIAL',
ADD COLUMN "baseAnalysisId" TEXT,
ADD COLUMN "selectedAssetIds" JSONB NOT NULL DEFAULT '[]';
DROP INDEX "BomCostingSourceFactAnalysis_sourceSnapshotId_schemaVersion_key";
CREATE UNIQUE INDEX "BomCostingSourceFactAnalysis_sourceSnapshotId_schemaVersion_revision_key"
ON "BomCostingSourceFactAnalysis"("sourceSnapshotId", "schemaVersion", "revision");
CREATE INDEX "BomCostingSourceFactAnalysis_baseAnalysisId_idx"
ON "BomCostingSourceFactAnalysis"("baseAnalysisId");
ALTER TABLE "BomCostingSourceFactAnalysis"
ADD CONSTRAINT "BomCostingSourceFactAnalysis_baseAnalysisId_fkey"
FOREIGN KEY ("baseAnalysisId") REFERENCES "BomCostingSourceFactAnalysis"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE TABLE "BomCostingAnalysisRun" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"revision" INTEGER NOT NULL,
"mode" "BomCostingAnalysisRunMode" NOT NULL,
"status" "BomCostingAnalysisRunStatus" NOT NULL DEFAULT 'PENDING',
"factAnalysisId" TEXT,
"selectedAssetIds" JSONB NOT NULL DEFAULT '[]',
"instruction" TEXT NOT NULL DEFAULT '',
"resultSnapshot" JSONB,
"model" TEXT NOT NULL DEFAULT '',
"callId" TEXT NOT NULL DEFAULT '',
"promptTokens" INTEGER NOT NULL DEFAULT 0,
"completionTokens" INTEGER NOT NULL DEFAULT 0,
"durationMs" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT NOT NULL DEFAULT '',
"createdById" TEXT,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingAnalysisRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "BomCostingAnalysisRun_caseId_revision_key"
ON "BomCostingAnalysisRun"("caseId", "revision");
CREATE INDEX "BomCostingAnalysisRun_caseId_status_idx"
ON "BomCostingAnalysisRun"("caseId", "status");
CREATE INDEX "BomCostingAnalysisRun_factAnalysisId_idx"
ON "BomCostingAnalysisRun"("factAnalysisId");
CREATE INDEX "BomCostingAnalysisRun_status_updatedAt_idx"
ON "BomCostingAnalysisRun"("status", "updatedAt");
CREATE INDEX "BomCostingAnalysisRun_createdById_idx"
ON "BomCostingAnalysisRun"("createdById");
ALTER TABLE "BomCostingAnalysisRun"
ADD CONSTRAINT "BomCostingAnalysisRun_caseId_fkey"
FOREIGN KEY ("caseId") REFERENCES "BomCostingCase"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingAnalysisRun"
ADD CONSTRAINT "BomCostingAnalysisRun_factAnalysisId_fkey"
FOREIGN KEY ("factAnalysisId") REFERENCES "BomCostingSourceFactAnalysis"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BomCostingAnalysisRun"
ADD CONSTRAINT "BomCostingAnalysisRun_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "BomCostingAnalysisRun" (
"id",
"caseId",
"revision",
"mode",
"status",
"factAnalysisId",
"resultSnapshot",
"model",
"callId",
"errorMessage",
"createdById",
"startedAt",
"completedAt",
"createdAt",
"updatedAt"
)
SELECT
'legacy-' || c."id",
c."id",
1,
'INITIAL'::"BomCostingAnalysisRunMode",
CASE c."status"
WHEN 'ANALYZING' THEN 'PROCESSING'::"BomCostingAnalysisRunStatus"
WHEN 'READY' THEN 'READY'::"BomCostingAnalysisRunStatus"
WHEN 'FAILED' THEN 'FAILED'::"BomCostingAnalysisRunStatus"
ELSE 'PENDING'::"BomCostingAnalysisRunStatus"
END,
(
SELECT f."id"
FROM "BomCostingSourceFactAnalysis" f
WHERE f."sourceSnapshotId" = c."sourceSnapshotId"
AND f."schemaVersion" = 'competitor-facts/v1'
ORDER BY f."revision" DESC
LIMIT 1
),
c."rawAnalysisSnapshot",
'',
c."providerTaskId",
CASE WHEN c."status" = 'FAILED' THEN c."statusMessage" ELSE '' END,
c."createdById",
c."processingStartedAt",
c."processingFinishedAt",
c."createdAt",
c."updatedAt"
FROM "BomCostingCase" c;

View File

@ -0,0 +1,39 @@
-- CreateEnum
CREATE TYPE "BomCostingReviewInputSource" AS ENUM ('MANUAL', 'LIBRARY_MATCH');
-- CreateTable
CREATE TABLE "BomCostingReviewInput" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"skuId" TEXT NOT NULL,
"lineId" TEXT NOT NULL,
"createdById" TEXT,
"source" "BomCostingReviewInputSource" NOT NULL DEFAULT 'MANUAL',
"lengthMm" DECIMAL(12,3),
"widthMm" DECIMAL(12,3),
"heightMm" DECIMAL(12,3),
"quantity" DECIMAL(12,4),
"materialCode" TEXT NOT NULL DEFAULT '',
"materialName" TEXT NOT NULL DEFAULT '',
"materialSpec" TEXT NOT NULL DEFAULT '',
"unitPrice" DECIMAL(12,4),
"priceUnit" TEXT NOT NULL DEFAULT '',
"currency" TEXT NOT NULL DEFAULT 'CNY',
"lossFactor" DECIMAL(8,4),
"subtotal" DECIMAL(12,2),
"note" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BomCostingReviewInput_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BomCostingReviewInput_caseId_createdAt_idx" ON "BomCostingReviewInput"("caseId", "createdAt");
CREATE INDEX "BomCostingReviewInput_lineId_createdAt_idx" ON "BomCostingReviewInput"("lineId", "createdAt");
CREATE INDEX "BomCostingReviewInput_createdById_createdAt_idx" ON "BomCostingReviewInput"("createdById", "createdAt");
-- AddForeignKey
ALTER TABLE "BomCostingReviewInput" ADD CONSTRAINT "BomCostingReviewInput_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "BomCostingCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingReviewInput" ADD CONSTRAINT "BomCostingReviewInput_skuId_fkey" FOREIGN KEY ("skuId") REFERENCES "BomCostingSku"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingReviewInput" ADD CONSTRAINT "BomCostingReviewInput_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "BomCostingLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingReviewInput" ADD CONSTRAINT "BomCostingReviewInput_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,23 @@
-- Keep manual review history when a recalculation replaces the current SKU/BOM rows.
ALTER TABLE "BomCostingReviewInput"
ADD COLUMN "skuExternalId" TEXT NOT NULL DEFAULT '',
ADD COLUMN "lineExternalId" TEXT NOT NULL DEFAULT '',
ADD COLUMN "skuName" TEXT NOT NULL DEFAULT '',
ADD COLUMN "partName" TEXT NOT NULL DEFAULT '';
ALTER TABLE "BomCostingReviewInput"
ALTER COLUMN "skuId" DROP NOT NULL,
ALTER COLUMN "lineId" DROP NOT NULL;
ALTER TABLE "BomCostingReviewInput"
DROP CONSTRAINT "BomCostingReviewInput_skuId_fkey",
DROP CONSTRAINT "BomCostingReviewInput_lineId_fkey";
ALTER TABLE "BomCostingReviewInput"
ADD CONSTRAINT "BomCostingReviewInput_skuId_fkey"
FOREIGN KEY ("skuId") REFERENCES "BomCostingSku"("id") ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT "BomCostingReviewInput_lineId_fkey"
FOREIGN KEY ("lineId") REFERENCES "BomCostingLine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "BomCostingReviewInput_caseId_skuExternalId_lineExternalId_createdAt_idx"
ON "BomCostingReviewInput"("caseId", "skuExternalId", "lineExternalId", "createdAt");

View File

@ -112,7 +112,7 @@ model ProductSKU {
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
// SKU标识
skuCode String @unique // SKU编码,如 "A001-RED-3S"
skuCode String @unique // SKU编码,如 "A001-RED-3S"
name String? // SKU名称
// 规格属性(灵活的JSON结构)
@ -138,7 +138,7 @@ model ProductSKU {
transferHistory Json? // [{ fromUserId, toUserId, transferredAt, reason, operatorId }]
// 状态
isActive Boolean @default(true) // 是否上架
isActive Boolean @default(true) // 是否上架
originalSkuCode String?
deletedCodeAt DateTime?
deletedByProductId String?
@ -150,15 +150,15 @@ model ProductSKU {
panels Panel[]
holes Hole[]
productHardwares ProductHardware[]
sourcePreSkuId String? @unique
sourcePreSku PreSku? @relation("PreSkuFormalizedSku", fields: [sourcePreSkuId], references: [id], onDelete: SetNull)
sourcePreSkuId String? @unique
sourcePreSku PreSku? @relation("PreSkuFormalizedSku", fields: [sourcePreSkuId], references: [id], onDelete: SetNull)
sourceAssetVariantId String?
sourceAssetVariant AssetVariant? @relation("ProductSKUSourceAssetVariant", fields: [sourceAssetVariantId], references: [id], onDelete: SetNull)
sourceAssetVariantIds String[] @default([])
sourceAssetVariant AssetVariant? @relation("ProductSKUSourceAssetVariant", fields: [sourceAssetVariantId], references: [id], onDelete: SetNull)
sourceAssetVariantIds String[] @default([])
assetProfile ProductSkuAssetProfile?
warehouseLocationId String?
warehouseLocation WarehouseLocation? @relation("ProductSKUWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull)
packagedItems PackagedItem[] @relation("PackagedItemSku")
warehouseLocation WarehouseLocation? @relation("ProductSKUWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull)
packagedItems PackagedItem[] @relation("PackagedItemSku")
qualityInspections ProductSkuQualityInspection[] @relation("ProductSKUQualityInspections")
// 元数据
@ -178,30 +178,30 @@ model ProductSKU {
// 正式 SKU 的资产资料结构化同步副本
model ProductSkuAssetProfile {
id String @id @default(cuid())
skuId String @unique
sku ProductSKU @relation(fields: [skuId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
skuId String @unique
sku ProductSKU @relation(fields: [skuId], references: [id], onDelete: Cascade)
primarySourceAssetVariantId String?
sourceAssetVariantIds String[] @default([])
sourceAssetFamilyId String?
sourceAssetSeriesId String?
model String?
modelName String?
specCode String?
specName String?
barcode String?
size Json
color String?
material String?
imageUrl String?
model String?
modelName String?
specCode String?
specName String?
barcode String?
size Json
color String?
material String?
imageUrl String?
sourceStatus ProductSkuAssetSourceStatus @default(ACTIVE)
skuDeletedAt DateTime?
syncedAt DateTime
lockedFields String[] @default([])
lockedReasons Json
sourceStatus ProductSkuAssetSourceStatus @default(ACTIVE)
skuDeletedAt DateTime?
syncedAt DateTime
lockedFields String[] @default([])
lockedReasons Json
sourceVariants Json
createdAt DateTime @default(now())
@ -236,7 +236,7 @@ model ProductCollaborator {
}
model IntakeNonIntegratedProduct {
id String @id
id String @id
spu String
spuBase String
brand String
@ -244,13 +244,13 @@ model IntakeNonIntegratedProduct {
operator String
purchaser String
payload Json
visibility String @default("DEPARTMENT") // PRIVATE, TEAM, DEPARTMENT, PUBLIC
visibility String @default("DEPARTMENT") // PRIVATE, TEAM, DEPARTMENT, PUBLIC
departmentId String?
teamId String?
createdBy String
updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
approvalWorkflows ApprovalWorkflow[]
skuItems IntakeNonIntegratedSkuItem[]
@ -412,16 +412,16 @@ model IntakeNonIntegratedSkuItem {
}
model IntakeProductModelAsset {
id String @id @default(cuid())
id String @id @default(cuid())
productId String
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
modelKey String
displayName String @default("")
displayName String @default("")
versions IntakeProductModelVersion[]
createdBy String
creator User @relation("IntakeProductModelAssetCreator", fields: [createdBy], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation("IntakeProductModelAssetCreator", fields: [createdBy], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, modelKey])
@@index([productId, createdAt])
@ -429,23 +429,23 @@ model IntakeProductModelAsset {
}
model IntakeProductModelVersion {
id String @id @default(cuid())
id String @id @default(cuid())
assetId String
asset IntakeProductModelAsset @relation(fields: [assetId], references: [id], onDelete: Cascade)
asset IntakeProductModelAsset @relation(fields: [assetId], references: [id], onDelete: Cascade)
versionNo Int
originalFileName String
mimeType String
fileSize Int
checksumSha256 String @default("")
sourceStorageKey String @db.Text
sourceUrl String @db.Text
previewStorageKey String? @db.Text
previewUrl String? @db.Text
checksumSha256 String @default("")
sourceStorageKey String @db.Text
sourceUrl String @db.Text
previewStorageKey String? @db.Text
previewUrl String? @db.Text
createdBy String
creator User @relation("IntakeProductModelVersionCreator", fields: [createdBy], references: [id], onDelete: Restrict)
creator User @relation("IntakeProductModelVersionCreator", fields: [createdBy], references: [id], onDelete: Restrict)
skuProfiles IntakeSkuAiProfile[]
aiRenders AssistedAssemblyAiRender[]
createdAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([assetId, versionNo])
@@index([assetId, createdAt])
@ -494,28 +494,28 @@ model IntakeSkuAiMaterialVersion {
}
model IntakeNonIntegratedPricingRow {
id String @id
productId String
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
skuItemId String
skuItem IntakeNonIntegratedSkuItem @relation("IntakeNonIntegratedPricingSkuItem", fields: [productId, skuItemId], references: [productId, skuItemId], onDelete: Cascade, onUpdate: Cascade, map: "IntakeNonIntegratedPricingRow_product_sku_item_fkey")
skuCode String @default("")
barcode String @default("")
costPrice Decimal? @db.Decimal(10, 2)
productTag String @default("")
pricingConfirmed Boolean @default(false)
pricingConfirmedAt DateTime?
systemEnteredAt DateTime?
jstVirtualInventoryStatus String @default("")
jstVirtualInventoryScheduledAt DateTime?
jstVirtualInventoryPushedAt DateTime?
jstVirtualInventoryRetryCount Int @default(0)
id String @id
productId String
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
skuItemId String
skuItem IntakeNonIntegratedSkuItem @relation("IntakeNonIntegratedPricingSkuItem", fields: [productId, skuItemId], references: [productId, skuItemId], onDelete: Cascade, onUpdate: Cascade, map: "IntakeNonIntegratedPricingRow_product_sku_item_fkey")
skuCode String @default("")
barcode String @default("")
costPrice Decimal? @db.Decimal(10, 2)
productTag String @default("")
pricingConfirmed Boolean @default(false)
pricingConfirmedAt DateTime?
systemEnteredAt DateTime?
jstVirtualInventoryStatus String @default("")
jstVirtualInventoryScheduledAt DateTime?
jstVirtualInventoryPushedAt DateTime?
jstVirtualInventoryRetryCount Int @default(0)
jstVirtualInventoryLastAttemptAt DateTime?
jstVirtualInventoryError String @default("")
jstVirtualInventorySoId String @default("")
source String @default("PAYLOAD_SYNC")
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
jstVirtualInventoryError String @default("")
jstVirtualInventorySoId String @default("")
source String @default("PAYLOAD_SYNC")
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([productId, skuItemId])
@@index([productId])
@ -549,19 +549,19 @@ model IntakeNonIntegratedProductLimitRow {
}
model MasterDataQualityDecision {
id String @id @default(cuid())
id String @id @default(cuid())
targetType String
targetId String
targetBusinessKey String @default("")
status String @default("APPROVED_INCOMPLETE")
missingFields String[] @default([])
reason String @db.Text
targetBusinessKey String @default("")
status String @default("APPROVED_INCOMPLETE")
missingFields String[] @default([])
reason String @db.Text
decidedBy String
decidedAt DateTime @default(now())
decidedAt DateTime @default(now())
resolvedBy String?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([targetType, targetId])
@@index([targetBusinessKey])
@ -621,16 +621,16 @@ model ProductPatentRecord {
}
model IntakeReferenceLibraryItem {
id String @id @default(cuid())
id String @id @default(cuid())
libraryType String
itemKey String
code String @default("")
name String @default("")
category String @default("")
letter String @default("")
payload Json @default("{}")
sortOrder Int @default(0)
isActive Boolean @default(true)
code String @default("")
name String @default("")
category String @default("")
letter String @default("")
payload Json @default("{}")
sortOrder Int @default(0)
isActive Boolean @default(true)
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
@ -644,32 +644,34 @@ model IntakeReferenceLibraryItem {
}
model BomCostingCase {
id String @id @default(cuid())
sourceUrl String @db.Text
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("")
analysisSchemaVersion String @default("competitor-analysis/v1")
status BomCostingCaseStatus @default(DRAFT)
progress Int @default(0)
statusMessage String @default("")
targetErrorPercent Decimal @default(10) @db.Decimal(5, 2)
currency String @default("CNY")
sourceCacheHit Boolean @default(false)
title String @default("")
providerCode String @default("")
providerTaskId String @default("")
analysisSchemaVersion String @default("competitor-analysis/v1")
status BomCostingCaseStatus @default(DRAFT)
progress Int @default(0)
statusMessage String @default("")
targetErrorPercent Decimal @default(10) @db.Decimal(5, 2)
currency String @default("CNY")
rawAnalysisSnapshot Json?
createdById String
creator User @relation("BomCostingCaseCreator", fields: [createdById], references: [id])
creator User @relation("BomCostingCaseCreator", fields: [createdById], references: [id])
processingStartedAt DateTime?
processingFinishedAt DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
skus BomCostingSku[]
taskEvents BomCostingTaskEvent[]
skus BomCostingSku[]
taskEvents BomCostingTaskEvent[]
analysisRuns BomCostingAnalysisRun[]
reviewInputs BomCostingReviewInput[]
@@index([createdById, deletedAt, createdAt])
@@index([status, deletedAt])
@ -678,29 +680,29 @@ model BomCostingCase {
}
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
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[]
cases BomCostingCase[]
assets BomCostingSourceAsset[]
factAnalyses BomCostingSourceFactAnalysis[]
@@index([status, updatedAt])
@ -708,77 +710,119 @@ model BomCostingSourceSnapshot {
}
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
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 BomCostingSourceFactAnalysis {
id String @id @default(cuid())
id String @id @default(cuid())
sourceSnapshotId String
sourceSnapshot BomCostingSourceSnapshot @relation(fields: [sourceSnapshotId], references: [id], onDelete: Cascade)
sourceSnapshot BomCostingSourceSnapshot @relation(fields: [sourceSnapshotId], references: [id], onDelete: Cascade)
schemaVersion String
revision Int @default(1)
origin BomCostingFactAnalysisOrigin @default(INITIAL)
baseAnalysisId String?
baseAnalysis BomCostingSourceFactAnalysis? @relation("BomCostingFactAnalysisRevisions", fields: [baseAnalysisId], references: [id], onDelete: SetNull)
derivedAnalyses BomCostingSourceFactAnalysis[] @relation("BomCostingFactAnalysisRevisions")
selectedAssetIds Json @default("[]")
assetFingerprint String
status BomCostingFactAnalysisStatus @default(PENDING)
model String @default("")
status BomCostingFactAnalysisStatus @default(PENDING)
model String @default("")
factSnapshot Json?
analyzedImageCount Int @default(0)
totalImageCount Int @default(0)
batchCount Int @default(0)
promptTokens Int @default(0)
completionTokens Int @default(0)
durationMs Int @default(0)
errorMessage String @default("")
analyzedImageCount Int @default(0)
totalImageCount Int @default(0)
batchCount Int @default(0)
promptTokens Int @default(0)
completionTokens Int @default(0)
durationMs Int @default(0)
errorMessage String @default("")
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sourceSnapshotId, schemaVersion])
analysisRuns BomCostingAnalysisRun[]
@@unique([sourceSnapshotId, schemaVersion, revision])
@@index([sourceSnapshotId, status])
@@index([status, updatedAt])
@@index([baseAnalysisId])
}
model BomCostingAnalysisRun {
id String @id @default(cuid())
caseId String
case BomCostingCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
revision Int
mode BomCostingAnalysisRunMode
status BomCostingAnalysisRunStatus @default(PENDING)
factAnalysisId String?
factAnalysis BomCostingSourceFactAnalysis? @relation(fields: [factAnalysisId], references: [id], onDelete: SetNull)
selectedAssetIds Json @default("[]")
instruction String @default("") @db.Text
resultSnapshot Json?
model String @default("")
callId String @default("")
promptTokens Int @default(0)
completionTokens Int @default(0)
durationMs Int @default(0)
errorMessage String @default("")
createdById String?
creator User? @relation("BomCostingAnalysisRunCreator", fields: [createdById], references: [id], onDelete: SetNull)
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([caseId, revision])
@@index([caseId, status])
@@index([factAnalysisId])
@@index([status, updatedAt])
@@index([createdById])
}
model BomCostingSku {
id String @id @default(cuid())
caseId String
case BomCostingCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
externalSkuId String
name String @default("")
structureGroupKey String @default("")
imageUrl String? @db.Text
attributes Json?
lengthMm Decimal? @db.Decimal(12, 3)
widthMm Decimal? @db.Decimal(12, 3)
heightMm Decimal? @db.Decimal(12, 3)
weightKg Decimal? @db.Decimal(12, 3)
packageLengthMm Decimal? @db.Decimal(12, 3)
packageWidthMm Decimal? @db.Decimal(12, 3)
packageHeightMm Decimal? @db.Decimal(12, 3)
packageWeightKg Decimal? @db.Decimal(12, 3)
estimatedCost Decimal? @db.Decimal(12, 2)
costCalculatedAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
caseId String
case BomCostingCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
externalSkuId String
name String @default("")
structureGroupKey String @default("")
imageUrl String? @db.Text
attributes Json?
lengthMm Decimal? @db.Decimal(12, 3)
widthMm Decimal? @db.Decimal(12, 3)
heightMm Decimal? @db.Decimal(12, 3)
weightKg Decimal? @db.Decimal(12, 3)
packageLengthMm Decimal? @db.Decimal(12, 3)
packageWidthMm Decimal? @db.Decimal(12, 3)
packageHeightMm Decimal? @db.Decimal(12, 3)
packageWeightKg Decimal? @db.Decimal(12, 3)
estimatedCost Decimal? @db.Decimal(12, 2)
costCalculatedAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bomLines BomCostingLine[]
bomLines BomCostingLine[]
reviewInputs BomCostingReviewInput[]
@@unique([caseId, externalSkuId])
@@index([caseId, sortOrder])
@ -786,39 +830,40 @@ model BomCostingSku {
}
model BomCostingLine {
id String @id @default(cuid())
skuId String
sku BomCostingSku @relation(fields: [skuId], references: [id], onDelete: Cascade)
externalLineId String
category BomCostingLineCategory
partName String
materialCode String @default("")
materialName String @default("")
materialSpec String @default("")
processName String @default("")
packagingKind String @default("")
lengthMm Decimal? @db.Decimal(12, 3)
widthMm Decimal? @db.Decimal(12, 3)
heightMm Decimal? @db.Decimal(12, 3)
quantity Decimal @default(1) @db.Decimal(12, 4)
unit String @default("")
weightKg Decimal? @db.Decimal(12, 4)
lossFactor Decimal @default(1) @db.Decimal(8, 4)
sourceConfidence Decimal? @db.Decimal(5, 4)
evidence Json?
matchStatus BomCostingMatchStatus @default(UNMATCHED)
matchedLibraryType String @default("")
matchedLibraryRecordId String @default("")
matchedLibraryName String @default("")
matchedLibrarySpec String @default("")
resolvedUnitPrice Decimal? @db.Decimal(12, 4)
calculatedQuantity Decimal? @db.Decimal(14, 6)
subtotal Decimal? @db.Decimal(12, 2)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
skuId String
sku BomCostingSku @relation(fields: [skuId], references: [id], onDelete: Cascade)
externalLineId String
category BomCostingLineCategory
partName String
materialCode String @default("")
materialName String @default("")
materialSpec String @default("")
processName String @default("")
packagingKind String @default("")
lengthMm Decimal? @db.Decimal(12, 3)
widthMm Decimal? @db.Decimal(12, 3)
heightMm Decimal? @db.Decimal(12, 3)
quantity Decimal @default(1) @db.Decimal(12, 4)
unit String @default("")
weightKg Decimal? @db.Decimal(12, 4)
lossFactor Decimal @default(1) @db.Decimal(8, 4)
sourceConfidence Decimal? @db.Decimal(5, 4)
evidence Json?
matchStatus BomCostingMatchStatus @default(UNMATCHED)
matchedLibraryType String @default("")
matchedLibraryRecordId String @default("")
matchedLibraryName String @default("")
matchedLibrarySpec String @default("")
resolvedUnitPrice Decimal? @db.Decimal(12, 4)
calculatedQuantity Decimal? @db.Decimal(14, 6)
subtotal Decimal? @db.Decimal(12, 2)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
priceSnapshots BomCostingPriceSnapshot[]
reviewInputs BomCostingReviewInput[]
@@unique([skuId, externalLineId])
@@index([skuId, sortOrder])
@ -845,6 +890,42 @@ model BomCostingPriceSnapshot {
@@index([sourceType, sourceRecordId])
}
model BomCostingReviewInput {
id String @id @default(cuid())
caseId String
case BomCostingCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
skuId String?
sku BomCostingSku? @relation(fields: [skuId], references: [id], onDelete: SetNull)
lineId String?
line BomCostingLine? @relation(fields: [lineId], references: [id], onDelete: SetNull)
skuExternalId String @default("")
lineExternalId String @default("")
skuName String @default("")
partName String @default("")
createdById String?
creator User? @relation("BomCostingReviewInputCreator", fields: [createdById], references: [id], onDelete: SetNull)
source BomCostingReviewInputSource @default(MANUAL)
lengthMm Decimal? @db.Decimal(12, 3)
widthMm Decimal? @db.Decimal(12, 3)
heightMm Decimal? @db.Decimal(12, 3)
quantity Decimal? @db.Decimal(12, 4)
materialCode String @default("")
materialName String @default("")
materialSpec String @default("")
unitPrice Decimal? @db.Decimal(12, 4)
priceUnit String @default("")
currency String @default("CNY")
lossFactor Decimal? @db.Decimal(8, 4)
subtotal Decimal? @db.Decimal(12, 2)
note String @default("") @db.Text
createdAt DateTime @default(now())
@@index([caseId, createdAt])
@@index([lineId, createdAt])
@@index([caseId, skuExternalId, lineExternalId, createdAt])
@@index([createdById, createdAt])
}
model BomCostingTaskEvent {
id String @id @default(cuid())
caseId String
@ -1528,7 +1609,7 @@ model AiRenderWorkbenchSpu {
sortOrder Int @default(0)
createdBy String
creator User @relation("AiRenderWorkbenchSpuCreator", fields: [createdBy], references: [id], onDelete: Cascade)
creator User @relation("AiRenderWorkbenchSpuCreator", fields: [createdBy], references: [id], onDelete: Cascade)
updatedBy String?
skus AiRenderWorkbenchSku[]
@ -1557,7 +1638,7 @@ model AiRenderWorkbenchSku {
sortOrder Int @default(0)
createdBy String
creator User @relation("AiRenderWorkbenchSkuCreator", fields: [createdBy], references: [id], onDelete: Cascade)
creator User @relation("AiRenderWorkbenchSkuCreator", fields: [createdBy], references: [id], onDelete: Cascade)
updatedBy String?
renders AssistedAssemblyAiRender[]
@ -1626,7 +1707,7 @@ model PreSkuBasicInfo {
manualUpdatedBy String?
manualUpdatedByName String?
manualUpdatedAt DateTime?
source String @default("DRAFT_GROUP")
source String @default("DRAFT_GROUP")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -1761,23 +1842,23 @@ model ProductSkuQualityInspection {
skuId String
sku ProductSKU @relation("ProductSKUQualityInspections", fields: [skuId], references: [id], onDelete: Cascade)
recordKey String @unique
sourceType String @default("WAREHOUSE")
sourceProductId String?
recordKey String @unique
sourceType String @default("WAREHOUSE")
sourceProductId String?
sourceProductSpu String?
sourceSkuItemId String?
sourceSkuItemId String?
batchNo String
inspectedAt DateTime?
inspectorName String?
conclusion String
detail String? @db.Text
batchNo String
inspectedAt DateTime?
inspectorName String?
conclusion String
detail String? @db.Text
inspectedQuantity Int?
qualifiedQuantity Int?
qualifiedRate Float?
metrics Json?
attachments Json?
status String @default("DRAFT")
qualifiedRate Float?
metrics Json?
attachments Json?
status String @default("DRAFT")
createdBy String?
updatedBy String?
@ -1864,6 +1945,26 @@ enum BomCostingFactAnalysisStatus {
FAILED
}
enum BomCostingFactAnalysisOrigin {
INITIAL
SELECTED_REANALYSIS
FULL_REANALYSIS
}
enum BomCostingAnalysisRunMode {
INITIAL
REUSE_FACTS
REANALYZE_SELECTED
REANALYZE_ALL
}
enum BomCostingAnalysisRunStatus {
PENDING
PROCESSING
READY
FAILED
}
enum BomCostingLineCategory {
BOARD
STEEL
@ -1879,6 +1980,11 @@ enum BomCostingMatchStatus {
NEEDS_CONFIRMATION
}
enum BomCostingReviewInputSource {
MANUAL
LIBRARY_MATCH
}
enum ApprovalWorkflowStatus {
DRAFT
PENDING
@ -2003,47 +2109,49 @@ model User {
status UserStatus @default(PENDING)
// 关联
createdProducts Product[] @relation("ProductCreator")
ownedSKUs ProductSKU[] @relation("SKUOwner")
createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator")
createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator")
createdPlanningDrafts PlanningDraft[] @relation("PlanningDraftCreator")
createdSubstrates Substrate[] @relation("SubstrateCreator")
createdAssetFamilies AssetFamily[] @relation("AssetFamilyCreator")
submittedAssetFamilies AssetFamily[] @relation("AssetFamilySubmitter")
reviewedAssetFamilies AssetFamily[] @relation("AssetFamilyReviewer")
rejectedAssetFamilies AssetFamily[] @relation("AssetFamilyRejecter")
modifiedAssetFamilies AssetFamily[] @relation("AssetFamilyLastModifier")
deletedAssetFamilies AssetFamily[] @relation("AssetFamilyDeleter")
createdAssetVariants AssetVariant[] @relation("AssetVariantCreator")
submittedAssetVariants AssetVariant[] @relation("AssetVariantSubmitter")
reviewedAssetVariants AssetVariant[] @relation("AssetVariantReviewer")
rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter")
modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier")
deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter")
createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator")
createdDraftGroups DraftGroup[] @relation("DraftGroupCreator")
createdAiRenderWorkbenchSpus AiRenderWorkbenchSpu[] @relation("AiRenderWorkbenchSpuCreator")
createdAiRenderWorkbenchSkus AiRenderWorkbenchSku[] @relation("AiRenderWorkbenchSkuCreator")
assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator")
createdProducts Product[] @relation("ProductCreator")
ownedSKUs ProductSKU[] @relation("SKUOwner")
createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator")
createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator")
createdPlanningDrafts PlanningDraft[] @relation("PlanningDraftCreator")
createdSubstrates Substrate[] @relation("SubstrateCreator")
createdAssetFamilies AssetFamily[] @relation("AssetFamilyCreator")
submittedAssetFamilies AssetFamily[] @relation("AssetFamilySubmitter")
reviewedAssetFamilies AssetFamily[] @relation("AssetFamilyReviewer")
rejectedAssetFamilies AssetFamily[] @relation("AssetFamilyRejecter")
modifiedAssetFamilies AssetFamily[] @relation("AssetFamilyLastModifier")
deletedAssetFamilies AssetFamily[] @relation("AssetFamilyDeleter")
createdAssetVariants AssetVariant[] @relation("AssetVariantCreator")
submittedAssetVariants AssetVariant[] @relation("AssetVariantSubmitter")
reviewedAssetVariants AssetVariant[] @relation("AssetVariantReviewer")
rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter")
modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier")
deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter")
createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator")
createdDraftGroups DraftGroup[] @relation("DraftGroupCreator")
createdAiRenderWorkbenchSpus AiRenderWorkbenchSpu[] @relation("AiRenderWorkbenchSpuCreator")
createdAiRenderWorkbenchSkus AiRenderWorkbenchSku[] @relation("AiRenderWorkbenchSkuCreator")
assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator")
createdIntakeProductModelAssets IntakeProductModelAsset[] @relation("IntakeProductModelAssetCreator")
createdIntakeProductModelVersions IntakeProductModelVersion[] @relation("IntakeProductModelVersionCreator")
createdIntakeSkuAiProfiles IntakeSkuAiProfile[] @relation("IntakeSkuAiProfileCreator")
createdIntakeSkuAiMaterialVersions IntakeSkuAiMaterialVersion[] @relation("IntakeSkuAiMaterialVersionCreator")
collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser")
addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy")
feedbacks Feedback[] @relation("FeedbackSubmitter")
createdBomCostingCases BomCostingCase[] @relation("BomCostingCaseCreator")
assignedFeedbacks Feedback[] @relation("FeedbackAssignee")
createdApprovalWorkflows ApprovalWorkflow[] @relation("ApprovalWorkflowCreator")
approvalWorkflowChanges ApprovalWorkflowFieldChange[]
barcodePrintImportRecords BarcodePrintImportRecord[] @relation("BarcodePrintImportRecordCreator")
workRecordEntries WorkRecord[] @relation("WorkRecordDesigner")
collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser")
addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy")
feedbacks Feedback[] @relation("FeedbackSubmitter")
createdBomCostingCases BomCostingCase[] @relation("BomCostingCaseCreator")
createdBomCostingAnalysisRuns BomCostingAnalysisRun[] @relation("BomCostingAnalysisRunCreator")
createdBomCostingReviewInputs BomCostingReviewInput[] @relation("BomCostingReviewInputCreator")
assignedFeedbacks Feedback[] @relation("FeedbackAssignee")
createdApprovalWorkflows ApprovalWorkflow[] @relation("ApprovalWorkflowCreator")
approvalWorkflowChanges ApprovalWorkflowFieldChange[]
barcodePrintImportRecords BarcodePrintImportRecord[] @relation("BarcodePrintImportRecordCreator")
workRecordEntries WorkRecord[] @relation("WorkRecordDesigner")
// 操作日志
activityLogs ActivityLog[]
operationLogs OperationLog[] @relation("OperationLogActor")
createdReleaseNotes ReleaseNote[] @relation("ReleaseNoteCreator")
activityLogs ActivityLog[]
operationLogs OperationLog[] @relation("OperationLogActor")
createdReleaseNotes ReleaseNote[] @relation("ReleaseNoteCreator")
releaseNoteAcknowledgements ReleaseNoteAcknowledgement[] @relation("ReleaseNoteAcknowledgementReader")
createdAt DateTime @default(now())
@ -2356,20 +2464,20 @@ model OperationLog {
status OperationLogStatus
source OperationLogSource @default(USER)
actorId String?
actor User? @relation("OperationLogActor", fields: [actorId], references: [id], onDelete: SetNull)
actorId String?
actor User? @relation("OperationLogActor", fields: [actorId], references: [id], onDelete: SetNull)
actorName String
productId String?
productSpu String?
skuId String?
skuCode String?
productId String?
productSpu String?
skuId String?
skuCode String?
workspaceCategory String?
errorCode String?
message String @db.Text
message String @db.Text
requestId String?
details Json?
details Json?
createdAt DateTime @default(now())
@ -2419,12 +2527,12 @@ model ReleaseNoteAcknowledgement {
// 产品部工作记录与月度归档
model WorkRecordScoreRule {
id String @id @default(cuid())
id String @id @default(cuid())
primary String
secondary String @default("")
secondary String @default("")
unitScore Decimal? @db.Decimal(12, 4)
sortOrder Int @default(0)
isActive Boolean @default(true)
sortOrder Int @default(0)
isActive Boolean @default(true)
records WorkRecord[]
@ -2444,22 +2552,22 @@ model WorkRecord {
designer User @relation("WorkRecordDesigner", fields: [designerId], references: [id], onDelete: Restrict)
primary String
secondary String @default("")
secondary String @default("")
scoreRuleId String?
scoreRule WorkRecordScoreRule? @relation(fields: [scoreRuleId], references: [id], onDelete: SetNull)
approvalOrTask String @default("") @db.Text
spu String @default("")
quantity Decimal @default(1) @db.Decimal(12, 2)
unitScore Decimal? @db.Decimal(12, 4)
taskTotal Decimal? @db.Decimal(14, 4)
fileStoragePath String @default("") @db.Text
approvalOrTask String @default("") @db.Text
spu String @default("")
quantity Decimal @default(1) @db.Decimal(12, 2)
unitScore Decimal? @db.Decimal(12, 4)
taskTotal Decimal? @db.Decimal(14, 4)
fileStoragePath String @default("") @db.Text
createdById String
updatedById String
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([workDate, deletedAt])
@@index([designerId, workDate])

View File

@ -0,0 +1,73 @@
export {}
const mockHandleRequest = jest.fn()
const mockJsonError = jest.fn()
const mockJsonSuccess = jest.fn()
const mockResumeRecalculation = 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),
jsonError: (...args: unknown[]) => mockJsonError(...args),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
}))
jest.mock('@/server/services/bom-costing/bom-costing.service', () => ({
bomCostingService: {
resumeRecalculation: (...args: unknown[]) =>
mockResumeRecalculation(...args),
processCase: (...args: unknown[]) => mockProcessCase(...args),
},
}))
const { POST } = require('./route')
describe('POST /api/bom-costing/cases/:id/recalculate/resume', () => {
const user = {
id: 'user-1',
role: 'USER',
businessRoles: ['OPERATIONS'],
}
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request, handler) =>
handler({ requestId: 'req-recalculate-resume', user }),
)
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
success: true,
data,
status,
requestId,
}))
})
it('继续失败的运行版本且不重复已经完成的事实图片', async () => {
mockResumeRecalculation.mockResolvedValue({
case: { id: 'case-ready', status: 'QUEUED' },
shouldSchedule: true,
resumed: true,
})
mockProcessCase.mockResolvedValue(undefined)
const response = await POST(
{},
{ params: Promise.resolve({ id: 'case-ready' }) },
)
expect(mockResumeRecalculation).toHaveBeenCalledWith('case-ready', user)
expect(response).toMatchObject({
success: true,
status: 202,
data: { resumed: true },
})
const callback = mockAfter.mock.calls[0][0] as () => Promise<void>
await callback()
expect(mockProcessCase).toHaveBeenCalledWith('case-ready')
})
})

View File

@ -0,0 +1,45 @@
import { after, NextRequest } from 'next/server'
import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service'
type RouteContext = {
params: Promise<{ id: string }>
}
export async function POST(request: NextRequest, context: RouteContext) {
return handleRequest(
request,
async (ctx) => {
const { id } = await context.params
try {
const result = await bomCostingService.resumeRecalculation(
decodeURIComponent(id),
ctx.user,
)
if (result.shouldSchedule) {
after(() => bomCostingService.processCase(result.case.id))
}
return jsonSuccess(
{
case: result.case,
resumed: result.resumed,
},
result.shouldSchedule ? 202 : 200,
ctx.requestId,
)
} catch (error) {
if (error instanceof AppError) {
return jsonError(
error.code,
error.message,
error.statusCode,
ctx.requestId,
)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,115 @@
export {}
const mockHandleRequest = jest.fn()
const mockJsonError = jest.fn()
const mockJsonSuccess = jest.fn()
const mockRecalculateCase = 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),
jsonError: (...args: unknown[]) => mockJsonError(...args),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
}))
jest.mock('@/server/services/bom-costing/bom-costing.service', () => ({
bomCostingService: {
recalculateCase: (...args: unknown[]) => mockRecalculateCase(...args),
processCase: (...args: unknown[]) => mockProcessCase(...args),
},
}))
const { POST } = require('./route')
describe('POST /api/bom-costing/cases/:id/recalculate', () => {
const user = {
id: 'user-1',
role: 'USER',
businessRoles: ['OPERATIONS'],
}
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request, handler) =>
handler({ requestId: 'req-recalculate', user }),
)
mockJsonError.mockImplementation((code, message, status, requestId) => ({
success: false,
error: { code, message },
status,
requestId,
}))
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
success: true,
data,
status,
requestId,
}))
})
it('校验选图模式并在响应后调度重新核算', async () => {
mockRecalculateCase.mockResolvedValue({
case: { id: 'case-ready', status: 'QUEUED' },
shouldSchedule: true,
started: true,
})
mockProcessCase.mockResolvedValue(undefined)
const request = {
json: async () => ({
mode: 'REANALYZE_SELECTED',
selectedAssetIds: ['asset-1'],
instruction: '重点核对折叠机构',
}),
}
const response = await POST(
request,
{ params: Promise.resolve({ id: 'case-ready' }) },
)
expect(mockRecalculateCase).toHaveBeenCalledWith(
'case-ready',
{
mode: 'REANALYZE_SELECTED',
selectedAssetIds: ['asset-1'],
instruction: '重点核对折叠机构',
},
user,
)
expect(response).toMatchObject({
success: true,
status: 202,
data: { started: true },
})
const callback = mockAfter.mock.calls[0][0] as () => Promise<void>
await callback()
expect(mockProcessCase).toHaveBeenCalledWith('case-ready')
})
it('选图模式没有图片时返回 400 且不启动任务', async () => {
const request = {
json: async () => ({
mode: 'REANALYZE_SELECTED',
selectedAssetIds: [],
instruction: '',
}),
}
const response = await POST(
request,
{ params: Promise.resolve({ id: 'case-ready' }) },
)
expect(response).toMatchObject({
success: false,
status: 400,
})
expect(mockRecalculateCase).not.toHaveBeenCalled()
expect(mockAfter).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,57 @@
import { after, NextRequest } from 'next/server'
import { recalculateBomCostingCaseSchema } from '@/lib/bom-costing/contracts'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service'
type RouteContext = {
params: Promise<{ id: string }>
}
export async function POST(request: NextRequest, context: RouteContext) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = recalculateBomCostingCaseSchema.safeParse(body)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message || '重新核算参数不完整',
400,
ctx.requestId,
)
}
const { id } = await context.params
try {
const result = await bomCostingService.recalculateCase(
decodeURIComponent(id),
parsed.data,
ctx.user,
)
if (result.shouldSchedule) {
after(() => bomCostingService.processCase(result.case.id))
}
return jsonSuccess(
{
case: result.case,
started: result.started,
},
result.shouldSchedule ? 202 : 200,
ctx.requestId,
)
} catch (error) {
if (error instanceof AppError) {
return jsonError(
error.code,
error.message,
error.statusCode,
ctx.requestId,
)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -141,10 +141,23 @@ export function deriveBomCostingImageActivity(
detail: BomCostingCaseDetail,
): BomCostingImageActivity {
const assets = detail.sourceSnapshot?.assets ?? []
const events = [...detail.taskEvents].sort(
const allEvents = [...detail.taskEvents].sort(
(left, right) =>
new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(),
)
const latestRun = detail.analysisRuns?.[0]
const recalculationStartIndex =
latestRun && latestRun.revision > 1
? allEvents.findLastIndex((event) =>
['RECALCULATION_QUEUED', 'RECALCULATION_RESUMED'].includes(
event.eventType,
),
)
: -1
const events =
recalculationStartIndex >= 0
? allEvents.slice(recalculationStartIndex)
: allEvents
const activeArchiveIds = new Set<string>()
const activeModelIds = new Set<string>()
const completedAnalysisAssetIds = new Set<string>()

View File

@ -660,6 +660,212 @@ describe('BomCostingWorkbenchClient', () => {
}
})
it('可选择指定图片复查,并把调用范围与补充要求准确提交', async () => {
const originalFetch = global.fetch
const now = new Date().toISOString()
const readyCase = {
id: 'case-recalculate',
sourceUrl: 'https://example.com/product/recalculate',
sourceHost: 'example.com',
title: '折叠边几',
providerCode: 'fixture',
providerTaskId: 'task-recalculate',
sourceCacheHit: true,
status: 'READY',
progress: 100,
statusMessage: '核算完成',
targetErrorPercent: 10,
currency: 'CNY',
createdById: 'user-1',
processingStartedAt: now,
processingFinishedAt: now,
createdAt: now,
updatedAt: now,
analysisRuns: [{
id: 'run-initial',
revision: 1,
mode: 'INITIAL',
status: 'READY',
factAnalysisId: 'facts-1',
selectedAssetIds: [],
instruction: '',
model: 'fixture',
callId: '',
promptTokens: 0,
completionTokens: 0,
durationMs: 100,
errorMessage: '',
startedAt: now,
completedAt: now,
createdAt: now,
}],
taskEvents: [],
sourceSnapshot: {
id: 'source-recalculate',
status: 'READY',
channel: 'fixture',
externalItemId: 'recalculate',
title: '折叠边几',
shopName: '测试店铺',
skuCount: 1,
imageCount: 2,
storedImageCount: 2,
failedImageCount: 0,
errorMessage: '',
fetchedAt: now,
completedAt: now,
assets: [
{
id: 'asset-main',
kind: 'MAIN',
status: 'STORED',
storedUrl: '/api/storage/object?key=bom-costing%2Fmain.webp',
contentType: 'image/webp',
byteSize: 2048,
externalSkuIds: [],
sortOrder: 0,
errorMessage: '',
},
{
id: 'asset-detail',
kind: 'DETAIL',
status: 'STORED',
storedUrl: '/api/storage/object?key=bom-costing%2Fdetail.webp',
contentType: 'image/webp',
byteSize: 4096,
externalSkuIds: [],
sortOrder: 1,
errorMessage: '',
},
],
},
skus: [{
id: 'sku-recalculate',
externalSkuId: 'sku-recalculate',
name: '原木色',
structureGroupKey: 'folding-table',
imageUrl: null,
attributes: {},
lengthMm: 800,
widthMm: 740,
heightMm: 620,
weightKg: null,
packageLengthMm: null,
packageWidthMm: null,
packageHeightMm: null,
packageWeightKg: null,
estimatedCost: 100,
costCalculatedAt: now,
costSummary: {
material: 60,
processing: 20,
labor: 10,
packaging: 10,
lossIncludedTotal: 100,
unresolvedLineCount: 0,
},
bomLines: [],
}],
}
global.fetch = jest.fn(async (input, init) => {
const url = String(input)
if (url.endsWith('/recalculate')) {
return {
ok: true,
json: async () => ({
success: true,
data: {
started: true,
case: {
...readyCase,
status: 'QUEUED',
progress: 48,
statusMessage: '正在补充分析 1 张图片',
analysisRuns: [{
...readyCase.analysisRuns[0],
id: 'run-selected',
revision: 2,
mode: 'REANALYZE_SELECTED',
status: 'PENDING',
selectedAssetIds: ['asset-detail'],
instruction: '重点核对折叠机构和导轨',
}],
},
},
}),
} as Response
}
return {
ok: true,
json: async () => ({
success: true,
data: { case: readyCase },
}),
} as Response
}) as jest.Mock
try {
render(
<BomCostingWorkbenchClient
initialCapabilities={{
enabled: true,
analysisConfigured: true,
modelingConfigured: false,
analysisProviderCode: 'fixture',
targetErrorPercent: 10,
fixtureAllowed: true,
}}
initialCases={[{
id: readyCase.id,
title: readyCase.title,
sourceUrl: readyCase.sourceUrl,
sourceHost: readyCase.sourceHost,
status: 'READY',
progress: 100,
statusMessage: '核算完成',
sourceCacheHit: true,
storedImageCount: 2,
failedImageCount: 0,
createdById: 'user-1',
createdAt: now,
skuCount: 1,
estimatedCostRange: { min: 100, max: 100 },
}]}
/>,
)
expect(await screen.findByText('预计出厂成本')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^重新核算/ }))
fireEvent.click(screen.getByRole('button', { name: /补充分析图片/ }))
fireEvent.click(
screen.getByRole('button', { name: '选择商品详情图 1进行复查' }),
)
fireEvent.change(screen.getByPlaceholderText(/重点核对折叠机构/), {
target: { value: '重点核对折叠机构和导轨' },
})
fireEvent.click(screen.getByRole('button', { name: '开始重新核算' }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/bom-costing/cases/case-recalculate/recalculate',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
mode: 'REANALYZE_SELECTED',
selectedAssetIds: ['asset-detail'],
instruction: '重点核对折叠机构和导轨',
}),
}),
)
})
expect(
await screen.findByText('重新核算已开始,离开页面也会继续处理'),
).toBeInTheDocument()
} finally {
global.fetch = originalFetch
}
})
it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => {
const originalFetch = global.fetch
const now = new Date().toISOString()
@ -774,11 +980,13 @@ describe('BomCostingWorkbenchClient', () => {
global.fetch = jest.fn(async (input, init) => {
const url = String(input)
if (url.endsWith('/lines/line-rubberwood') && init?.method === 'PATCH') {
const patchBody =
typeof init.body === 'string' ? JSON.parse(init.body) : {}
return {
ok: true,
json: async () => ({
success: true,
data: { case: resolvedDetail },
data: { case: patchBody.review ? detail : resolvedDetail },
}),
} as Response
}
@ -847,6 +1055,43 @@ describe('BomCostingWorkbenchClient', () => {
expect(screen.getByText('待匹配')).toBeInTheDocument()
expect(screen.queryByText('¥0.00')).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '记录橡胶木桌面板人工复核值' }),
)
fireEvent.change(screen.getByLabelText('材质名称'), {
target: { value: '橡胶木实木板' },
})
fireEvent.change(screen.getByLabelText('单价'), {
target: { value: '80' },
})
fireEvent.change(screen.getByLabelText('价格单位'), {
target: { value: '㎡' },
})
fireEvent.click(screen.getByRole('button', { name: '保存并留档' }))
await waitFor(() => {
const reviewPatchCall = (global.fetch as jest.Mock).mock.calls.find(
([url, init]) => {
if (
!String(url).endsWith('/lines/line-rubberwood') ||
init?.method !== 'PATCH' ||
typeof init.body !== 'string'
) {
return false
}
return Boolean(JSON.parse(init.body).review)
},
)
expect(reviewPatchCall).toBeDefined()
expect(JSON.parse(reviewPatchCall?.[1]?.body)).toEqual({
review: expect.objectContaining({
materialName: '橡胶木实木板',
unitPrice: 80,
priceUnit: '㎡',
}),
})
})
fireEvent.click(screen.getByRole('button', { name: /确认价格/ }))
fireEvent.click(
await screen.findByRole('button', { name: /18ENF级橡胶木/ }),
@ -854,10 +1099,18 @@ describe('BomCostingWorkbenchClient', () => {
await waitFor(() => {
const patchCall = (global.fetch as jest.Mock).mock.calls.find(
([url, init]) =>
String(url).endsWith('/lines/line-rubberwood') &&
init?.method === 'PATCH',
([url, init]) => {
if (
!String(url).endsWith('/lines/line-rubberwood') ||
init?.method !== 'PATCH' ||
typeof init.body !== 'string'
) {
return false
}
return JSON.parse(init.body).matchedLibraryRecordId === 'rubberwood-18'
},
)
expect(patchCall).toBeDefined()
expect(JSON.parse(patchCall?.[1]?.body)).toEqual({
matchedLibraryRecordId: 'rubberwood-18',
heightMm: 18,

View File

@ -35,7 +35,7 @@ const NAV_ITEMS = [
export function AppSideNav({ user }: { user?: WorkspacePermissionUser | null }) {
const pathname = usePathname()
const visibleNavItems = user === undefined
? NAV_ITEMS
? NAV_ITEMS.filter((item) => item.href !== '/bom-costing')
: NAV_ITEMS.filter((item) => canAccessWorkspacePath(item.href, user))
const isActive = (href: string) => {

View File

@ -76,7 +76,7 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
user !== undefined && workspaceAccessLevel !== 'full'
const brandHref = shouldRestrictNavigation ? FORMAL_PRODUCTS_HOME_PATH : '/'
const visibleNavItems = user === undefined
? WORKBENCH_NAV_ITEMS
? WORKBENCH_NAV_ITEMS.filter((item) => item.href !== '/bom-costing')
: WORKBENCH_NAV_ITEMS.filter((item) => canAccessWorkspacePath(item.href, user))
const activeNavItem =
visibleNavItems.find((item) => matchesWorkbenchHref(pathname, item.href)) ??

View File

@ -52,7 +52,7 @@ describe('AppTopNav', () => {
const warehouseLink = screen.getByRole('link', { name: '仓库管理' })
const qualityLink = screen.getByRole('link', { name: '仓库质检' })
const aiRenderLink = screen.getByRole('link', { name: 'AI 出图工作台' })
const aiRenderLink = screen.getByRole('link', { name: 'AI出图' })
expect(qualityLink).toHaveAttribute('href', '/warehouse-quality')
expect(aiRenderLink).toHaveAttribute('href', '/ai-render-workbench')
@ -65,11 +65,24 @@ describe('AppTopNav', () => {
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
expect(screen.getAllByRole('link', { name: '仓库质检' })).toHaveLength(1)
expect(screen.getAllByRole('link', { name: 'AI 出图工作台' })).toHaveLength(1)
expect(screen.getAllByRole('link', { name: 'AI出图' })).toHaveLength(1)
})
it('在产品资料附近显示 BOM 核算入口', () => {
render(<AppTopNav variant="workbench" />)
render(
<AppTopNav
variant="workbench"
user={{
id: 'user-1',
name: '企划',
email: 'planning@example.com',
role: 'USER',
businessRoles: ['PLANNING'],
avatar: null,
department: null,
}}
/>,
)
const intakeLink = screen.getByRole('link', { name: '产品资料' })
const costingLink = screen.getByRole('link', { name: 'BOM核算' })
@ -79,6 +92,48 @@ describe('AppTopNav', () => {
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('用户信息加载完成前不展示 BOM 核算入口', () => {
const { container } = render(<AppTopNav variant="workbench" />)
expect(container.querySelector('a[href="/bom-costing"]')).toBeNull()
})
it('BOM 核算入口只对指定业务类型可见', () => {
const { container, rerender } = render(
<AppTopNav
variant="workbench"
user={{
id: 'user-1',
name: '企划',
email: 'planning@example.com',
role: 'USER',
businessRoles: ['PLANNING'],
avatar: null,
department: null,
}}
/>,
)
expect(container.querySelector('a[href="/bom-costing"]')).not.toBeNull()
rerender(
<AppTopNav
variant="workbench"
user={{
id: 'user-2',
name: '策划',
email: 'strategy@example.com',
role: 'USER',
businessRoles: ['STRATEGY'],
avatar: null,
department: null,
}}
/>,
)
expect(container.querySelector('a[href="/bom-costing"]')).toBeNull()
})
})
describe('AppTopNav AI render workbench permissions', () => {

View File

@ -5,7 +5,7 @@ import {
import { canAccessWorkspacePath } from './workspace-route-permissions'
describe('BOM 核算权限', () => {
it.each(['OPERATIONS', 'PLANNING', 'STRATEGY', 'PURCHASING'])(
it.each(['OPERATIONS', 'PLANNING', 'PURCHASING', 'DESIGN'])(
'允许 %s 访问工作台',
(businessRole) => {
const user = {
@ -17,6 +17,19 @@ describe('BOM 核算权限', () => {
},
)
it('不把策划业务类型误当作企划权限', () => {
expect(
canViewBomCostingWorkbench({ role: 'USER', businessRoles: ['STRATEGY'] }),
).toBe(false)
expect(
canAccessWorkspacePath(BOM_COSTING_WORKBENCH_PATH, {
role: 'USER',
businessRoles: ['STRATEGY'],
workspaceAllowedPathPrefixes: [BOM_COSTING_WORKBENCH_PATH],
}),
).toBe(false)
})
it('允许管理员,拒绝普通、供应商和外部录入账号', () => {
expect(canViewBomCostingWorkbench({ role: 'ADMIN' })).toBe(true)
expect(canViewBomCostingWorkbench({ role: 'USER', businessRoles: ['GENERAL'] })).toBe(false)

View File

@ -7,8 +7,8 @@ export const BOM_COSTING_WORKBENCH_PATH = '/bom-costing'
const BOM_COSTING_BUSINESS_ROLES = new Set([
'OPERATIONS',
'PLANNING',
'STRATEGY',
'PURCHASING',
'DESIGN',
])
export function canViewBomCostingWorkbench(

View File

@ -158,6 +158,48 @@ export interface BomCostingCaseDetail {
updatedAt: string
sourceSnapshot: BomCostingSourceSnapshotDetail | null
skus: BomCostingSkuDetail[]
reviewInputs?: Array<{
id: string
skuId: string | null
lineId: string | null
skuName: string
partName: string
source: 'MANUAL' | 'LIBRARY_MATCH'
materialCode: string
materialName: string
materialSpec: string
lengthMm: number | null
widthMm: number | null
heightMm: number | null
quantity: number | null
unitPrice: number | null
priceUnit: string
currency: string
lossFactor: number | null
subtotal: number | null
note: string
createdById: string | null
createdByName: string
createdAt: string
}>
analysisRuns: Array<{
id: string
revision: number
mode: 'INITIAL' | 'REUSE_FACTS' | 'REANALYZE_SELECTED' | 'REANALYZE_ALL'
status: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED'
factAnalysisId: string | null
selectedAssetIds: string[]
instruction: string
model: string
callId: string
promptTokens: number
completionTokens: number
durationMs: number
errorMessage: string
startedAt: string | null
completedAt: string | null
createdAt: string
}>
taskEvents: Array<{
id: string
eventType: string

View File

@ -1,6 +1,8 @@
import {
BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
competitorAnalysisResultV1Schema,
recalculateBomCostingCaseSchema,
updateBomCostingLineSchema,
} from './contracts'
describe('CompetitorAnalysisResultV1', () => {
@ -158,3 +160,66 @@ describe('CompetitorAnalysisResultV1', () => {
}).success).toBe(false)
})
})
describe('recalculateBomCostingCaseSchema', () => {
it('指定图片复查必须至少选择一张图片', () => {
expect(recalculateBomCostingCaseSchema.safeParse({
mode: 'REANALYZE_SELECTED',
selectedAssetIds: [],
instruction: '',
}).success).toBe(false)
expect(recalculateBomCostingCaseSchema.parse({
mode: 'REANALYZE_SELECTED',
selectedAssetIds: ['asset-1'],
instruction: '重点核对折叠机构',
})).toEqual({
mode: 'REANALYZE_SELECTED',
selectedAssetIds: ['asset-1'],
instruction: '重点核对折叠机构',
})
})
it('复用事实与全部重分析不接受混入的图片选择', () => {
expect(recalculateBomCostingCaseSchema.safeParse({
mode: 'REUSE_FACTS',
selectedAssetIds: ['asset-1'],
}).success).toBe(false)
expect(recalculateBomCostingCaseSchema.safeParse({
mode: 'REANALYZE_ALL',
selectedAssetIds: [],
}).success).toBe(true)
})
})
describe('updateBomCostingLineSchema', () => {
it('接受人工复核的尺寸、材质、价格和备注', () => {
const result = updateBomCostingLineSchema.parse({
review: {
materialName: '橡胶木实木板',
materialSpec: '18mm',
lengthMm: 1200,
widthMm: 600,
heightMm: 18,
quantity: 1,
unitPrice: 80,
priceUnit: '㎡',
lossFactor: 1.08,
note: '详情图复核',
},
})
expect(result.review).toEqual({
materialName: '橡胶木实木板',
materialSpec: '18mm',
lengthMm: 1200,
widthMm: 600,
heightMm: 18,
quantity: 1,
unitPrice: 80,
priceUnit: '㎡',
lossFactor: 1.08,
note: '详情图复核',
})
})
})

View File

@ -163,6 +163,48 @@ export const createBomCostingCaseSchema = z.object({
sourceUrl: z.string().trim().min(1).max(2_048),
})
export const bomCostingRecalculationModeSchema = z.enum([
'REUSE_FACTS',
'REANALYZE_SELECTED',
'REANALYZE_ALL',
])
export const recalculateBomCostingCaseSchema = z.object({
mode: bomCostingRecalculationModeSchema,
selectedAssetIds: z.array(z.string().trim().min(1).max(200))
.max(500)
.default([]),
instruction: z.string().trim().max(1_000).default(''),
}).superRefine((input, context) => {
if (
input.mode === 'REANALYZE_SELECTED' &&
input.selectedAssetIds.length === 0
) {
context.addIssue({
code: 'custom',
path: ['selectedAssetIds'],
message: '请选择需要重新分析的图片',
})
}
if (
input.mode !== 'REANALYZE_SELECTED' &&
input.selectedAssetIds.length > 0
) {
context.addIssue({
code: 'custom',
path: ['selectedAssetIds'],
message: '当前重新核算方式不接收指定图片',
})
}
})
export type BomCostingRecalculationModeValue = z.infer<
typeof bomCostingRecalculationModeSchema
>
export type RecalculateBomCostingCaseInput = z.infer<
typeof recalculateBomCostingCaseSchema
>
export const updateBomCostingLineSchema = z.object({
matchedLibraryRecordId: z.string().trim().max(200).nullable().optional(),
lengthMm: z.number().finite().positive().nullable().optional(),
@ -170,6 +212,19 @@ export const updateBomCostingLineSchema = z.object({
heightMm: z.number().finite().positive().nullable().optional(),
quantity: z.number().finite().positive().optional(),
lossFactor: z.number().finite().min(1).max(3).optional(),
review: z.object({
materialCode: z.string().trim().max(120).optional(),
materialName: z.string().trim().max(200).optional(),
materialSpec: z.string().trim().max(200).optional(),
lengthMm: z.number().finite().positive().nullable().optional(),
widthMm: z.number().finite().positive().nullable().optional(),
heightMm: z.number().finite().positive().nullable().optional(),
quantity: z.number().finite().positive().optional(),
unitPrice: z.number().finite().positive().nullable().optional(),
priceUnit: z.string().trim().max(40).optional(),
lossFactor: z.number().finite().min(1).max(3).optional(),
note: z.string().trim().max(1_000).optional(),
}).optional(),
})
export type UpdateBomCostingLineInput = z.infer<typeof updateBomCostingLineSchema>

View File

@ -100,10 +100,7 @@ export function canAccessWorkspacePath(
pathname === BOM_COSTING_WORKBENCH_PATH ||
pathname.startsWith(`${BOM_COSTING_WORKBENCH_PATH}/`)
) {
return (
canViewBomCostingWorkbench(user) ||
isPathAllowedByPrefixes(pathname, user?.workspaceAllowedPathPrefixes)
)
return canViewBomCostingWorkbench(user)
}
if (

View File

@ -1,10 +1,14 @@
import {
BomCostingAnalysisRunMode,
BomCostingAnalysisRunStatus,
BomCostingCaseStatus,
BomCostingFactAnalysisOrigin,
BomCostingLineCategory,
BomCostingMatchStatus,
BomCostingSourceAssetKind,
BomCostingSourceAssetStatus,
BomCostingSourceStatus,
BomCostingReviewInputSource,
Prisma,
} from '@prisma/client'
import { prisma } from '@/lib/prisma'
@ -56,6 +60,25 @@ const caseDetailInclude = Prisma.validator<Prisma.BomCostingCaseInclude>()({
orderBy: { createdAt: 'desc' },
take: 120,
},
analysisRuns: {
orderBy: { revision: 'desc' },
take: 20,
},
reviewInputs: {
orderBy: { createdAt: 'desc' },
take: 100,
include: {
creator: {
select: { id: true, name: true },
},
sku: {
select: { name: true },
},
line: {
select: { partName: true },
},
},
},
})
export type BomCostingCaseDetailRecord = Prisma.BomCostingCaseGetPayload<{
@ -72,6 +95,31 @@ export interface BomCostingPreparedPriceSnapshot {
lossFactor: number
}
export interface BomCostingReviewInputData {
caseId: string
skuId: string | null
lineId: string | null
skuExternalId: string
lineExternalId: string
skuName: string
partName: string
createdById: string | null
source: BomCostingReviewInputSource
lengthMm: number | null
widthMm: number | null
heightMm: number | null
quantity: number | null
materialCode: string
materialName: string
materialSpec: string
unitPrice: number | null
priceUnit: string
currency?: string
lossFactor: number | null
subtotal: number | null
note: string
}
export interface BomCostingPreparedLine {
externalLineId: string
category: BomCostingLineCategory
@ -345,59 +393,119 @@ export class BomCostingRepository {
model: string
totalImageCount: number
}) {
const record = await prisma.bomCostingSourceFactAnalysis.upsert({
where: {
sourceSnapshotId_schemaVersion: {
for (let attempt = 0; attempt < 3; attempt += 1) {
const latest = await prisma.bomCostingSourceFactAnalysis.findFirst({
where: {
sourceSnapshotId: input.sourceSnapshotId,
schemaVersion: input.schemaVersion,
},
},
create: {
orderBy: { revision: 'desc' },
})
if (
latest?.assetFingerprint === input.assetFingerprint &&
(
latest.status !== BOM_COSTING_FACT_ANALYSIS_STATUS.READY ||
latest.analyzedImageCount === input.totalImageCount
)
) {
return latest
}
try {
return await prisma.bomCostingSourceFactAnalysis.create({
data: {
sourceSnapshotId: input.sourceSnapshotId,
schemaVersion: input.schemaVersion,
revision: (latest?.revision ?? 0) + 1,
origin: BomCostingFactAnalysisOrigin.INITIAL,
baseAnalysisId:
latest?.status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY
? latest.id
: null,
assetFingerprint: input.assetFingerprint,
model: input.model,
totalImageCount: input.totalImageCount,
},
})
} catch (error) {
if (
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
error.code !== 'P2002' ||
attempt === 2
) {
throw error
}
}
}
throw new Error('BOM 图片事实版本创建失败')
}
async findLatestReadySourceFactAnalysis(input: {
sourceSnapshotId: string
schemaVersion: string
assetFingerprint: string
}) {
return prisma.bomCostingSourceFactAnalysis.findFirst({
where: {
sourceSnapshotId: input.sourceSnapshotId,
schemaVersion: input.schemaVersion,
assetFingerprint: input.assetFingerprint,
model: input.model,
totalImageCount: input.totalImageCount,
},
update: {
model: input.model,
totalImageCount: input.totalImageCount,
status: BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
},
orderBy: { revision: 'desc' },
})
if (record.assetFingerprint === input.assetFingerprint) {
if (
record.status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY &&
record.analyzedImageCount !== input.totalImageCount
) {
return prisma.bomCostingSourceFactAnalysis.update({
where: { id: record.id },
}
async createSourceFactAnalysisRevision(input: {
sourceSnapshotId: string
schemaVersion: string
assetFingerprint: string
origin: BomCostingFactAnalysisOrigin
baseAnalysisId: string | null
selectedAssetIds: string[]
model: string
factSnapshot: Prisma.InputJsonValue | typeof Prisma.JsonNull
analyzedImageCount: number
totalImageCount: number
}) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const latest = await prisma.bomCostingSourceFactAnalysis.findFirst({
where: {
sourceSnapshotId: input.sourceSnapshotId,
schemaVersion: input.schemaVersion,
},
orderBy: { revision: 'desc' },
select: { revision: true },
})
try {
return await prisma.bomCostingSourceFactAnalysis.create({
data: {
status: BOM_COSTING_FACT_ANALYSIS_STATUS.PENDING,
completedAt: null,
sourceSnapshotId: input.sourceSnapshotId,
schemaVersion: input.schemaVersion,
revision: (latest?.revision ?? 0) + 1,
origin: input.origin,
baseAnalysisId: input.baseAnalysisId,
selectedAssetIds: input.selectedAssetIds,
assetFingerprint: input.assetFingerprint,
model: input.model,
factSnapshot: input.factSnapshot,
analyzedImageCount: input.analyzedImageCount,
totalImageCount: input.totalImageCount,
},
})
} catch (error) {
if (
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
error.code !== 'P2002' ||
attempt === 2
) {
throw error
}
}
return record
}
return prisma.bomCostingSourceFactAnalysis.update({
where: { id: record.id },
data: {
assetFingerprint: input.assetFingerprint,
status: BOM_COSTING_FACT_ANALYSIS_STATUS.PENDING,
model: input.model,
factSnapshot: Prisma.JsonNull,
analyzedImageCount: 0,
totalImageCount: input.totalImageCount,
batchCount: 0,
promptTokens: 0,
completionTokens: 0,
durationMs: 0,
errorMessage: '',
startedAt: null,
completedAt: null,
},
})
throw new Error('BOM 图片事实修订版本创建失败')
}
async findSourceFactAnalysis(id: string) {
@ -522,6 +630,14 @@ export class BomCostingRepository {
},
},
},
analysisRuns: {
create: {
revision: 1,
mode: BomCostingAnalysisRunMode.INITIAL,
status: BomCostingAnalysisRunStatus.PENDING,
createdById: input.createdById,
},
},
},
include: caseDetailInclude,
})
@ -541,6 +657,25 @@ export class BomCostingRepository {
},
})
if (claimed.count !== 1) return false
const pendingRun = await tx.bomCostingAnalysisRun.findFirst({
where: {
caseId: id,
status: BomCostingAnalysisRunStatus.PENDING,
},
orderBy: { revision: 'desc' },
})
if (!pendingRun) {
throw new Error('BOM 核算运行版本不存在')
}
await tx.bomCostingAnalysisRun.update({
where: { id: pendingRun.id },
data: {
status: BomCostingAnalysisRunStatus.PROCESSING,
errorMessage: '',
startedAt: new Date(),
completedAt: null,
},
})
const record = await tx.bomCostingCase.findUnique({
where: { id },
select: {
@ -564,6 +699,173 @@ export class BomCostingRepository {
})
}
async findProcessingAnalysisRun(caseId: string) {
return prisma.bomCostingAnalysisRun.findFirst({
where: {
caseId,
status: BomCostingAnalysisRunStatus.PROCESSING,
},
orderBy: { revision: 'desc' },
})
}
async linkAnalysisRunFact(runId: string, factAnalysisId: string) {
return prisma.bomCostingAnalysisRun.update({
where: { id: runId },
data: { factAnalysisId },
})
}
async createRecalculationRun(input: {
caseId: string
mode: Exclude<BomCostingAnalysisRunMode, 'INITIAL'>
selectedAssetIds: string[]
instruction: string
createdById: string
statusMessage: string
}) {
return prisma.$transaction(async (tx) => {
const queued = await tx.bomCostingCase.updateMany({
where: {
id: input.caseId,
status: BomCostingCaseStatus.READY,
deletedAt: null,
},
data: {
status: BomCostingCaseStatus.QUEUED,
progress: 48,
statusMessage: input.statusMessage,
processingStartedAt: null,
processingFinishedAt: null,
},
})
if (queued.count !== 1) return false
const activeRunCount = await tx.bomCostingAnalysisRun.count({
where: {
caseId: input.caseId,
status: {
in: [
BomCostingAnalysisRunStatus.PENDING,
BomCostingAnalysisRunStatus.PROCESSING,
],
},
},
})
if (activeRunCount > 0) {
throw new Error('BOM 核算案例存在未完成的运行版本')
}
const current = await tx.bomCostingCase.findUniqueOrThrow({
where: { id: input.caseId },
select: { providerTaskId: true },
})
const latest = await tx.bomCostingAnalysisRun.findFirst({
where: { caseId: input.caseId },
orderBy: { revision: 'desc' },
select: { revision: true },
})
const revision = (latest?.revision ?? 0) + 1
const run = await tx.bomCostingAnalysisRun.create({
data: {
caseId: input.caseId,
revision,
mode: input.mode,
status: BomCostingAnalysisRunStatus.PENDING,
selectedAssetIds: input.selectedAssetIds,
instruction: input.instruction,
createdById: input.createdById,
},
})
await tx.bomCostingTaskEvent.create({
data: {
caseId: input.caseId,
eventType: 'RECALCULATION_QUEUED',
status: BomCostingCaseStatus.QUEUED,
progress: 48,
message: input.statusMessage,
providerTaskId: current.providerTaskId,
payload: {
runId: run.id,
revision,
mode: input.mode,
selectedAssetIds: input.selectedAssetIds,
instruction: input.instruction,
},
},
})
return true
})
}
async resumeFailedRecalculation(input: {
caseId: string
statusMessage: string
}) {
return prisma.$transaction(async (tx) => {
const run = await tx.bomCostingAnalysisRun.findFirst({
where: {
caseId: input.caseId,
revision: { gt: 1 },
status: BomCostingAnalysisRunStatus.FAILED,
},
orderBy: { revision: 'desc' },
})
if (!run) return false
const queued = await tx.bomCostingCase.updateMany({
where: {
id: input.caseId,
status: BomCostingCaseStatus.READY,
deletedAt: null,
},
data: {
status: BomCostingCaseStatus.QUEUED,
progress: 48,
statusMessage: input.statusMessage,
processingStartedAt: null,
processingFinishedAt: null,
},
})
if (queued.count !== 1) return false
const resumed = await tx.bomCostingAnalysisRun.updateMany({
where: {
id: run.id,
status: BomCostingAnalysisRunStatus.FAILED,
},
data: {
status: BomCostingAnalysisRunStatus.PENDING,
errorMessage: '',
startedAt: null,
completedAt: null,
},
})
if (resumed.count !== 1) {
throw new Error('BOM 重新核算运行版本状态已变化')
}
const current = await tx.bomCostingCase.findUniqueOrThrow({
where: { id: input.caseId },
select: { providerTaskId: true },
})
await tx.bomCostingTaskEvent.create({
data: {
caseId: input.caseId,
eventType: 'RECALCULATION_RESUMED',
status: BomCostingCaseStatus.QUEUED,
progress: 48,
message: input.statusMessage,
providerTaskId: current.providerTaskId,
payload: {
runId: run.id,
revision: run.revision,
mode: run.mode,
selectedAssetIds: run.selectedAssetIds,
},
},
})
return true
})
}
async resumeFailedCase(input: {
caseId: string
statusMessage: string
@ -588,6 +890,25 @@ export class BomCostingRepository {
})
if (resumed.count !== 1) return false
const failedRun = await tx.bomCostingAnalysisRun.findFirst({
where: {
caseId: input.caseId,
status: BomCostingAnalysisRunStatus.FAILED,
},
orderBy: { revision: 'desc' },
})
if (failedRun) {
await tx.bomCostingAnalysisRun.update({
where: { id: failedRun.id },
data: {
status: BomCostingAnalysisRunStatus.PENDING,
errorMessage: '',
startedAt: null,
completedAt: null,
},
})
}
const record = await tx.bomCostingCase.findUnique({
where: { id: input.caseId },
select: { providerTaskId: true },
@ -792,15 +1113,37 @@ export class BomCostingRepository {
async completeCase(input: {
caseId: string
analysisRunId: string
factAnalysisId: string | null
title: string
analysis: Prisma.InputJsonValue
skus: BomCostingPreparedSku[]
eventPayload: Prisma.InputJsonValue
model: string
callId: string | null
promptTokens: number | null
completionTokens: number | null
durationMs: number
}) {
return prisma.$transaction(async (tx) => {
await tx.bomCostingSku.deleteMany({
where: { caseId: input.caseId },
})
await tx.bomCostingAnalysisRun.update({
where: { id: input.analysisRunId },
data: {
status: BomCostingAnalysisRunStatus.READY,
factAnalysisId: input.factAnalysisId,
resultSnapshot: input.analysis,
model: input.model,
callId: input.callId ?? '',
promptTokens: input.promptTokens ?? 0,
completionTokens: input.completionTokens ?? 0,
durationMs: input.durationMs,
errorMessage: '',
completedAt: new Date(),
},
})
return tx.bomCostingCase.update({
where: { id: input.caseId },
data: {
@ -828,15 +1171,42 @@ export class BomCostingRepository {
})
}
async failCase(caseId: string, message: string) {
async failCase(input: {
caseId: string
analysisRunId: string | null
message: string
}) {
return prisma.$transaction(async (tx) => {
const run = input.analysisRunId
? await tx.bomCostingAnalysisRun.findUnique({
where: { id: input.analysisRunId },
})
: null
if (run) {
await tx.bomCostingAnalysisRun.update({
where: { id: run.id },
data: {
status: BomCostingAnalysisRunStatus.FAILED,
errorMessage: input.message,
completedAt: new Date(),
},
})
}
const isRecalculation = Boolean(run && run.revision > 1)
const record = await tx.bomCostingCase.update({
where: { id: caseId },
data: {
status: BomCostingCaseStatus.FAILED,
statusMessage: message,
processingFinishedAt: new Date(),
},
where: { id: input.caseId },
data: isRecalculation
? {
status: BomCostingCaseStatus.READY,
progress: 100,
statusMessage: '重新核算未完成,当前仍显示上一次结果',
processingFinishedAt: new Date(),
}
: {
status: BomCostingCaseStatus.FAILED,
statusMessage: input.message,
processingFinishedAt: new Date(),
},
select: {
progress: true,
providerTaskId: true,
@ -844,12 +1214,21 @@ export class BomCostingRepository {
})
await tx.bomCostingTaskEvent.create({
data: {
caseId,
eventType: 'TASK_FAILED',
status: BomCostingCaseStatus.FAILED,
caseId: input.caseId,
eventType: isRecalculation ? 'RECALCULATION_FAILED' : 'TASK_FAILED',
status: isRecalculation
? BomCostingCaseStatus.READY
: BomCostingCaseStatus.FAILED,
progress: record.progress,
message,
message: input.message,
providerTaskId: record.providerTaskId,
payload: run
? {
runId: run.id,
revision: run.revision,
mode: run.mode,
}
: undefined,
},
})
})
@ -859,6 +1238,10 @@ export class BomCostingRepository {
return prisma.bomCostingLine.findUnique({
where: { id: lineId },
include: {
priceSnapshots: {
where: { isCurrent: true },
orderBy: { matchedAt: 'desc' },
},
sku: {
include: {
case: true,
@ -873,6 +1256,7 @@ export class BomCostingRepository {
skuId: string
data: Prisma.BomCostingLineUpdateInput
priceSnapshot: BomCostingPreparedPriceSnapshot | null
reviewInput: BomCostingReviewInputData | null
}) {
return prisma.$transaction(async (tx) => {
await tx.bomCostingPriceSnapshot.updateMany({
@ -898,6 +1282,34 @@ export class BomCostingRepository {
: undefined,
},
})
if (input.reviewInput) {
await tx.bomCostingReviewInput.create({
data: {
caseId: input.reviewInput.caseId,
skuId: input.reviewInput.skuId,
lineId: input.reviewInput.lineId,
skuExternalId: input.reviewInput.skuExternalId,
lineExternalId: input.reviewInput.lineExternalId,
skuName: input.reviewInput.skuName,
partName: input.reviewInput.partName,
createdById: input.reviewInput.createdById,
source: input.reviewInput.source,
lengthMm: input.reviewInput.lengthMm,
widthMm: input.reviewInput.widthMm,
heightMm: input.reviewInput.heightMm,
quantity: input.reviewInput.quantity,
materialCode: input.reviewInput.materialCode,
materialName: input.reviewInput.materialName,
materialSpec: input.reviewInput.materialSpec,
unitPrice: input.reviewInput.unitPrice,
priceUnit: input.reviewInput.priceUnit,
currency: input.reviewInput.currency ?? 'CNY',
lossFactor: input.reviewInput.lossFactor,
subtotal: input.reviewInput.subtotal,
note: input.reviewInput.note,
},
})
}
const lines = await tx.bomCostingLine.findMany({
where: { skuId: input.skuId },

View File

@ -184,9 +184,15 @@ function serializeSkuForFactExtraction(sku: MarketSkuSnapshot) {
function buildFactBatchUserText(
snapshot: MarketDetailSnapshot,
images: BomCostingMultimodalInputImage[],
analysisInstruction?: string,
) {
return JSON.stringify({
task: '逐张提取所有与家具结构、用料、尺寸、五金、机构、加工和包材有关的直接事实',
reviewInstruction: analysisInstruction?.trim() || undefined,
reviewInstructionRule:
analysisInstruction?.trim()
? '这是人工提出的复查重点。必须重点核对,但仍只能记录图片或商品原文能支持的事实;不得为了迎合要求而编造证据。'
: undefined,
requiredOutputShape: {
images: [{
imageId: '必须来自本批输入',
@ -302,6 +308,7 @@ export async function extractBomCostingImageFacts(input: {
config: BomCostingMultimodalProviderConfig
snapshot: MarketDetailSnapshot
existingSnapshot?: BomCostingSourceFactSnapshot | null
analysisInstruction?: string
onBatchStarted?: (progress: Omit<BomCostingFactBatchProgress, 'snapshot' | 'call'>) => Promise<void>
onBatchCompleted?: (progress: BomCostingFactBatchProgress) => Promise<void>
}) {
@ -387,7 +394,11 @@ export async function extractBomCostingImageFacts(input: {
| { type: 'image_url'; image_url: { url: string } }
> = [{
type: 'text',
text: buildFactBatchUserText(input.snapshot, batchImages),
text: buildFactBatchUserText(
input.snapshot,
batchImages,
input.analysisInstruction,
),
}]
for (const image of batchImages) {
userContent.push({

View File

@ -128,9 +128,15 @@ function serializeSkuForPrompt(sku: MarketSkuSnapshot) {
function buildUserText(
snapshot: MarketDetailSnapshot,
factSnapshot: BomCostingSourceFactSnapshot,
analysisInstruction?: string,
) {
return JSON.stringify({
task: '合并全部逐图事实与 SKU 真相,生成结构组级家具 BOM,并把每个 SKU 绑定到对应结构组',
reviewInstruction: analysisInstruction?.trim() || undefined,
reviewInstructionRule:
analysisInstruction?.trim()
? '这是人工补充的核算重点。应据此重新检查已保存事实与结构推断,但不得覆盖 SKU 真相,也不得虚构图片事实。'
: undefined,
requiredOutputShape: {
product: {
brand: 'string optional',
@ -301,6 +307,7 @@ export interface BomCostingMultimodalAnalyzeInput {
sourceUrl: string
snapshot: MarketDetailSnapshot
existingFactSnapshot?: BomCostingSourceFactSnapshot | null
analysisInstruction?: string
onFactBatchStarted?: (
progress: Omit<BomCostingFactBatchProgress, 'snapshot' | 'call'>,
) => Promise<void>
@ -335,6 +342,7 @@ export class BomCostingMultimodalAnalyzer {
config,
snapshot: input.snapshot,
existingSnapshot: input.existingFactSnapshot,
analysisInstruction: input.analysisInstruction,
onBatchStarted: input.onFactBatchStarted,
onBatchCompleted: input.onFactBatchCompleted,
})
@ -355,7 +363,11 @@ export class BomCostingMultimodalAnalyzer {
const responsePayload = await callBomCostingMultimodalModel({
config,
systemPrompt: SYSTEM_PROMPT,
userContent: buildUserText(input.snapshot, factOutput.snapshot),
userContent: buildUserText(
input.snapshot,
factOutput.snapshot,
input.analysisInstruction,
),
maxTokens,
timeoutMs,
})

View File

@ -1,6 +1,8 @@
/** @jest-environment node */
import {
BomCostingAnalysisRunMode,
BomCostingAnalysisRunStatus,
BomCostingCaseStatus,
BomCostingSourceAssetKind,
BomCostingSourceAssetStatus,
@ -197,6 +199,10 @@ function factAnalysisRecord(
id: 'fact-analysis-1',
sourceSnapshotId: 'source-1',
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
revision: 1,
origin: 'INITIAL',
baseAnalysisId: null,
selectedAssetIds: [],
assetFingerprint: 'asset-fingerprint',
status,
model: 'grok-4.5',
@ -246,6 +252,7 @@ function caseRecord() {
updatedAt: new Date(),
skus: [],
taskEvents: [],
analysisRuns: [],
}
}
@ -253,6 +260,17 @@ describe('BomCostingProcessingService', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.spyOn(bomCostingRepository, 'claimCaseForProcessing').mockResolvedValue(true)
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: 'analysis-run-1',
caseId: 'case-1',
revision: 1,
mode: BomCostingAnalysisRunMode.INITIAL,
status: BomCostingAnalysisRunStatus.PROCESSING,
factAnalysisId: null,
selectedAssetIds: [],
instruction: '',
} as never)
jest.spyOn(bomCostingRepository, 'findCaseForProcessing')
.mockResolvedValue(caseRecord() as never)
jest.spyOn(bomCostingRepository, 'updateCaseStage').mockResolvedValue({} as never)
@ -260,6 +278,8 @@ describe('BomCostingProcessingService', () => {
.mockResolvedValue(factAnalysisRecord() as never)
jest.spyOn(bomCostingRepository, 'findSourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord() as never)
jest.spyOn(bomCostingRepository, 'linkAnalysisRunFact')
.mockResolvedValue({} as never)
jest.spyOn(bomCostingRepository, 'claimSourceFactAnalysis')
.mockResolvedValue(true)
jest.spyOn(bomCostingRepository, 'saveSourceFactAnalysisBatch')
@ -425,8 +445,11 @@ describe('BomCostingProcessingService', () => {
await new BomCostingProcessingService().processCase('case-1')
expect(bomCostingRepository.failCase).toHaveBeenCalledWith(
'case-1',
'多模态分析服务处理超时(524)',
{
caseId: 'case-1',
analysisRunId: 'analysis-run-1',
message: '多模态分析服务处理超时(524)',
},
)
expect(bomCostingRepository.failSourceSnapshot).not.toHaveBeenCalled()
expect(bomCostingRepository.failSourceFactAnalysis).toHaveBeenCalledWith(
@ -471,8 +494,11 @@ describe('BomCostingProcessingService', () => {
)
expect(bomCostingRepository.failSourceFactAnalysis).not.toHaveBeenCalled()
expect(bomCostingRepository.failCase).toHaveBeenCalledWith(
'case-1',
'多模态分析服务处理超时(524)',
{
caseId: 'case-1',
analysisRunId: 'analysis-run-1',
message: '多模态分析服务处理超时(524)',
},
)
})
@ -516,4 +542,108 @@ describe('BomCostingProcessingService', () => {
)
expect(bomCostingRepository.saveSourceFactAnalysisBatch).not.toHaveBeenCalled()
})
it('指定图片复查只从基础事实中移除所选图片,并把人工重点传给模型', async () => {
jest.spyOn(bomCostingRepository, 'findProcessingAnalysisRun')
.mockResolvedValue({
id: 'analysis-run-2',
caseId: 'case-1',
revision: 2,
mode: BomCostingAnalysisRunMode.REANALYZE_SELECTED,
status: BomCostingAnalysisRunStatus.PROCESSING,
factAnalysisId: null,
selectedAssetIds: ['asset-sku'],
instruction: '重点核对折叠导轨',
} as never)
jest.spyOn(bomCostingRepository, 'findLatestReadySourceFactAnalysis')
.mockResolvedValue(factAnalysisRecord(
BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
) as never)
const partialFactSnapshot: BomCostingSourceFactSnapshot = {
...factSnapshot,
imageFacts: [factSnapshot.imageFacts[0]],
}
jest.spyOn(bomCostingRepository, 'createSourceFactAnalysisRevision')
.mockResolvedValue({
...factAnalysisRecord(),
id: 'fact-analysis-2',
revision: 2,
origin: 'SELECTED_REANALYSIS',
baseAnalysisId: 'fact-analysis-1',
selectedAssetIds: ['asset-sku'],
factSnapshot: partialFactSnapshot,
analyzedImageCount: 1,
} as never)
mockRunAnalysis.mockImplementation(async (input) => {
const analysisInput = input as {
existingFactSnapshot?: BomCostingSourceFactSnapshot | null
analysisInstruction?: string
onFactExtractionCompleted?: (result: unknown) => Promise<void>
}
expect(analysisInput.existingFactSnapshot?.imageFacts).toHaveLength(1)
expect(
analysisInput.existingFactSnapshot?.imageFacts[0]?.kind,
).toBe('MAIN')
expect(analysisInput.analysisInstruction).toBe('重点核对折叠导轨')
await analysisInput.onFactExtractionCompleted?.({
snapshot: factSnapshot,
trace: {
model: 'grok-4.5',
callIds: ['fact-call-selected'],
batchCount: 1,
reusedImageCount: 1,
analyzedImageCount: 1,
durationMs: 10_000,
promptTokens: 40,
completionTokens: 60,
},
})
return {
result: analysisResult,
trace: {
model: 'grok-4.5',
callId: 'synthesis-call-selected',
imageCount: 2,
inputSkuCount: 1,
factBatchCount: 1,
reusedImageCount: 1,
analyzedImageCount: 1,
durationMs: 20_000,
promptTokens: 100,
completionTokens: 150,
},
}
})
const { BomCostingProcessingService } = await import(
'./bom-costing-processing.service'
)
await new BomCostingProcessingService().processCase('case-1')
expect(
bomCostingRepository.createSourceFactAnalysisRevision,
).toHaveBeenCalledWith(
expect.objectContaining({
origin: 'SELECTED_REANALYSIS',
baseAnalysisId: 'fact-analysis-1',
selectedAssetIds: ['asset-sku'],
analyzedImageCount: 1,
factSnapshot: expect.objectContaining({
imageFacts: [expect.objectContaining({ kind: 'MAIN' })],
}),
}),
)
expect(bomCostingRepository.completeSourceFactAnalysis).toHaveBeenCalledWith(
expect.objectContaining({
id: 'fact-analysis-2',
analyzedImageCount: 2,
}),
)
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
expect.objectContaining({
analysisRunId: 'analysis-run-2',
factAnalysisId: 'fact-analysis-2',
}),
)
})
})

View File

@ -2,7 +2,9 @@ import 'server-only'
import { createHash } from 'node:crypto'
import {
BomCostingAnalysisRunMode,
BomCostingCaseStatus,
BomCostingFactAnalysisOrigin,
BomCostingMatchStatus,
BomCostingSourceAssetStatus,
BomCostingSourceStatus,
@ -103,6 +105,12 @@ function parseSourceFactSnapshot(value: Prisma.JsonValue | null) {
return bomCostingSourceFactSnapshotSchema.parse(value)
}
function readStringArray(value: Prisma.JsonValue) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: []
}
function buildResultStats(
skus: ReturnType<typeof prepareBomCostingAnalysis>,
) {
@ -215,6 +223,17 @@ export class BomCostingProcessingService {
const claimed = await bomCostingRepository.claimCaseForProcessing(caseId)
if (!claimed) return
const analysisRun =
await bomCostingRepository.findProcessingAnalysisRun(caseId)
if (!analysisRun) {
await bomCostingRepository.failCase({
caseId,
analysisRunId: null,
message: 'BOM 核算运行版本不存在',
})
return
}
const analysisRunId = analysisRun.id
let sourceOwnedByTask = false
let sourceSnapshotId: string | null = null
let factAnalysisOwnedByTask = false
@ -381,13 +400,113 @@ export class BomCostingProcessingService {
marketSnapshot &&
analysisInputStats.imageCount > 0
) {
const factAnalysis = await bomCostingRepository.prepareSourceFactAnalysis({
sourceSnapshotId: archivedSource.id,
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint: buildSourceAssetFingerprint(archivedSource),
model: modelName ?? '',
totalImageCount: analysisInputStats.imageCount,
})
const assetFingerprint = buildSourceAssetFingerprint(archivedSource)
let factAnalysis = analysisRun.factAnalysisId
? await bomCostingRepository.findSourceFactAnalysis(
analysisRun.factAnalysisId,
)
: null
if (!factAnalysis) {
if (analysisRun.mode === BomCostingAnalysisRunMode.INITIAL) {
factAnalysis =
await bomCostingRepository.prepareSourceFactAnalysis({
sourceSnapshotId: archivedSource.id,
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint,
model: modelName ?? '',
totalImageCount: analysisInputStats.imageCount,
})
} else {
const baseFactAnalysis =
await bomCostingRepository.findLatestReadySourceFactAnalysis({
sourceSnapshotId: archivedSource.id,
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint,
})
if (
analysisRun.mode !==
BomCostingAnalysisRunMode.REANALYZE_ALL &&
!baseFactAnalysis
) {
throw new Error(
'当前竞品没有可复用的完整图片事实,请选择全部重新分析',
)
}
if (analysisRun.mode === BomCostingAnalysisRunMode.REUSE_FACTS) {
factAnalysis = baseFactAnalysis
} else {
const baseSnapshot = baseFactAnalysis
? parseSourceFactSnapshot(baseFactAnalysis.factSnapshot)
: null
const selectedAssetIds = readStringArray(
analysisRun.selectedAssetIds,
)
const selectedIdentities = new Set(
analysisInputStats.images.flatMap((image) => {
const planned = analysisPlanByIdentity.get(
buildBomCostingImageIdentity(image.url),
)
return planned?.assetId &&
selectedAssetIds.includes(planned.assetId)
? [buildBomCostingImageIdentity(image.url)]
: []
}),
)
if (
analysisRun.mode ===
BomCostingAnalysisRunMode.REANALYZE_SELECTED &&
selectedIdentities.size !== selectedAssetIds.length
) {
throw new Error(
'指定复查的图片已发生变化,请刷新页面后重新选择',
)
}
const initialSnapshot =
analysisRun.mode ===
BomCostingAnalysisRunMode.REANALYZE_SELECTED &&
baseSnapshot
? bomCostingSourceFactSnapshotSchema.parse({
...baseSnapshot,
imageFacts: baseSnapshot.imageFacts.filter(
(image) =>
!selectedIdentities.has(
buildBomCostingImageIdentity(
image.sourceImageUrl,
),
),
),
})
: null
factAnalysis =
await bomCostingRepository.createSourceFactAnalysisRevision({
sourceSnapshotId: archivedSource.id,
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
assetFingerprint,
origin:
analysisRun.mode ===
BomCostingAnalysisRunMode.REANALYZE_SELECTED
? BomCostingFactAnalysisOrigin.SELECTED_REANALYSIS
: BomCostingFactAnalysisOrigin.FULL_REANALYSIS,
baseAnalysisId: baseFactAnalysis?.id ?? null,
selectedAssetIds,
model: modelName ?? '',
factSnapshot: initialSnapshot
? initialSnapshot as unknown as Prisma.InputJsonValue
: Prisma.JsonNull,
analyzedImageCount:
initialSnapshot?.imageFacts.length ?? 0,
totalImageCount: analysisInputStats.imageCount,
})
}
}
if (!factAnalysis) {
throw new Error('竞品逐图事实记录不存在')
}
await bomCostingRepository.linkAnalysisRunFact(
analysisRunId,
factAnalysis.id,
)
}
factAnalysisId = factAnalysis.id
if (factAnalysis.status === BOM_COSTING_FACT_ANALYSIS_STATUS.READY) {
existingFactSnapshot = parseSourceFactSnapshot(
@ -404,6 +523,8 @@ export class BomCostingProcessingService {
totalImageCount: factAnalysis.totalImageCount,
batchCount: factAnalysis.batchCount,
model: factAnalysis.model,
runId: analysisRunId,
mode: analysisRun.mode,
},
})
} else {
@ -423,6 +544,8 @@ export class BomCostingProcessingService {
message: '同一竞品正在提取逐图事实,等待复用结果',
payload: {
factAnalysisId: factAnalysis.id,
runId: analysisRunId,
mode: analysisRun.mode,
},
})
const readyFactAnalysis =
@ -441,6 +564,8 @@ export class BomCostingProcessingService {
totalImageCount: readyFactAnalysis.totalImageCount,
batchCount: readyFactAnalysis.batchCount,
model: readyFactAnalysis.model,
runId: analysisRunId,
mode: analysisRun.mode,
},
})
}
@ -450,10 +575,21 @@ export class BomCostingProcessingService {
eventType: 'MODEL_ANALYSIS_STARTED',
status: BomCostingCaseStatus.ANALYZING,
progress: existingFactSnapshot ? 76 : 52,
message: existingFactSnapshot
? '逐图事实已就绪,准备合并产品结构与成本信息'
: `准备逐批分析全部 ${analysisInputStats.imageCount} 张竞品图片`,
message:
analysisRun.mode === BomCostingAnalysisRunMode.REUSE_FACTS
? '已复用逐图事实,正在重新汇总产品结构与成本信息'
: existingFactSnapshot
? '逐图事实已就绪,准备合并产品结构与成本信息'
: analysisRun.mode ===
BomCostingAnalysisRunMode.REANALYZE_SELECTED
? '正在复查指定图片并更新事实'
: `准备逐批分析全部 ${analysisInputStats.imageCount} 张竞品图片`,
payload: {
runId: analysisRunId,
revision: analysisRun.revision,
mode: analysisRun.mode,
selectedAssetIds: readStringArray(analysisRun.selectedAssetIds),
instruction: analysisRun.instruction,
model: modelName,
imageCount: analysisInputStats.imageCount,
archivedImageCount: archivedSource?.storedImageCount ?? 0,
@ -477,6 +613,7 @@ export class BomCostingProcessingService {
sourceUrl: record.sourceUrl,
snapshot: marketSnapshot,
existingFactSnapshot,
analysisInstruction: analysisRun.instruction,
onFactBatchStarted:
factAnalysisOwnedByTask && factAnalysisId
? async (progress) => {
@ -625,6 +762,8 @@ export class BomCostingProcessingService {
})
await bomCostingRepository.completeCase({
caseId,
analysisRunId,
factAnalysisId,
title: persistedResult.product.title,
analysis: persistedResult as unknown as Prisma.InputJsonValue,
skus: preparedSkus,
@ -634,6 +773,11 @@ export class BomCostingProcessingService {
callId: output.trace.callId,
durationMs: output.trace.durationMs,
},
model: output.trace.model,
callId: output.trace.callId,
promptTokens: output.trace.promptTokens,
completionTokens: output.trace.completionTokens,
durationMs: output.trace.durationMs,
})
} catch (error) {
const message = readErrorMessage(error)
@ -647,13 +791,21 @@ export class BomCostingProcessingService {
.failSourceSnapshot(sourceSnapshotId, message)
.catch(() => undefined)
}
await bomCostingRepository.failCase(caseId, message).catch((persistError) => {
console.error('[BOM costing] failed to persist task failure', {
await bomCostingRepository
.failCase({
caseId,
error:
persistError instanceof Error ? persistError.message : String(persistError),
analysisRunId,
message,
})
.catch((persistError) => {
console.error('[BOM costing] failed to persist task failure', {
caseId,
error:
persistError instanceof Error
? persistError.message
: String(persistError),
})
})
})
}
}
}

View File

@ -35,6 +35,7 @@ export interface CompetitorAnalysisProvider {
sourceUrl: string
snapshot: MarketDetailSnapshot | null
existingFactSnapshot?: BomCostingSourceFactSnapshot | null
analysisInstruction?: string
onFactBatchStarted?: (
progress: Omit<BomCostingFactBatchProgress, 'snapshot' | 'call'>,
) => Promise<void>
@ -312,6 +313,7 @@ class MarketDetailMultimodalProvider implements CompetitorAnalysisProvider {
sourceUrl: string
snapshot: MarketDetailSnapshot | null
existingFactSnapshot?: BomCostingSourceFactSnapshot | null
analysisInstruction?: string
onFactBatchStarted?: (
progress: Omit<BomCostingFactBatchProgress, 'snapshot' | 'call'>,
) => Promise<void>

View File

@ -106,6 +106,115 @@ describe('BomCostingService', () => {
})
})
it('指定图片复查只接受当前归档图片,并创建独立重新核算版本', async () => {
process.env.BOM_COSTING_ANALYSIS_PROVIDER = 'fixture'
const readyCase = {
id: 'case-ready',
status: 'READY',
sourceSnapshot: {
id: 'source-ready',
status: 'READY',
storedImageCount: 2,
assets: [
{
id: 'asset-main',
status: 'STORED',
storedUrl: '/api/storage/object?key=main',
},
{
id: 'asset-detail',
status: 'STORED',
storedUrl: '/api/storage/object?key=detail',
},
],
},
analysisRuns: [
{
id: 'run-1',
revision: 1,
status: 'READY',
factAnalysisId: 'fact-1',
},
],
}
const queuedCase = {
...readyCase,
status: 'QUEUED',
}
jest
.spyOn(bomCostingService, 'getCase')
.mockResolvedValueOnce(readyCase as never)
.mockResolvedValueOnce(queuedCase as never)
const createRunSpy = jest
.spyOn(bomCostingRepository, 'createRecalculationRun')
.mockResolvedValue(true)
const result = await bomCostingService.recalculateCase(
'case-ready',
{
mode: 'REANALYZE_SELECTED',
selectedAssetIds: ['asset-detail'],
instruction: '重点核对折叠导轨',
},
operationsUser,
)
expect(createRunSpy).toHaveBeenCalledWith({
caseId: 'case-ready',
mode: 'REANALYZE_SELECTED',
selectedAssetIds: ['asset-detail'],
instruction: '重点核对折叠导轨',
createdById: operationsUser.id,
statusMessage: '等待复查 1 张图片并重新核算',
})
expect(result).toMatchObject({
case: queuedCase,
shouldSchedule: true,
started: true,
})
})
it('指定图片不属于当前归档资料时拒绝发起,避免分析错图', async () => {
process.env.BOM_COSTING_ANALYSIS_PROVIDER = 'fixture'
jest.spyOn(bomCostingService, 'getCase').mockResolvedValue({
id: 'case-ready',
status: 'READY',
sourceSnapshot: {
id: 'source-ready',
status: 'READY',
storedImageCount: 1,
assets: [{
id: 'asset-main',
status: 'STORED',
storedUrl: '/api/storage/object?key=main',
}],
},
analysisRuns: [{
id: 'run-1',
revision: 1,
status: 'READY',
factAnalysisId: 'fact-1',
}],
} as never)
const createRunSpy = jest.spyOn(
bomCostingRepository,
'createRecalculationRun',
)
await expect(
bomCostingService.recalculateCase(
'case-ready',
{
mode: 'REANALYZE_SELECTED',
selectedAssetIds: ['asset-other'],
instruction: '',
},
operationsUser,
),
).rejects.toThrow('选择的复查图片不属于当前竞品资料')
expect(createRunSpy).not.toHaveBeenCalled()
})
it('板材缺厚度时返回同材种候选并要求用户确认', async () => {
jest.spyOn(bomCostingRepository, 'findLineForUpdate').mockResolvedValue({
id: 'line-board',
@ -180,6 +289,124 @@ describe('BomCostingService', () => {
])
})
it('人工复核会按当前尺寸、材质和价格计算并写入不可覆盖的留档', async () => {
const updatedCase = { id: 'case-1', status: 'READY' }
jest.spyOn(bomCostingRepository, 'findLineForUpdate').mockResolvedValue({
id: 'line-board',
skuId: 'sku-1',
externalLineId: 'line-board',
category: 'BOARD',
materialCode: '',
materialName: '待确认板材',
materialSpec: '',
partName: '桌面板',
packagingKind: '',
lengthMm: new Prisma.Decimal(800),
widthMm: new Prisma.Decimal(500),
heightMm: null,
quantity: new Prisma.Decimal(1),
unit: '块',
lossFactor: new Prisma.Decimal(1),
resolvedUnitPrice: null,
matchedLibraryType: '',
matchedLibraryRecordId: '',
matchedLibraryName: '',
matchedLibrarySpec: '',
sku: {
externalSkuId: 'sku-1',
name: '测试 SKU',
case: {
id: 'case-1',
createdById: operationsUser.id,
deletedAt: null,
},
},
} as never)
jest.spyOn(bomCostingRepository, 'listPriceCandidates').mockResolvedValue({
BOARD: [],
STEEL: [],
ACCESSORY: [],
PACKAGING: [],
PROCESSING: [],
LABOR: [],
})
const updateLineSpy = jest
.spyOn(bomCostingRepository, 'updateLine')
.mockResolvedValue([
{
category: 'BOARD',
matchedLibraryType: 'MANUAL_REVIEW',
subtotal: new Prisma.Decimal(62.21),
},
] as never)
jest
.spyOn(bomCostingRepository, 'updateSkuEstimatedCost')
.mockResolvedValue({} as never)
jest.spyOn(bomCostingService, 'getCase').mockResolvedValue(updatedCase as never)
await bomCostingService.updateLine(
'case-1',
'line-board',
{
review: {
materialName: '橡胶木实木板',
materialSpec: '18mm',
lengthMm: 1200,
widthMm: 600,
heightMm: 18,
quantity: 1,
unitPrice: 80,
priceUnit: '㎡',
lossFactor: 1.08,
note: '详情图尺寸标注复核',
},
},
operationsUser,
)
expect(updateLineSpy).toHaveBeenCalledWith(
expect.objectContaining({
lineId: 'line-board',
skuId: 'sku-1',
data: expect.objectContaining({
heightMm: 18,
matchedLibraryType: 'MANUAL_REVIEW',
resolvedUnitPrice: 80,
calculatedQuantity: 0.72,
subtotal: 62.21,
}),
priceSnapshot: expect.objectContaining({
sourceType: 'MANUAL_REVIEW',
sourceRecordId: 'line-board',
unitPrice: 80,
unit: '㎡',
lossFactor: 1.08,
}),
reviewInput: expect.objectContaining({
caseId: 'case-1',
skuId: 'sku-1',
lineId: 'line-board',
skuExternalId: 'sku-1',
lineExternalId: 'line-board',
skuName: '测试 SKU',
partName: '桌面板',
source: 'MANUAL',
materialName: '橡胶木实木板',
materialSpec: '18mm',
lengthMm: 1200,
widthMm: 600,
heightMm: 18,
unitPrice: 80,
priceUnit: '㎡',
lossFactor: 1.08,
subtotal: 62.21,
note: '详情图尺寸标注复核',
createdById: operationsUser.id,
}),
}),
)
})
it('历史包材价格不是资产中心规格款时不再展示完整成本', async () => {
jest.spyOn(bomCostingRepository, 'listCases').mockResolvedValue([
{

View File

@ -1,15 +1,18 @@
import 'server-only'
import {
BomCostingAnalysisRunMode,
BomCostingCaseStatus,
BomCostingLineCategory,
BomCostingMatchStatus,
BomCostingReviewInputSource,
BomCostingSourceStatus,
Prisma,
} from '@prisma/client'
import { nanoid } from 'nanoid'
import {
BOM_COSTING_TARGET_ERROR_PERCENT,
type RecalculateBomCostingCaseInput,
type UpdateBomCostingLineInput,
} from '@/lib/bom-costing/contracts'
import {
@ -29,6 +32,7 @@ import {
bomCostingRepository,
type BomCostingCaseDetailRecord,
type BomCostingPreparedPriceSnapshot,
type BomCostingReviewInputData,
} from '@/server/repositories/bom-costing.repository'
import { bomCostingAccessService } from './bom-costing-access.service'
import {
@ -48,6 +52,7 @@ const CATEGORY_LABELS: Record<BomCostingLineCategory, string> = {
}
const CURRENT_PACKAGING_LIBRARY_TYPE = 'ASSET_PACKAGING_VARIANT'
const MANUAL_REVIEW_PRICE_SOURCE = 'MANUAL_REVIEW'
function decimalToNumber(value: Prisma.Decimal | null | undefined) {
return value === null || value === undefined ? null : value.toNumber()
@ -59,10 +64,20 @@ function isLegacyPackagingPriceSource(input: {
}) {
return (
input.category === BomCostingLineCategory.PACKAGING &&
input.matchedLibraryType !== CURRENT_PACKAGING_LIBRARY_TYPE
input.matchedLibraryType !== CURRENT_PACKAGING_LIBRARY_TYPE &&
input.matchedLibraryType !== MANUAL_REVIEW_PRICE_SOURCE
)
}
function resolveManualPricingMode(priceUnit: string) {
const normalized = priceUnit.trim().toLowerCase()
if (['㎡', 'm²', 'm2', '平方米'].includes(normalized)) return 'AREA_SQM' as const
if (['米', 'm', '米长', '延米'].includes(normalized)) return 'LENGTH_M' as const
if (['m³', 'm3', '立方米', '方'].includes(normalized)) return 'VOLUME_CBM' as const
if (['件', '个', '套', '只', '根'].includes(normalized)) return 'UNIT' as const
return undefined
}
function normalizePackagingKind(value?: string) {
const normalized = value?.trim().toUpperCase() ?? ''
const supported = new Set<BomCostingPackagingKind>([
@ -238,6 +253,52 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
costSummary,
}
}),
reviewInputs: record.reviewInputs.map((input) => ({
id: input.id,
skuId: input.skuId,
lineId: input.lineId,
skuName: input.sku?.name ?? input.skuName,
partName: input.line?.partName ?? input.partName,
source: input.source,
materialCode: input.materialCode,
materialName: input.materialName,
materialSpec: input.materialSpec,
lengthMm: decimalToNumber(input.lengthMm),
widthMm: decimalToNumber(input.widthMm),
heightMm: decimalToNumber(input.heightMm),
quantity: decimalToNumber(input.quantity),
unitPrice: decimalToNumber(input.unitPrice),
priceUnit: input.priceUnit,
currency: input.currency,
lossFactor: decimalToNumber(input.lossFactor),
subtotal: decimalToNumber(input.subtotal),
note: input.note,
createdById: input.createdById,
createdByName: input.creator?.name ?? '已离职用户',
createdAt: input.createdAt.toISOString(),
})),
analysisRuns: record.analysisRuns.map((run) => ({
id: run.id,
revision: run.revision,
mode: run.mode,
status: run.status,
factAnalysisId: run.factAnalysisId,
selectedAssetIds: Array.isArray(run.selectedAssetIds)
? run.selectedAssetIds.filter(
(value): value is string => typeof value === 'string',
)
: [],
instruction: run.instruction,
model: run.model,
callId: run.callId,
promptTokens: run.promptTokens,
completionTokens: run.completionTokens,
durationMs: run.durationMs,
errorMessage: run.errorMessage,
startedAt: run.startedAt?.toISOString() ?? null,
completedAt: run.completedAt?.toISOString() ?? null,
createdAt: run.createdAt.toISOString(),
})),
taskEvents: [...record.taskEvents]
.reverse()
.map((event) => ({
@ -454,6 +515,169 @@ export class BomCostingService {
}
}
async recalculateCase(
caseId: string,
input: RecalculateBomCostingCaseInput,
user: AuthUser,
) {
const current = await this.getCase(caseId, user)
if (
current.status === BomCostingCaseStatus.QUEUED ||
current.status === BomCostingCaseStatus.ANALYZING
) {
return {
case: current,
shouldSchedule: false,
started: false,
}
}
if (current.status !== BomCostingCaseStatus.READY) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'当前核算尚未完成,不能重新核算',
)
}
const capabilities = await getBomCostingCapabilities()
if (!capabilities.analysisConfigured) {
throw new AppError(
ErrorCode.BOM_COSTING_ANALYSIS_NOT_CONFIGURED,
'多模态分析服务尚未配置或未启用',
)
}
const source = current.sourceSnapshot
if (
!source ||
source.status !== BomCostingSourceStatus.READY ||
source.storedImageCount < 1
) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'已归档的竞品资料不可用,不能重新核算',
)
}
const reusableFactRun = current.analysisRuns.find(
(run) => run.status === 'READY' && run.factAnalysisId,
)
if (
input.mode !== 'REANALYZE_ALL' &&
!reusableFactRun
) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'当前案例没有完整的图片事实,请选择“全部重新分析”',
)
}
const storedAssetIds = new Set(
source.assets
.filter((asset) => asset.status === 'STORED' && asset.storedUrl)
.map((asset) => asset.id),
)
const selectedAssetIds = Array.from(new Set(input.selectedAssetIds))
if (
input.mode === 'REANALYZE_SELECTED' &&
(
selectedAssetIds.length === 0 ||
selectedAssetIds.some((assetId) => !storedAssetIds.has(assetId))
)
) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'选择的复查图片不属于当前竞品资料,请刷新后重试',
)
}
const mode = input.mode as Exclude<
BomCostingAnalysisRunMode,
typeof BomCostingAnalysisRunMode.INITIAL
>
const statusMessage =
mode === BomCostingAnalysisRunMode.REUSE_FACTS
? '已复用图片事实,等待重新汇总结构与价格'
: mode === BomCostingAnalysisRunMode.REANALYZE_SELECTED
? `等待复查 ${selectedAssetIds.length} 张图片并重新核算`
: `等待重新分析全部 ${source.storedImageCount} 张图片`
const started = await bomCostingRepository.createRecalculationRun({
caseId,
mode,
selectedAssetIds:
mode === BomCostingAnalysisRunMode.REANALYZE_SELECTED
? selectedAssetIds
: [],
instruction: input.instruction,
createdById: user.id,
statusMessage,
})
const latest = await this.getCase(caseId, user)
if (
!started &&
latest.status !== BomCostingCaseStatus.QUEUED &&
latest.status !== BomCostingCaseStatus.ANALYZING
) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'核算状态已经变化,请刷新后再试',
)
}
return {
case: latest,
shouldSchedule: started,
started,
}
}
async resumeRecalculation(caseId: string, user: AuthUser) {
const current = await this.getCase(caseId, user)
if (
current.status === BomCostingCaseStatus.QUEUED ||
current.status === BomCostingCaseStatus.ANALYZING
) {
return {
case: current,
shouldSchedule: false,
resumed: false,
}
}
if (current.status !== BomCostingCaseStatus.READY) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'当前案例不能继续上次重新核算',
)
}
const failedRun = current.analysisRuns.find(
(run) => run.revision > 1 && run.status === 'FAILED',
)
if (!failedRun) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'没有可继续的重新核算任务',
)
}
const resumed = await bomCostingRepository.resumeFailedRecalculation({
caseId,
statusMessage: `继续第 ${failedRun.revision} 次核算,已完成的图片不会重复分析`,
})
const latest = await this.getCase(caseId, user)
if (
!resumed &&
latest.status !== BomCostingCaseStatus.QUEUED &&
latest.status !== BomCostingCaseStatus.ANALYZING
) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'核算状态已经变化,请刷新后再试',
)
}
return {
case: latest,
shouldSchedule: resumed,
resumed,
}
}
async updateLine(
caseId: string,
lineId: string,
@ -474,9 +698,10 @@ export class BomCostingService {
const candidates = await bomCostingRepository.listPriceCandidates()
const categoryCandidates = candidates[line.category]
const manualReview = input.review
const requestedRecordId = input.matchedLibraryRecordId
let candidate: BomCostingLibraryCandidate | null = null
if (requestedRecordId === undefined && line.matchedLibraryRecordId) {
if (!manualReview && requestedRecordId === undefined && line.matchedLibraryRecordId) {
candidate =
categoryCandidates.find(
(item) => item.recordId === line.matchedLibraryRecordId,
@ -493,7 +718,7 @@ export class BomCostingService {
pricingMode: 'UNIT',
}
: null)
} else if (requestedRecordId) {
} else if (!manualReview && requestedRecordId) {
candidate =
categoryCandidates.find((item) => item.recordId === requestedRecordId) ?? null
if (!candidate) {
@ -504,29 +729,99 @@ export class BomCostingService {
}
}
const lengthMm = input.lengthMm === undefined
? decimalToNumber(line.lengthMm)
: input.lengthMm
const widthMm = input.widthMm === undefined
? decimalToNumber(line.widthMm)
: input.widthMm
const heightMm = input.heightMm === undefined
? decimalToNumber(line.heightMm)
: input.heightMm
const quantity = input.quantity ?? line.quantity.toNumber()
const lossFactor = input.lossFactor ?? line.lossFactor.toNumber()
const lengthMm = manualReview
? manualReview.lengthMm === undefined
? decimalToNumber(line.lengthMm)
: manualReview.lengthMm
: input.lengthMm === undefined
? decimalToNumber(line.lengthMm)
: input.lengthMm
const widthMm = manualReview
? manualReview.widthMm === undefined
? decimalToNumber(line.widthMm)
: manualReview.widthMm
: input.widthMm === undefined
? decimalToNumber(line.widthMm)
: input.widthMm
const heightMm = manualReview
? manualReview.heightMm === undefined
? decimalToNumber(line.heightMm)
: manualReview.heightMm
: input.heightMm === undefined
? decimalToNumber(line.heightMm)
: input.heightMm
const quantity = manualReview
? manualReview.quantity ?? line.quantity.toNumber()
: input.quantity ?? line.quantity.toNumber()
const lossFactor = manualReview
? manualReview.lossFactor ?? line.lossFactor.toNumber()
: input.lossFactor ?? line.lossFactor.toNumber()
const manualPrice = manualReview
? manualReview.unitPrice === undefined
? decimalToNumber(line.resolvedUnitPrice)
: manualReview.unitPrice
: null
const manualPriceUnit = manualReview
? manualReview.priceUnit ?? line.unit
: ''
const manualMaterialCode = manualReview?.materialCode ?? line.materialCode
const manualMaterialName = manualReview?.materialName ?? line.materialName
const manualMaterialSpec = manualReview?.materialSpec ?? line.materialSpec
const pricingMode = manualReview
? resolveManualPricingMode(manualPriceUnit)
: candidate?.pricingMode
const calculation = calculateBomCostingLine({
category: line.category,
packagingKind: normalizePackagingKind(line.packagingKind),
pricingMode: candidate?.pricingMode,
pricingMode,
lengthMm,
widthMm,
heightMm,
quantity,
unitPrice: candidate?.unitPrice,
unitPrice: manualReview ? manualPrice : candidate?.unitPrice,
lossFactor,
})
const manualHasPrice = manualReview && manualPrice !== null
const reviewSource = manualReview
? BomCostingReviewInputSource.MANUAL
: BomCostingReviewInputSource.LIBRARY_MATCH
const reviewInput: BomCostingReviewInputData | null =
manualReview || candidate
? {
caseId: line.sku.case.id,
skuId: line.skuId,
lineId: line.id,
skuExternalId: line.sku.externalSkuId,
lineExternalId: line.externalLineId,
skuName: line.sku.name,
partName: line.partName,
createdById: user.id,
source: reviewSource,
lengthMm,
widthMm,
heightMm,
quantity,
materialCode: manualReview
? manualMaterialCode
: candidate?.code ?? line.materialCode,
materialName: manualReview
? manualMaterialName
: candidate?.name ?? line.materialName,
materialSpec: manualReview
? manualMaterialSpec
: candidate?.spec ?? line.materialSpec,
unitPrice: manualReview ? manualPrice : candidate?.unitPrice ?? null,
priceUnit: manualReview ? manualPriceUnit : candidate?.unit ?? line.unit,
lossFactor,
subtotal:
calculation.subtotal === null
? null
: roundBomCostMoney(calculation.subtotal),
note: manualReview?.note ?? '',
}
: null
const lineSubtotals = await bomCostingRepository.updateLine({
lineId,
skuId: line.skuId,
@ -536,21 +831,51 @@ export class BomCostingService {
heightMm,
quantity,
lossFactor,
matchStatus: candidate
? BomCostingMatchStatus.MATCHED
: BomCostingMatchStatus.UNMATCHED,
matchedLibraryType: candidate?.libraryType ?? '',
matchedLibraryRecordId: candidate?.recordId ?? '',
matchedLibraryName: candidate?.name ?? '',
matchedLibrarySpec: candidate?.spec ?? '',
resolvedUnitPrice: candidate?.unitPrice ?? null,
matchStatus: manualReview
? manualHasPrice
? BomCostingMatchStatus.MATCHED
: BomCostingMatchStatus.UNMATCHED
: candidate
? BomCostingMatchStatus.MATCHED
: BomCostingMatchStatus.UNMATCHED,
matchedLibraryType: manualReview
? manualHasPrice
? MANUAL_REVIEW_PRICE_SOURCE
: ''
: candidate?.libraryType ?? '',
matchedLibraryRecordId: manualReview ? '' : candidate?.recordId ?? '',
matchedLibraryName: manualReview
? manualHasPrice
? manualMaterialName
: ''
: candidate?.name ?? '',
matchedLibrarySpec: manualReview
? manualHasPrice
? manualMaterialSpec
: ''
: candidate?.spec ?? '',
resolvedUnitPrice: manualReview ? manualPrice : candidate?.unitPrice ?? null,
calculatedQuantity: calculation.pricingQuantity,
subtotal:
calculation.subtotal === null
? null
: roundBomCostMoney(calculation.subtotal),
},
priceSnapshot: candidate ? createSnapshot(candidate, lossFactor) : null,
priceSnapshot:
manualReview && manualHasPrice
? {
sourceType: MANUAL_REVIEW_PRICE_SOURCE,
sourceRecordId: line.id,
sourceName: manualMaterialName || line.partName,
sourceSpec: manualMaterialSpec,
unitPrice: manualPrice,
unit: manualPriceUnit,
lossFactor,
}
: candidate
? createSnapshot(candidate, lossFactor)
: null,
reviewInput,
})
const summary = summarizeBomCosting(
lineSubtotals.map((item) => ({