diff --git a/furniture-3d-system/prisma/migrations/20260730193000_add_bom_costing_recalculation_runs/migration.sql b/furniture-3d-system/prisma/migrations/20260730193000_add_bom_costing_recalculation_runs/migration.sql new file mode 100644 index 00000000..c3b7ca8e --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260730193000_add_bom_costing_recalculation_runs/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260731100000_add_bom_costing_review_inputs/migration.sql b/furniture-3d-system/prisma/migrations/20260731100000_add_bom_costing_review_inputs/migration.sql new file mode 100644 index 00000000..6fecddd7 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260731100000_add_bom_costing_review_inputs/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260731103000_preserve_bom_costing_review_history/migration.sql b/furniture-3d-system/prisma/migrations/20260731103000_preserve_bom_costing_review_history/migration.sql new file mode 100644 index 00000000..bc69b3d1 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260731103000_preserve_bom_costing_review_history/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 59c44681..3b921f96 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -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]) diff --git a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/resume/route.test.ts b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/resume/route.test.ts new file mode 100644 index 00000000..9eb07afc --- /dev/null +++ b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/resume/route.test.ts @@ -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 + await callback() + expect(mockProcessCase).toHaveBeenCalledWith('case-ready') + }) +}) diff --git a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/resume/route.ts b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/resume/route.ts new file mode 100644 index 00000000..61ffda07 --- /dev/null +++ b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/resume/route.ts @@ -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 }, + ) +} diff --git a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/route.test.ts b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/route.test.ts new file mode 100644 index 00000000..1529b117 --- /dev/null +++ b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/route.test.ts @@ -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 + 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() + }) +}) diff --git a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/route.ts b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/route.ts new file mode 100644 index 00000000..2f6b11c1 --- /dev/null +++ b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/recalculate/route.ts @@ -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 }, + ) +} diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingAssetWorkspace.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingAssetWorkspace.tsx index 0e14a113..35a67f14 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingAssetWorkspace.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingAssetWorkspace.tsx @@ -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() const activeModelIds = new Set() const completedAnalysisAssetIds = new Set() diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx index 435d8ecf..83ca01f9 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -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( + , + ) + + 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, diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index 98270d0b..029cf5b6 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -22,7 +22,9 @@ import { Link2, ListFilter, PackageSearch, + Pencil, Play, + RefreshCw, ScanSearch, Search, X, @@ -59,6 +61,58 @@ interface ApiEnvelope { type ProcessStageKey = 'source' | 'archive' | 'model' | 'cost' type ProcessStageState = 'waiting' | 'active' | 'done' | 'failed' type HistoryStatusFilter = 'ALL' | 'ACTIVE' | 'READY' | 'FAILED' +type RecalculationMode = + | 'REUSE_FACTS' + | 'REANALYZE_SELECTED' + | 'REANALYZE_ALL' + +interface RecalculationRequest { + mode: RecalculationMode + selectedAssetIds: string[] + instruction: string +} + +type LineEditorMode = 'LIBRARY' | 'MANUAL' + +interface ManualReviewDraft { + materialCode: string + materialName: string + materialSpec: string + lengthMm: string + widthMm: string + heightMm: string + quantity: string + unitPrice: string + priceUnit: string + lossFactor: string + note: string +} + +const RECALCULATION_OPTIONS: Array<{ + value: RecalculationMode + label: string + description: string + scope: string +}> = [ + { + value: 'REUSE_FACTS', + label: '按现有事实重算', + description: '保留逐图分析结果,重新汇总结构、BOM 与资料库价格。', + scope: '图片调用 0 次 · 汇总模型 1 次', + }, + { + value: 'REANALYZE_SELECTED', + label: '补充分析图片', + description: '只重看你勾选的图片,其余图片事实继续复用。', + scope: '图片调用按所选数量 · 汇总模型 1 次', + }, + { + value: 'REANALYZE_ALL', + label: '全部重新分析', + description: '重新读取全部有效图片,并生成新的事实与核算版本。', + scope: '图片调用覆盖全部图片 · 汇总模型 1 次', + }, +] const STATUS_LABELS: Record = { DRAFT: '草稿', @@ -188,17 +242,51 @@ function toSummary(detail: BomCostingCaseDetail): BomCostingCaseSummary { } } +function buildManualReviewDraft(line: BomCostingLineDetail): ManualReviewDraft { + return { + materialCode: line.materialCode, + materialName: line.materialName, + materialSpec: line.materialSpec, + lengthMm: line.lengthMm === null ? '' : String(line.lengthMm), + widthMm: line.widthMm === null ? '' : String(line.widthMm), + heightMm: line.heightMm === null ? '' : String(line.heightMm), + quantity: String(line.quantity), + unitPrice: line.resolvedUnitPrice === null ? '' : String(line.resolvedUnitPrice), + priceUnit: line.resolvedPriceUnit || line.unit, + lossFactor: String(line.lossFactor), + note: '', + } +} + +function getCurrentTaskEvents(detail: BomCostingCaseDetail) { + const events = [...detail.taskEvents].sort( + (left, right) => + new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(), + ) + const latestRun = detail.analysisRuns?.[0] + if (!latestRun || latestRun.revision <= 1) return events + const recalculationStartIndex = events.findLastIndex((event) => + ['RECALCULATION_QUEUED', 'RECALCULATION_RESUMED'].includes( + event.eventType, + ), + ) + return recalculationStartIndex >= 0 + ? events.slice(recalculationStartIndex) + : events +} + function getStageState( detail: BomCostingCaseDetail, stage: (typeof PROCESS_STAGES)[number], ): ProcessStageState { + const taskEvents = getCurrentTaskEvents(detail) if ( detail.status === 'READY' || - detail.taskEvents.some((event) => stage.completeEvents.includes(event.eventType)) + taskEvents.some((event) => stage.completeEvents.includes(event.eventType)) ) { return 'done' } - const started = detail.taskEvents.some((event) => + const started = taskEvents.some((event) => stage.startEvents.includes(event.eventType), ) if (detail.status === 'FAILED' && started) return 'failed' @@ -210,7 +298,7 @@ function getStageMessage( detail: BomCostingCaseDetail, stage: (typeof PROCESS_STAGES)[number], ) { - const event = [...detail.taskEvents].reverse().find((item) => + const event = [...getCurrentTaskEvents(detail)].reverse().find((item) => [...stage.startEvents, ...stage.completeEvents].includes(item.eventType), ) return event?.message ?? stage.waitingText @@ -329,10 +417,11 @@ function ProcessInspector({ detail: BomCostingCaseDetail now: number }) { - const modelStarted = [...detail.taskEvents].reverse().find( + const taskEvents = getCurrentTaskEvents(detail) + const modelStarted = [...taskEvents].reverse().find( (event) => event.eventType === 'MODEL_ANALYSIS_STARTED', ) - const modelCompleted = [...detail.taskEvents].reverse().find( + const modelCompleted = [...taskEvents].reverse().find( (event) => event.eventType === 'MODEL_ANALYSIS_COMPLETED', ) const startedPayload = readRecord(modelStarted?.payload) @@ -354,7 +443,7 @@ function ProcessInspector({ const focusAsset = detail.sourceSnapshot?.assets.find( (asset) => asset.id === imageActivity.focusAssetId, ) - const recentEvents = [...detail.taskEvents] + const recentEvents = [...taskEvents] .sort( (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), @@ -514,11 +603,17 @@ function ResultCanvas({ selectedSku, onSelectSku, onCaseUpdated, + onRecalculate, + onResumeRecalculation, + recalculating, }: { detail: BomCostingCaseDetail selectedSku: BomCostingSkuDetail onSelectSku: (skuId: string) => void onCaseUpdated: (detail: BomCostingCaseDetail) => void + onRecalculate: (input: RecalculationRequest) => Promise + onResumeRecalculation: () => Promise + recalculating: boolean }) { const sourceAssets = useMemo( () => @@ -555,6 +650,12 @@ function ResultCanvas({ const [editingPriceLineId, setEditingPriceLineId] = useState( null, ) + const [editingLineMode, setEditingLineMode] = useState( + null, + ) + const [manualReviewDraft, setManualReviewDraft] = useState( + null, + ) const [priceCandidatesByLine, setPriceCandidatesByLine] = useState< Record >({}) @@ -566,6 +667,12 @@ function ResultCanvas({ null, ) const [priceEditorError, setPriceEditorError] = useState('') + const [recalculationOpen, setRecalculationOpen] = useState(false) + const [recalculationMode, setRecalculationMode] = + useState('REUSE_FACTS') + const [selectedReanalysisAssetIds, setSelectedReanalysisAssetIds] = + useState([]) + const [recalculationInstruction, setRecalculationInstruction] = useState('') const assetIdByStoredUrl = new Map() for (const asset of sourceAssets) { if (asset.storedUrl && !assetIdByStoredUrl.has(asset.storedUrl)) { @@ -587,6 +694,56 @@ function ResultCanvas({ })) } + const selectingReanalysisImages = + recalculationOpen && recalculationMode === 'REANALYZE_SELECTED' + const selectedReanalysisAssetIdSet = new Set(selectedReanalysisAssetIds) + const latestAnalysisRun = detail.analysisRuns?.[0] ?? null + const failedRecalculationRun = + latestAnalysisRun && + latestAnalysisRun.revision > 1 && + latestAnalysisRun.status === 'FAILED' + ? latestAnalysisRun + : null + const activeRecalculationRun = + latestAnalysisRun && + latestAnalysisRun.revision > 1 && + ['PENDING', 'PROCESSING'].includes(latestAnalysisRun.status) + ? latestAnalysisRun + : null + + const toggleReanalysisAsset = (assetId: string) => { + setSelectedReanalysisAssetIds((current) => + current.includes(assetId) + ? current.filter((id) => id !== assetId) + : [...current, assetId], + ) + } + + const submitRecalculation = async () => { + if ( + recalculating || + ( + recalculationMode === 'REANALYZE_SELECTED' && + selectedReanalysisAssetIds.length === 0 + ) + ) { + return + } + const started = await onRecalculate({ + mode: recalculationMode, + selectedAssetIds: + recalculationMode === 'REANALYZE_SELECTED' + ? selectedReanalysisAssetIds + : [], + instruction: recalculationInstruction.trim(), + }) + if (!started) return + setRecalculationOpen(false) + setSelectedReanalysisAssetIds([]) + setRecalculationInstruction('') + setRecalculationMode('REUSE_FACTS') + } + const selectEvidenceAsset = (assetId: string) => { const galleryAsset = galleryAssets.find((asset) => asset.id === assetId) if (galleryAsset) { @@ -611,12 +768,16 @@ function ResultCanvas({ } const openPriceEditor = async (line: BomCostingLineDetail) => { - if (editingPriceLineId === line.id) { + if (editingPriceLineId === line.id && editingLineMode === 'LIBRARY') { setEditingPriceLineId(null) + setEditingLineMode(null) + setManualReviewDraft(null) setPriceEditorError('') return } setEditingPriceLineId(line.id) + setEditingLineMode('LIBRARY') + setManualReviewDraft(null) setPriceCandidateQuery('') setPriceEditorError('') if (priceCandidatesByLine[line.id]) return @@ -643,6 +804,20 @@ function ResultCanvas({ } } + const openManualReviewEditor = (line: BomCostingLineDetail) => { + if (editingPriceLineId === line.id && editingLineMode === 'MANUAL') { + setEditingPriceLineId(null) + setEditingLineMode(null) + setManualReviewDraft(null) + setPriceEditorError('') + return + } + setEditingPriceLineId(line.id) + setEditingLineMode('MANUAL') + setManualReviewDraft(buildManualReviewDraft(line)) + setPriceEditorError('') + } + const confirmPriceCandidate = async ( line: BomCostingLineDetail, candidate: BomCostingPriceCandidateDetail, @@ -669,6 +844,8 @@ function ResultCanvas({ const data = await readApiData<{ case: BomCostingCaseDetail }>(response) onCaseUpdated(data.case) setEditingPriceLineId(null) + setEditingLineMode(null) + setManualReviewDraft(null) } catch (error) { setPriceEditorError( error instanceof Error ? error.message : '价格确认失败,请重试', @@ -678,6 +855,61 @@ function ResultCanvas({ } } + const saveManualReview = async (line: BomCostingLineDetail) => { + if (!manualReviewDraft || savingPriceLineId) return + + const parsePositive = ( + value: string, + label: string, + emptyValue: number | null, + ) => { + const normalized = value.trim() + if (!normalized) return emptyValue + const parsed = Number(normalized) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${label}必须是大于 0 的数字`) + } + return parsed + } + + try { + const review = { + materialCode: manualReviewDraft.materialCode.trim(), + materialName: manualReviewDraft.materialName.trim(), + materialSpec: manualReviewDraft.materialSpec.trim(), + lengthMm: parsePositive(manualReviewDraft.lengthMm, '长度', null), + widthMm: parsePositive(manualReviewDraft.widthMm, '宽度', null), + heightMm: parsePositive(manualReviewDraft.heightMm, '厚度/高度', null), + quantity: parsePositive(manualReviewDraft.quantity, '数量', line.quantity), + unitPrice: parsePositive(manualReviewDraft.unitPrice, '单价', null), + priceUnit: manualReviewDraft.priceUnit.trim(), + lossFactor: parsePositive(manualReviewDraft.lossFactor, '损耗系数', 1), + note: manualReviewDraft.note.trim(), + } + setSavingPriceLineId(line.id) + setPriceEditorError('') + const response = await fetch( + `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ review }), + }, + ) + const data = await readApiData<{ case: BomCostingCaseDetail }>(response) + onCaseUpdated(data.case) + setEditingPriceLineId(null) + setEditingLineMode(null) + setManualReviewDraft(null) + } catch (error) { + setPriceEditorError( + error instanceof Error ? error.message : '复核记录保存失败,请重试', + ) + } finally { + setSavingPriceLineId(null) + } + } + const missingCostGroups = new Set( selectedSku.costSummary.missingCostGroups ?? [], ) @@ -733,9 +965,38 @@ function ResultCanvas({ missing: missingCostGroups.has('PACKAGING'), }, ] + const currentSkuReviewInputs = (detail.reviewInputs ?? []).slice(0, 8) return (
+ {activeRecalculationRun ? ( +
+
+ +
+
+ {detail.statusMessage} + + {detail.progress}% + +
+
+
+
+
+
+
+ ) : null}
setSelectedAssetId(asset.id)} - aria-label={`查看${asset.kind === 'MAIN' ? '主图' : '当前 SKU 图片'} ${asset.sortOrder + 1}`} - aria-pressed={asset.id === selectedAsset?.id} + onClick={() => { + setSelectedAssetId(asset.id) + if (selectingReanalysisImages) { + toggleReanalysisAsset(asset.id) + } + }} + aria-label={ + selectingReanalysisImages + ? `${selectedReanalysisAssetIdSet.has(asset.id) ? '取消选择' : '选择'}${asset.kind === 'MAIN' ? '主图' : '当前 SKU 图片'} ${asset.sortOrder + 1}进行复查` + : `查看${asset.kind === 'MAIN' ? '主图' : '当前 SKU 图片'} ${asset.sortOrder + 1}` + } + aria-pressed={ + selectingReanalysisImages + ? selectedReanalysisAssetIdSet.has(asset.id) + : asset.id === selectedAsset?.id + } className={cn( 'relative h-[82px] w-[82px] shrink-0 overflow-hidden rounded-[6px] bg-[#edf0ef] outline-none transition', - asset.id === selectedAsset?.id + selectingReanalysisImages && + selectedReanalysisAssetIdSet.has(asset.id) + ? 'ring-2 ring-[#40585d] ring-offset-1' + : asset.id === selectedAsset?.id ? 'ring-2 ring-[#40585d] ring-offset-1' : 'hover:ring-1 hover:ring-black/20 focus-visible:ring-2 focus-visible:ring-[#40585d]', )} @@ -771,6 +1048,19 @@ function ResultCanvas({ {asset.kind === 'MAIN' ? '主图' : 'SKU 图'} + {selectingReanalysisImages ? ( + + ) : null} ))} {galleryAssets.length === 0 ? ( @@ -819,7 +1109,12 @@ function ResultCanvas({
+ {selectingReanalysisImages ? ( + + ) : null}
))}
@@ -940,46 +1256,67 @@ function ResultCanvas({ {line.quantity} {line.unit} - {line.category === 'PROCESSING' || - line.category === 'LABOR' ? ( - - {line.resolvedUnitPrice === null - ? 'AI 估算待补' - : 'AI 估算'} - - ) : canConfirmFromLibrary ? ( +
+
+ {line.category === 'PROCESSING' || + line.category === 'LABOR' ? ( + + {line.resolvedUnitPrice === null + ? 'AI 估算待补' + : 'AI 估算'} + + ) : canConfirmFromLibrary ? ( + + ) : null} + {line.resolvedUnitPrice !== null ? ( +
+ {formatMoney(line.resolvedUnitPrice)}/{line.resolvedPriceUnit || line.unit} +
+ ) : null} +
- ) : null} - {line.resolvedUnitPrice !== null ? ( -
- {formatMoney(line.resolvedUnitPrice)}/{line.resolvedPriceUnit || line.unit} -
- ) : null} +
{formatMoney(line.subtotal)} @@ -990,6 +1327,80 @@ function ResultCanvas({
+ {editingLineMode === 'MANUAL' && manualReviewDraft ? ( +
+
+
+
记录人工复核值
+
+ 保存本次测算的尺寸、材质和价格,后续可用于复核模型与计算规则。 +
+
+ + 不改资料库 + +
+
+ {([ + ['materialName', '材质名称', '如:橡胶木实木板'], + ['materialSpec', '材质规格', '如:18mm'], + ['materialCode', '材质编码', '可选'], + ['priceUnit', '价格单位', '㎡ / 件 / 米'], + ['lengthMm', '长度 mm', '可选'], + ['widthMm', '宽度 mm', '可选'], + ['heightMm', '厚度/高度 mm', '可选'], + ['quantity', '数量', '必填'], + ['unitPrice', '单价', '可选'], + ['lossFactor', '损耗系数', '默认 1'], + ] as const).map(([field, label, placeholder]) => ( + + setManualReviewDraft((current) => + current + ? { ...current, [field]: event.target.value } + : current, + ) + } + aria-label={label} + placeholder={placeholder} + type={field === 'priceUnit' || field.startsWith('material') ? 'text' : 'number'} + step="any" + containerClassName="!space-y-0" + className="!min-h-9 !rounded-[5px] !bg-white !px-2.5 !py-1.5 text-[10px]" + /> + ))} +
+