feat(bom-costing): 按业务类型开放并留档人工核算
This commit is contained in:
parent
d9a7ac208a
commit
2d3d5f2ef0
@ -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;
|
||||
@ -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;
|
||||
@ -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");
|
||||
@ -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])
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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>()
|
||||
|
||||
@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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) => {
|
||||
|
||||
@ -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)) ??
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: '详情图复核',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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([
|
||||
{
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user