feat(bom-costing): add auditable dynamic company cost engine
This commit is contained in:
parent
b1566874e1
commit
ead86d523e
@ -35,6 +35,21 @@
|
||||
- 源库连接信息只允许放在生产本地 `/opt/furniture-3d-system-prod/shared/employee-directory-sync.env`,不能写入 Git 或共享文档。
|
||||
- 生产应用内部接口密钥继续从 `/opt/furniture-3d-system-prod/shared/.env` 的 `HR_SYNC_SECRET` 读取。
|
||||
|
||||
## BOM 成本参考镜像
|
||||
|
||||
- 用户核算请求只读取本地 PostgreSQL 镜像,不在请求中连接上游 `rpt.pm_bomBaseInfo`。
|
||||
- 低压只读预检:`npm run sync:bom-cost-reference -- --dry-run --max-pages=2`;默认单连接、每页 100 行、完整页之间等待 250ms。
|
||||
- 真实同步必须显式传 `--apply`、固定确认词、目标 PostgreSQL 身份和来源 SQL Server 身份;上游登录必须是 `report_reader`,`app_writer` 会被程序拒绝。
|
||||
- 生产定时器安装脚本:`furniture-3d-system/scripts/install-bom-cost-reference-sync-timer.sh`。
|
||||
- 生产 systemd 名称:`furniture-bom-cost-reference-sync.service` / `furniture-bom-cost-reference-sync.timer`。
|
||||
- 默认调度:每天中国标准时间 `02:00`,附带最多 10 分钟随机延迟;安装时可通过非敏感环境变量调整。
|
||||
- 上游连接继续只使用生产共享 `.env` 中现有的 `JST_PLANNING_SQLSERVER_* / report_reader`;不在第二个文件中重复保存密码。
|
||||
- 首次运行安装脚本只会创建权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/bom-cost-reference-sync.env` 门禁与限速模板;目标/来源身份未填齐或现有只读配置不完整时拒绝安装定时器。
|
||||
- 本地参考行只保留最新成功批次,避免每日约 76 万行无限增长;历史场景在自己的明细快照中保留来源行、指纹、单价、规则和原始计算依据,不依赖旧镜像行回查。
|
||||
- 镜像新鲜度按最新成功批次的完成时间判断。上游当前可见的创建时间只作审计展示,不能用来判断当天同步是否过期。
|
||||
- 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。
|
||||
- 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。
|
||||
|
||||
## 明确排除
|
||||
|
||||
- `192.168.58.20` 不是当前 `furniture-3d-system` 的生产部署目标。
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
"prisma:studio": "prisma studio",
|
||||
"sync:supply-chain-product-archive": "tsx scripts/sync-supply-chain-product-archive.ts",
|
||||
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
|
||||
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
|
||||
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",
|
||||
"import:yunshan-created-at": "tsx scripts/import-yunshan-product-created-at.ts",
|
||||
"db:verify-master-data": "node scripts/verify-master-data-foundation.js",
|
||||
"db:verify-master-data:audit": "node scripts/verify-master-data-foundation.js --output .master-data-audits/foundation-hard-ready-gate-current.json --summary-output .master-data-audits/foundation-hard-ready-gate-current.summary.json --summary-stdout",
|
||||
|
||||
@ -0,0 +1,239 @@
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "BomCostingReferenceSyncStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "BomCostingScenarioKind" AS ENUM ('SAME_MATERIAL', 'MATERIAL_SUBSTITUTION');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "BomCostingScenarioStatus" AS ENUM ('FINAL', 'PROVISIONAL', 'INSUFFICIENT_DATA', 'STALE');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE "BomCostingReferenceSyncRun" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sourceSystem" TEXT NOT NULL DEFAULT 'JSReportReplica',
|
||||
"sourceDatabase" TEXT NOT NULL DEFAULT '',
|
||||
"sourceSchema" TEXT NOT NULL DEFAULT 'rpt',
|
||||
"sourceObject" TEXT NOT NULL DEFAULT 'pm_bomBaseInfo',
|
||||
"status" "BomCostingReferenceSyncStatus" NOT NULL DEFAULT 'RUNNING',
|
||||
"batchKey" TEXT NOT NULL DEFAULT '',
|
||||
"configFingerprint" VARCHAR(64) NOT NULL DEFAULT '',
|
||||
"cursorStart" TEXT NOT NULL DEFAULT '',
|
||||
"cursorEnd" TEXT NOT NULL DEFAULT '',
|
||||
"rowsRead" INTEGER NOT NULL DEFAULT 0,
|
||||
"rowsWritten" INTEGER NOT NULL DEFAULT 0,
|
||||
"rowsCreated" INTEGER NOT NULL DEFAULT 0,
|
||||
"rowsUpdated" INTEGER NOT NULL DEFAULT 0,
|
||||
"rowsUnchanged" INTEGER NOT NULL DEFAULT 0,
|
||||
"rowsSkipped" INTEGER NOT NULL DEFAULT 0,
|
||||
"errorCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"errorMessage" TEXT NOT NULL DEFAULT '',
|
||||
"durationMs" INTEGER NOT NULL DEFAULT 0,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"sourceAsOf" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "BomCostingReferenceSyncRun_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "BomCostingReferenceRow" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sourceRowId" TEXT NOT NULL,
|
||||
"sourceSku" TEXT NOT NULL DEFAULT '',
|
||||
"bomVersionKey" TEXT NOT NULL DEFAULT '',
|
||||
"category" TEXT NOT NULL DEFAULT '',
|
||||
"categoryCode" TEXT NOT NULL DEFAULT '',
|
||||
"partNumber" TEXT NOT NULL DEFAULT '',
|
||||
"partName" TEXT NOT NULL DEFAULT '',
|
||||
"side" TEXT NOT NULL DEFAULT '',
|
||||
"materialName" TEXT NOT NULL DEFAULT '',
|
||||
"materialCode" TEXT NOT NULL DEFAULT '',
|
||||
"materialSpec" TEXT NOT NULL DEFAULT '',
|
||||
"processName" TEXT NOT NULL DEFAULT '',
|
||||
"unit" TEXT NOT NULL DEFAULT '',
|
||||
"rawLength" TEXT NOT NULL DEFAULT '',
|
||||
"rawWidth" TEXT NOT NULL DEFAULT '',
|
||||
"rawLongEdge" TEXT NOT NULL DEFAULT '',
|
||||
"rawShortEdge" TEXT NOT NULL DEFAULT '',
|
||||
"rawQuantity" TEXT NOT NULL DEFAULT '',
|
||||
"rawCoefficient" TEXT NOT NULL DEFAULT '',
|
||||
"rawArea" TEXT NOT NULL DEFAULT '',
|
||||
"rawEdgeLength" TEXT NOT NULL DEFAULT '',
|
||||
"rawUnitPrice" TEXT NOT NULL DEFAULT '',
|
||||
"rawAmount" TEXT NOT NULL DEFAULT '',
|
||||
"rawNote" TEXT NOT NULL DEFAULT '',
|
||||
"rawCreatedAt" TEXT NOT NULL DEFAULT '',
|
||||
"lengthMm" DECIMAL(18, 6),
|
||||
"widthMm" DECIMAL(18, 6),
|
||||
"longEdgeMm" DECIMAL(18, 6),
|
||||
"shortEdgeMm" DECIMAL(18, 6),
|
||||
"quantity" DECIMAL(18, 6),
|
||||
"coefficient" DECIMAL(18, 6),
|
||||
"areaSqm" DECIMAL(18, 6),
|
||||
"edgeLengthM" DECIMAL(18, 6),
|
||||
"unitPrice" DECIMAL(18, 6),
|
||||
"amount" DECIMAL(18, 6),
|
||||
"sourceCreatedAt" TIMESTAMP(3),
|
||||
"sourceFingerprint" VARCHAR(64) NOT NULL,
|
||||
"isSummaryRow" BOOLEAN NOT NULL DEFAULT false,
|
||||
"syncRunId" TEXT NOT NULL,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "BomCostingReferenceRow_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "BomCostingMaterialOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"normalizedName" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL DEFAULT 'BOARD',
|
||||
"aliases" JSONB NOT NULL DEFAULT '[]',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "BomCostingMaterialOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "BomCostingScenario" (
|
||||
"id" TEXT NOT NULL,
|
||||
"caseId" TEXT NOT NULL,
|
||||
"skuId" TEXT NOT NULL,
|
||||
"kind" "BomCostingScenarioKind" NOT NULL,
|
||||
"scenarioKey" TEXT NOT NULL DEFAULT '',
|
||||
"revision" INTEGER NOT NULL DEFAULT 1,
|
||||
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
|
||||
"targetMaterialId" TEXT,
|
||||
"targetMaterialName" TEXT NOT NULL DEFAULT '',
|
||||
"status" "BomCostingScenarioStatus" NOT NULL DEFAULT 'PROVISIONAL',
|
||||
"totalCost" DECIMAL(18, 2),
|
||||
"knownCost" DECIMAL(18, 2),
|
||||
"materialCost" DECIMAL(18, 2),
|
||||
"processingCost" DECIMAL(18, 2),
|
||||
"laborCost" DECIMAL(18, 2),
|
||||
"packagingCost" DECIMAL(18, 2),
|
||||
"lossCost" DECIMAL(18, 2),
|
||||
"competitorPrice" DECIMAL(18, 2),
|
||||
"priceDelta" DECIMAL(18, 2),
|
||||
"confidence" DECIMAL(5, 4),
|
||||
"warning" TEXT NOT NULL DEFAULT '',
|
||||
"unresolvedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"dataAsOf" TIMESTAMP(3),
|
||||
"syncRunId" TEXT,
|
||||
"rulesetVersion" TEXT NOT NULL DEFAULT 'dynamic-bom-cost/v2',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "BomCostingScenario_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "BomCostingScenarioLine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scenarioId" TEXT NOT NULL,
|
||||
"sourceLineId" TEXT,
|
||||
"sourceRowId" TEXT NOT NULL DEFAULT '',
|
||||
"category" TEXT NOT NULL DEFAULT '',
|
||||
"partName" TEXT NOT NULL DEFAULT '',
|
||||
"originalMaterial" TEXT NOT NULL DEFAULT '',
|
||||
"appliedMaterial" TEXT NOT NULL DEFAULT '',
|
||||
"materialSpec" TEXT NOT NULL DEFAULT '',
|
||||
"unit" TEXT NOT NULL DEFAULT '',
|
||||
"quantity" DECIMAL(18, 6),
|
||||
"coefficient" DECIMAL(18, 6),
|
||||
"pricingQuantity" DECIMAL(18, 6),
|
||||
"unitPrice" DECIMAL(18, 6),
|
||||
"subtotal" DECIMAL(18, 2),
|
||||
"priceSource" TEXT NOT NULL DEFAULT '',
|
||||
"sourceSku" TEXT NOT NULL DEFAULT '',
|
||||
"referenceSkus" JSONB NOT NULL DEFAULT '[]',
|
||||
"referenceRowIds" JSONB NOT NULL DEFAULT '[]',
|
||||
"sourceFingerprint" VARCHAR(64) NOT NULL DEFAULT '',
|
||||
"sourceCreatedAt" TIMESTAMP(3),
|
||||
"sourceSyncedAt" TIMESTAMP(3),
|
||||
"calculationRule" TEXT NOT NULL DEFAULT '',
|
||||
"confidence" DECIMAL(5, 4),
|
||||
"status" "BomCostingScenarioStatus" NOT NULL DEFAULT 'PROVISIONAL',
|
||||
"warning" TEXT NOT NULL DEFAULT '',
|
||||
"rawSnapshot" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "BomCostingScenarioLine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "BomCostingReferenceRow_source_key" ON "BomCostingReferenceRow"("syncRunId", "sourceRowId", "sourceSku", "bomVersionKey");
|
||||
CREATE INDEX "BomCostingReferenceSyncRun_status_startedAt_idx" ON "BomCostingReferenceSyncRun"("status", "startedAt");
|
||||
CREATE INDEX "BomCostingReferenceSyncRun_status_completedAt_idx" ON "BomCostingReferenceSyncRun"("status", "completedAt");
|
||||
CREATE INDEX "BomCostingReferenceSyncRun_completedAt_idx" ON "BomCostingReferenceSyncRun"("completedAt");
|
||||
CREATE INDEX "BomCostingReferenceSyncRun_batchKey_idx" ON "BomCostingReferenceSyncRun"("batchKey");
|
||||
CREATE INDEX "BomCostingReferenceRow_sku_category_material_idx" ON "BomCostingReferenceRow"("sourceSku", "categoryCode", "materialName");
|
||||
CREATE INDEX "BomCostingReferenceRow_material_spec_unit_idx" ON "BomCostingReferenceRow"("materialName", "materialSpec", "unit");
|
||||
CREATE INDEX "BomCostingReferenceRow_syncRunId_sku_category_material_idx" ON "BomCostingReferenceRow"("syncRunId", "sourceSku", "categoryCode", "materialName");
|
||||
CREATE INDEX "BomCostingReferenceRow_syncRunId_material_spec_unit_idx" ON "BomCostingReferenceRow"("syncRunId", "materialName", "materialSpec", "unit");
|
||||
CREATE INDEX "BomCostingReferenceRow_syncRunId_category_part_number_idx" ON "BomCostingReferenceRow"("syncRunId", "categoryCode", "partNumber");
|
||||
CREATE INDEX "BomCostingReferenceRow_syncRunId_category_dimensions_idx" ON "BomCostingReferenceRow"("syncRunId", "categoryCode", "lengthMm", "widthMm");
|
||||
CREATE INDEX "BomCostingReferenceRow_syncRunId_idx" ON "BomCostingReferenceRow"("syncRunId");
|
||||
CREATE INDEX "BomCostingReferenceRow_syncRunId_summary_category_idx" ON "BomCostingReferenceRow"("syncRunId", "isSummaryRow", "categoryCode");
|
||||
CREATE INDEX "BomCostingReferenceRow_sourceCreatedAt_idx" ON "BomCostingReferenceRow"("sourceCreatedAt");
|
||||
CREATE UNIQUE INDEX "BomCostingMaterialOption_code_key" ON "BomCostingMaterialOption"("code");
|
||||
CREATE INDEX "BomCostingMaterialOption_enabled_sortOrder_idx" ON "BomCostingMaterialOption"("enabled", "sortOrder");
|
||||
CREATE INDEX "BomCostingMaterialOption_normalizedName_idx" ON "BomCostingMaterialOption"("normalizedName");
|
||||
CREATE UNIQUE INDEX "BomCostingScenario_sku_kind_scenarioKey_revision_key" ON "BomCostingScenario"("skuId", "kind", "scenarioKey", "revision");
|
||||
CREATE INDEX "BomCostingScenario_caseId_kind_idx" ON "BomCostingScenario"("caseId", "kind");
|
||||
CREATE INDEX "BomCostingScenario_skuId_isCurrent_updatedAt_idx" ON "BomCostingScenario"("skuId", "isCurrent", "updatedAt");
|
||||
CREATE INDEX "BomCostingScenario_status_dataAsOf_idx" ON "BomCostingScenario"("status", "dataAsOf");
|
||||
CREATE INDEX "BomCostingScenario_syncRunId_idx" ON "BomCostingScenario"("syncRunId");
|
||||
CREATE INDEX "BomCostingScenarioLine_scenarioId_category_idx" ON "BomCostingScenarioLine"("scenarioId", "category");
|
||||
CREATE INDEX "BomCostingScenarioLine_sourceRowId_idx" ON "BomCostingScenarioLine"("sourceRowId");
|
||||
CREATE INDEX "BomCostingScenarioLine_status_idx" ON "BomCostingScenarioLine"("status");
|
||||
|
||||
-- Material choices are a read-only projection of the company's enabled
|
||||
-- substrate dictionary. The scheduled mirror refresh keeps this projection
|
||||
-- current; this initial insert prevents a newly migrated environment from
|
||||
-- exposing an empty selector when the master data already exists.
|
||||
INSERT INTO "BomCostingMaterialOption" (
|
||||
"id", "code", "name", "normalizedName", "category", "aliases", "enabled", "sortOrder", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'bom-material-' || "id",
|
||||
'SUBSTRATE_SYSTEM_NAME:' || "id",
|
||||
"name",
|
||||
lower(regexp_replace("name", '\s+', '', 'g')),
|
||||
'BOARD',
|
||||
jsonb_build_array("name"),
|
||||
"isActive",
|
||||
"sortOrder",
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "SubstrateSystemNameOption"
|
||||
ON CONFLICT ("code") DO UPDATE SET
|
||||
"name" = EXCLUDED."name",
|
||||
"normalizedName" = EXCLUDED."normalizedName",
|
||||
"aliases" = EXCLUDED."aliases",
|
||||
"enabled" = EXCLUDED."enabled",
|
||||
"sortOrder" = EXCLUDED."sortOrder",
|
||||
"updatedAt" = CURRENT_TIMESTAMP;
|
||||
|
||||
ALTER TABLE "BomCostingReferenceRow"
|
||||
ADD CONSTRAINT "BomCostingReferenceRow_syncRunId_fkey"
|
||||
FOREIGN KEY ("syncRunId") REFERENCES "BomCostingReferenceSyncRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "BomCostingScenario"
|
||||
ADD CONSTRAINT "BomCostingScenario_caseId_fkey"
|
||||
FOREIGN KEY ("caseId") REFERENCES "BomCostingCase"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT "BomCostingScenario_skuId_fkey"
|
||||
FOREIGN KEY ("skuId") REFERENCES "BomCostingSku"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT "BomCostingScenario_targetMaterialId_fkey"
|
||||
FOREIGN KEY ("targetMaterialId") REFERENCES "BomCostingMaterialOption"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT "BomCostingScenario_syncRunId_fkey"
|
||||
FOREIGN KEY ("syncRunId") REFERENCES "BomCostingReferenceSyncRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "BomCostingScenarioLine"
|
||||
ADD CONSTRAINT "BomCostingScenarioLine_scenarioId_fkey"
|
||||
FOREIGN KEY ("scenarioId") REFERENCES "BomCostingScenario"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT "BomCostingScenarioLine_sourceLineId_fkey"
|
||||
FOREIGN KEY ("sourceLineId") REFERENCES "BomCostingLine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "BomCostingScenario"
|
||||
ALTER COLUMN "rulesetVersion" SET DEFAULT 'dynamic-bom-cost/v3';
|
||||
@ -269,24 +269,24 @@ model IntakeNonIntegratedProduct {
|
||||
}
|
||||
|
||||
model BarcodePrintImportRecord {
|
||||
id String @id @default(cuid())
|
||||
kind String
|
||||
id String @id @default(cuid())
|
||||
kind String
|
||||
// System-created barcode rows point to their pricing-row source. Imported
|
||||
// rows keep this null, so identical Excel rows remain independently valid.
|
||||
sourcePricingRowId String? @unique
|
||||
skuCode String
|
||||
normalizedSku String
|
||||
supplierCode String @default("")
|
||||
supplierName String @default("")
|
||||
normalizedSupplierName String @default("")
|
||||
sourceFileName String @default("")
|
||||
rowNumber Int @default(0)
|
||||
importedAt DateTime @default(now())
|
||||
updates Json
|
||||
createdBy String
|
||||
creator User @relation("BarcodePrintImportRecordCreator", fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sourcePricingRowId String? @unique
|
||||
skuCode String
|
||||
normalizedSku String
|
||||
supplierCode String @default("")
|
||||
supplierName String @default("")
|
||||
normalizedSupplierName String @default("")
|
||||
sourceFileName String @default("")
|
||||
rowNumber Int @default(0)
|
||||
importedAt DateTime @default(now())
|
||||
updates Json
|
||||
createdBy String
|
||||
creator User @relation("BarcodePrintImportRecordCreator", fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([kind])
|
||||
@@index([normalizedSku])
|
||||
@ -792,6 +792,7 @@ model BomCostingCase {
|
||||
taskEvents BomCostingTaskEvent[]
|
||||
analysisRuns BomCostingAnalysisRun[]
|
||||
reviewInputs BomCostingReviewInput[]
|
||||
scenarios BomCostingScenario[]
|
||||
|
||||
@@index([createdById, deletedAt, createdAt])
|
||||
@@index([status, deletedAt])
|
||||
@ -943,6 +944,7 @@ model BomCostingSku {
|
||||
|
||||
bomLines BomCostingLine[]
|
||||
reviewInputs BomCostingReviewInput[]
|
||||
scenarios BomCostingScenario[]
|
||||
|
||||
@@unique([caseId, externalSkuId])
|
||||
@@index([caseId, sortOrder])
|
||||
@ -984,6 +986,7 @@ model BomCostingLine {
|
||||
|
||||
priceSnapshots BomCostingPriceSnapshot[]
|
||||
reviewInputs BomCostingReviewInput[]
|
||||
scenarioLines BomCostingScenarioLine[]
|
||||
|
||||
@@unique([skuId, externalLineId])
|
||||
@@index([skuId, sortOrder])
|
||||
@ -1062,6 +1065,200 @@ model BomCostingTaskEvent {
|
||||
@@index([providerTaskId])
|
||||
}
|
||||
|
||||
// Local, auditable mirror of the upstream rpt.pm_bomBaseInfo view. The
|
||||
// costing request path only reads these rows; the SQL Server source is used by
|
||||
// the scheduled sync job and never by a user request.
|
||||
model BomCostingReferenceSyncRun {
|
||||
id String @id @default(cuid())
|
||||
sourceSystem String @default("JSReportReplica")
|
||||
sourceDatabase String @default("")
|
||||
sourceSchema String @default("rpt")
|
||||
sourceObject String @default("pm_bomBaseInfo")
|
||||
status BomCostingReferenceSyncStatus @default(RUNNING)
|
||||
batchKey String @default("")
|
||||
configFingerprint String @default("") @db.VarChar(64)
|
||||
cursorStart String @default("")
|
||||
cursorEnd String @default("")
|
||||
rowsRead Int @default(0)
|
||||
rowsWritten Int @default(0)
|
||||
rowsCreated Int @default(0)
|
||||
rowsUpdated Int @default(0)
|
||||
rowsUnchanged Int @default(0)
|
||||
rowsSkipped Int @default(0)
|
||||
errorCount Int @default(0)
|
||||
errorMessage String @default("") @db.Text
|
||||
durationMs Int @default(0)
|
||||
startedAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
sourceAsOf DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
referenceRows BomCostingReferenceRow[]
|
||||
scenarios BomCostingScenario[]
|
||||
|
||||
@@index([status, startedAt])
|
||||
@@index([status, completedAt])
|
||||
@@index([completedAt])
|
||||
@@index([batchKey])
|
||||
}
|
||||
|
||||
model BomCostingReferenceRow {
|
||||
id String @id @default(cuid())
|
||||
sourceRowId String
|
||||
sourceSku String @default("")
|
||||
bomVersionKey String @default("")
|
||||
category String @default("")
|
||||
categoryCode String @default("")
|
||||
partNumber String @default("")
|
||||
partName String @default("")
|
||||
side String @default("")
|
||||
materialName String @default("")
|
||||
materialCode String @default("")
|
||||
materialSpec String @default("")
|
||||
processName String @default("")
|
||||
unit String @default("")
|
||||
rawLength String @default("")
|
||||
rawWidth String @default("")
|
||||
rawLongEdge String @default("")
|
||||
rawShortEdge String @default("")
|
||||
rawQuantity String @default("")
|
||||
rawCoefficient String @default("")
|
||||
rawArea String @default("")
|
||||
rawEdgeLength String @default("")
|
||||
rawUnitPrice String @default("")
|
||||
rawAmount String @default("")
|
||||
rawNote String @default("") @db.Text
|
||||
rawCreatedAt String @default("")
|
||||
lengthMm Decimal? @db.Decimal(18, 6)
|
||||
widthMm Decimal? @db.Decimal(18, 6)
|
||||
longEdgeMm Decimal? @db.Decimal(18, 6)
|
||||
shortEdgeMm Decimal? @db.Decimal(18, 6)
|
||||
quantity Decimal? @db.Decimal(18, 6)
|
||||
coefficient Decimal? @db.Decimal(18, 6)
|
||||
areaSqm Decimal? @db.Decimal(18, 6)
|
||||
edgeLengthM Decimal? @db.Decimal(18, 6)
|
||||
unitPrice Decimal? @db.Decimal(18, 6)
|
||||
amount Decimal? @db.Decimal(18, 6)
|
||||
sourceCreatedAt DateTime?
|
||||
sourceFingerprint String @db.VarChar(64)
|
||||
isSummaryRow Boolean @default(false)
|
||||
syncRunId String
|
||||
syncRun BomCostingReferenceSyncRun @relation(fields: [syncRunId], references: [id], onDelete: Cascade)
|
||||
syncedAt DateTime @default(now())
|
||||
|
||||
// A run is written as an isolated snapshot so failed/new runs cannot mutate
|
||||
// the visible batch. After activation, only the newest operational mirror is
|
||||
// retained; scenario lines keep immutable historical calculation snapshots.
|
||||
@@unique([syncRunId, sourceRowId, sourceSku, bomVersionKey], map: "BomCostingReferenceRow_source_key")
|
||||
@@index([syncRunId, sourceSku, categoryCode, materialName], map: "BomCostingReferenceRow_syncRunId_sku_category_material_idx")
|
||||
@@index([syncRunId, materialName, materialSpec, unit], map: "BomCostingReferenceRow_syncRunId_material_spec_unit_idx")
|
||||
@@index([syncRunId, categoryCode, partNumber], map: "BomCostingReferenceRow_syncRunId_category_part_number_idx")
|
||||
@@index([syncRunId, categoryCode, lengthMm, widthMm], map: "BomCostingReferenceRow_syncRunId_category_dimensions_idx")
|
||||
@@index([sourceSku, categoryCode, materialName], map: "BomCostingReferenceRow_sku_category_material_idx")
|
||||
@@index([materialName, materialSpec, unit], map: "BomCostingReferenceRow_material_spec_unit_idx")
|
||||
@@index([syncRunId])
|
||||
@@index([syncRunId, isSummaryRow, categoryCode], map: "BomCostingReferenceRow_syncRunId_summary_category_idx")
|
||||
@@index([sourceCreatedAt])
|
||||
}
|
||||
|
||||
model BomCostingMaterialOption {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
normalizedName String
|
||||
category String @default("BOARD")
|
||||
aliases Json @default("[]")
|
||||
enabled Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
substitutionScenarios BomCostingScenario[]
|
||||
|
||||
@@index([enabled, sortOrder])
|
||||
@@index([normalizedName])
|
||||
}
|
||||
|
||||
model BomCostingScenario {
|
||||
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: Cascade)
|
||||
kind BomCostingScenarioKind
|
||||
scenarioKey String @default("")
|
||||
revision Int @default(1)
|
||||
isCurrent Boolean @default(true)
|
||||
targetMaterialId String?
|
||||
targetMaterial BomCostingMaterialOption? @relation(fields: [targetMaterialId], references: [id], onDelete: SetNull)
|
||||
targetMaterialName String @default("")
|
||||
status BomCostingScenarioStatus @default(PROVISIONAL)
|
||||
totalCost Decimal? @db.Decimal(18, 2)
|
||||
knownCost Decimal? @db.Decimal(18, 2)
|
||||
materialCost Decimal? @db.Decimal(18, 2)
|
||||
processingCost Decimal? @db.Decimal(18, 2)
|
||||
laborCost Decimal? @db.Decimal(18, 2)
|
||||
packagingCost Decimal? @db.Decimal(18, 2)
|
||||
lossCost Decimal? @db.Decimal(18, 2)
|
||||
competitorPrice Decimal? @db.Decimal(18, 2)
|
||||
priceDelta Decimal? @db.Decimal(18, 2)
|
||||
confidence Decimal? @db.Decimal(5, 4)
|
||||
warning String @default("") @db.Text
|
||||
unresolvedCount Int @default(0)
|
||||
dataAsOf DateTime?
|
||||
syncRunId String?
|
||||
syncRun BomCostingReferenceSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull)
|
||||
rulesetVersion String @default("dynamic-bom-cost/v3")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
lines BomCostingScenarioLine[]
|
||||
|
||||
@@unique([skuId, kind, scenarioKey, revision], map: "BomCostingScenario_sku_kind_scenarioKey_revision_key")
|
||||
@@index([caseId, kind])
|
||||
@@index([skuId, isCurrent, updatedAt])
|
||||
@@index([status, dataAsOf])
|
||||
@@index([syncRunId])
|
||||
}
|
||||
|
||||
model BomCostingScenarioLine {
|
||||
id String @id @default(cuid())
|
||||
scenarioId String
|
||||
scenario BomCostingScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
sourceLineId String?
|
||||
sourceLine BomCostingLine? @relation(fields: [sourceLineId], references: [id], onDelete: SetNull)
|
||||
sourceRowId String @default("")
|
||||
category String @default("")
|
||||
partName String @default("")
|
||||
originalMaterial String @default("")
|
||||
appliedMaterial String @default("")
|
||||
materialSpec String @default("")
|
||||
unit String @default("")
|
||||
quantity Decimal? @db.Decimal(18, 6)
|
||||
coefficient Decimal? @db.Decimal(18, 6)
|
||||
pricingQuantity Decimal? @db.Decimal(18, 6)
|
||||
unitPrice Decimal? @db.Decimal(18, 6)
|
||||
subtotal Decimal? @db.Decimal(18, 2)
|
||||
priceSource String @default("")
|
||||
sourceSku String @default("")
|
||||
referenceSkus Json @default("[]")
|
||||
referenceRowIds Json @default("[]")
|
||||
sourceFingerprint String @default("") @db.VarChar(64)
|
||||
sourceCreatedAt DateTime?
|
||||
sourceSyncedAt DateTime?
|
||||
calculationRule String @default("")
|
||||
confidence Decimal? @db.Decimal(5, 4)
|
||||
status BomCostingScenarioStatus @default(PROVISIONAL)
|
||||
warning String @default("") @db.Text
|
||||
rawSnapshot Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([scenarioId, category])
|
||||
@@index([sourceRowId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 装配相关
|
||||
// ============================================
|
||||
@ -2106,6 +2303,24 @@ enum BomCostingReviewInputSource {
|
||||
LIBRARY_MATCH
|
||||
}
|
||||
|
||||
enum BomCostingReferenceSyncStatus {
|
||||
RUNNING
|
||||
SUCCEEDED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum BomCostingScenarioKind {
|
||||
SAME_MATERIAL
|
||||
MATERIAL_SUBSTITUTION
|
||||
}
|
||||
|
||||
enum BomCostingScenarioStatus {
|
||||
FINAL
|
||||
PROVISIONAL
|
||||
INSUFFICIENT_DATA
|
||||
STALE
|
||||
}
|
||||
|
||||
enum ApprovalWorkflowStatus {
|
||||
DRAFT
|
||||
PENDING
|
||||
|
||||
197
furniture-3d-system/scripts/backtest-bom-cost-reference.ts
Normal file
197
furniture-3d-system/scripts/backtest-bom-cost-reference.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
import { BomCostingReferenceSyncStatus, Prisma } from '@prisma/client'
|
||||
import {
|
||||
BOM_COST_BACKTEST_MAX_SAMPLE_SIZE,
|
||||
BOM_COST_BACKTEST_MIN_SAMPLE_SIZE,
|
||||
buildBomCostBacktestReport,
|
||||
evaluateBomCostBacktestCase,
|
||||
selectBomCostBacktestCandidates,
|
||||
type BomCostBacktestMirrorRow,
|
||||
} from '../src/lib/bom-costing/dynamic-bom-cost-backtest'
|
||||
|
||||
loadEnvConfig(process.cwd())
|
||||
|
||||
type CliOptions = {
|
||||
sampleSize: number
|
||||
help: boolean
|
||||
}
|
||||
|
||||
const mirrorRowSelect = Prisma.validator<Prisma.BomCostingReferenceRowSelect>()({
|
||||
sourceRowId: true,
|
||||
sourceSku: true,
|
||||
bomVersionKey: true,
|
||||
category: true,
|
||||
categoryCode: true,
|
||||
partNumber: true,
|
||||
partName: true,
|
||||
materialName: true,
|
||||
materialCode: true,
|
||||
materialSpec: true,
|
||||
processName: true,
|
||||
unit: true,
|
||||
rawNote: true,
|
||||
lengthMm: true,
|
||||
widthMm: true,
|
||||
quantity: true,
|
||||
coefficient: true,
|
||||
areaSqm: true,
|
||||
edgeLengthM: true,
|
||||
unitPrice: true,
|
||||
amount: true,
|
||||
sourceCreatedAt: true,
|
||||
syncedAt: true,
|
||||
isSummaryRow: true,
|
||||
})
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = { sampleSize: 30, help: false }
|
||||
for (const argument of argv) {
|
||||
if (argument === '--help' || argument === '-h') options.help = true
|
||||
else if (argument.startsWith('--sample-size=')) {
|
||||
const sampleSize = Number(argument.slice('--sample-size='.length))
|
||||
if (!Number.isSafeInteger(sampleSize) ||
|
||||
sampleSize < BOM_COST_BACKTEST_MIN_SAMPLE_SIZE ||
|
||||
sampleSize > BOM_COST_BACKTEST_MAX_SAMPLE_SIZE) {
|
||||
throw new Error(
|
||||
`--sample-size must be an integer between ${BOM_COST_BACKTEST_MIN_SAMPLE_SIZE} and ${BOM_COST_BACKTEST_MAX_SAMPLE_SIZE}`,
|
||||
)
|
||||
}
|
||||
options.sampleSize = sampleSize
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npm run backtest:bom-cost-reference
|
||||
npm run backtest:bom-cost-reference -- --sample-size=50
|
||||
|
||||
只读取本地最新成功 BOM 镜像,不访问上游,也不写数据库。默认稳定抽取 30 个公司 SKU,
|
||||
比较历史“总成本”与当前规则逐行重算结果。样本只能设为 20 至 50;任何可比较 SKU
|
||||
误差超过固定 10% 门槛,或可比较样本不足,命令都会以非 0 状态退出。
|
||||
`.trim())
|
||||
}
|
||||
|
||||
function stableCandidateKey(sourceSku: string, bomVersionKey: string) {
|
||||
return createHash('sha256').update(`${sourceSku}\u0000${bomVersionKey}`).digest('hex')
|
||||
}
|
||||
|
||||
function pairKey(sourceSku: string, bomVersionKey: string) {
|
||||
return `${sourceSku}\u0000${bomVersionKey}`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
const { prisma } = await import('../src/lib/prisma')
|
||||
try {
|
||||
const latestSuccessful = await prisma.bomCostingReferenceSyncRun.findFirst({
|
||||
where: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
batchKey: true,
|
||||
completedAt: true,
|
||||
sourceAsOf: true,
|
||||
rowsRead: true,
|
||||
},
|
||||
})
|
||||
if (!latestSuccessful) throw new Error('本地没有成功的 BOM 镜像批次,无法回测')
|
||||
|
||||
const [summaryRows, residualRow] = await Promise.all([
|
||||
prisma.bomCostingReferenceRow.findMany({
|
||||
where: {
|
||||
syncRunId: latestSuccessful.id,
|
||||
isSummaryRow: true,
|
||||
amount: { gt: 0 },
|
||||
sourceSku: { not: '' },
|
||||
},
|
||||
select: mirrorRowSelect,
|
||||
orderBy: [{ sourceSku: 'asc' }, { sourceCreatedAt: 'desc' }],
|
||||
}),
|
||||
prisma.bomCostingReferenceRow.findFirst({
|
||||
where: { syncRunId: { not: latestSuccessful.id } },
|
||||
select: { syncRunId: true },
|
||||
}),
|
||||
])
|
||||
const typedSummaries = summaryRows as BomCostBacktestMirrorRow[]
|
||||
const summaryById = new Map(typedSummaries.map((row) => [row.sourceRowId, row]))
|
||||
const candidates = selectBomCostBacktestCandidates(typedSummaries)
|
||||
.sort((left, right) => stableCandidateKey(left.sourceSku, left.bomVersionKey)
|
||||
.localeCompare(stableCandidateKey(right.sourceSku, right.bomVersionKey)))
|
||||
const evaluations = []
|
||||
let evaluatedCandidateCount = 0
|
||||
const chunkSize = Math.max(50, options.sampleSize * 2)
|
||||
for (let offset = 0; offset < candidates.length &&
|
||||
evaluations.filter((evaluation) => evaluation.status === 'COMPARABLE').length < options.sampleSize;
|
||||
offset += chunkSize) {
|
||||
const chunk = candidates.slice(offset, offset + chunkSize)
|
||||
const sourceSkus = Array.from(new Set(chunk.map((candidate) => candidate.sourceSku)))
|
||||
const detailRows = await prisma.bomCostingReferenceRow.findMany({
|
||||
where: {
|
||||
syncRunId: latestSuccessful.id,
|
||||
isSummaryRow: false,
|
||||
sourceSku: { in: sourceSkus },
|
||||
},
|
||||
select: mirrorRowSelect,
|
||||
orderBy: [{ sourceSku: 'asc' }, { sourceRowId: 'asc' }],
|
||||
}) as BomCostBacktestMirrorRow[]
|
||||
const detailsByPair = new Map<string, BomCostBacktestMirrorRow[]>()
|
||||
for (const row of detailRows) {
|
||||
const key = pairKey(row.sourceSku, row.bomVersionKey)
|
||||
const values = detailsByPair.get(key) ?? []
|
||||
values.push(row)
|
||||
detailsByPair.set(key, values)
|
||||
}
|
||||
for (const candidate of chunk) {
|
||||
evaluatedCandidateCount += 1
|
||||
const summary = summaryById.get(candidate.historicalTotalSourceRowId)
|
||||
if (!summary) continue
|
||||
const evaluation = evaluateBomCostBacktestCase([
|
||||
...(detailsByPair.get(pairKey(candidate.sourceSku, candidate.bomVersionKey)) ?? []),
|
||||
summary,
|
||||
])
|
||||
if (evaluation) evaluations.push(evaluation)
|
||||
if (evaluations.filter((item) => item.status === 'COMPARABLE').length >= options.sampleSize) break
|
||||
}
|
||||
}
|
||||
|
||||
const report = buildBomCostBacktestReport({
|
||||
evaluations,
|
||||
requestedSampleSize: options.sampleSize,
|
||||
})
|
||||
console.log(JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
dataSource: 'LOCAL_BOM_REFERENCE_MIRROR',
|
||||
upstreamAccessed: false,
|
||||
sync: {
|
||||
runId: latestSuccessful.id,
|
||||
batchKey: latestSuccessful.batchKey,
|
||||
completedAt: latestSuccessful.completedAt?.toISOString() ?? null,
|
||||
sourceAsOf: latestSuccessful.sourceAsOf?.toISOString() ?? null,
|
||||
rowsRead: latestSuccessful.rowsRead,
|
||||
mirrorState: residualRow ? 'MIXED' : 'SINGLE_SUCCESSFUL_SNAPSHOT',
|
||||
residualRunId: residualRow?.syncRunId ?? null,
|
||||
},
|
||||
availableCandidateCount: candidates.length,
|
||||
evaluatedCandidateCount,
|
||||
...report,
|
||||
}, null, 2))
|
||||
if (!report.passed) process.exitCode = 1
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(`BOM cost backtest failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}"
|
||||
SERVICE_NAME="${BOM_COST_REFERENCE_SYNC_SERVICE_NAME:-furniture-bom-cost-reference-sync}"
|
||||
APP_ROOT="$BASE/current/furniture-3d-system"
|
||||
SHARED="$BASE/shared"
|
||||
APP_ENV="$SHARED/.env"
|
||||
SYNC_ENV="$SHARED/bom-cost-reference-sync.env"
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer"
|
||||
SYNC_CALENDAR="${BOM_COST_REFERENCE_SYNC_CALENDAR:-*-*-* 02:00:00 Asia/Shanghai}"
|
||||
RANDOMIZED_DELAY_SEC="${BOM_COST_REFERENCE_SYNC_RANDOMIZED_DELAY_SEC:-10min}"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "This installer must run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$APP_ROOT" ]; then
|
||||
echo "Missing app root: $APP_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$APP_ENV" ]; then
|
||||
echo "Missing app env: $APP_ENV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$SHARED"
|
||||
if [ ! -f "$SYNC_ENV" ]; then
|
||||
umask 077
|
||||
cat >"$SYNC_ENV" <<'EOF'
|
||||
# Company BOM cost reference mirror controls. The SQL Server report_reader
|
||||
# connection stays in shared/.env under JST_PLANNING_SQLSERVER_*.
|
||||
BOM_COSTING_EXPECTED_DATABASE_IDENTITY=
|
||||
BOM_COSTING_EXPECTED_SOURCE_DATABASE_IDENTITY=
|
||||
BOM_COSTING_REFERENCE_PAGE_SIZE=100
|
||||
BOM_COSTING_REFERENCE_PAGE_DELAY_MS=250
|
||||
BOM_COSTING_REFERENCE_MAX_AGE_DAYS=2
|
||||
EOF
|
||||
echo "Created env template: $SYNC_ENV"
|
||||
fi
|
||||
|
||||
chmod 600 "$SYNC_ENV"
|
||||
|
||||
required_values=(
|
||||
BOM_COSTING_EXPECTED_DATABASE_IDENTITY
|
||||
BOM_COSTING_EXPECTED_SOURCE_DATABASE_IDENTITY
|
||||
)
|
||||
for name in "${required_values[@]}"; do
|
||||
if ! grep -Eq "^${name}=.+$" "$SYNC_ENV"; then
|
||||
echo "Missing $name in $SYNC_ENV; timer was not installed." >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
required_app_values=(
|
||||
DATABASE_URL
|
||||
JST_PLANNING_SQLSERVER_HOST
|
||||
JST_PLANNING_SQLSERVER_DATABASE
|
||||
JST_PLANNING_SQLSERVER_USERNAME
|
||||
JST_PLANNING_SQLSERVER_PASSWORD
|
||||
)
|
||||
for name in "${required_app_values[@]}"; do
|
||||
if ! grep -Eq "^${name}=.+$" "$APP_ENV"; then
|
||||
echo "Missing $name in $APP_ENV; timer was not installed." >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
if ! grep -Eq "^JST_PLANNING_SQLSERVER_USERNAME=(report_reader|\"report_reader\"|'report_reader')$" "$APP_ENV"; then
|
||||
echo "JST_PLANNING_SQLSERVER_USERNAME must be report_reader; timer was not installed." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
systemd-analyze calendar "$SYNC_CALENDAR" >/dev/null
|
||||
fi
|
||||
|
||||
cat >"$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Furniture local BOM cost reference mirror sync
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=$APP_ROOT
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=$APP_ENV
|
||||
EnvironmentFile=$SYNC_ENV
|
||||
ExecStart=/bin/bash -lc 'cd "$APP_ROOT" && ./node_modules/.bin/tsx scripts/sync-bom-cost-reference.ts --apply --confirm=sync-bom-cost-reference --expected-database-identity="\$BOM_COSTING_EXPECTED_DATABASE_IDENTITY" --expected-source-database-identity="\$BOM_COSTING_EXPECTED_SOURCE_DATABASE_IDENTITY"'
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
TimeoutStartSec=6h
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
EOF
|
||||
|
||||
cat >"$TIMER_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Run furniture BOM cost reference mirror sync daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=$SYNC_CALENDAR
|
||||
Persistent=true
|
||||
RandomizedDelaySec=$RANDOMIZED_DELAY_SEC
|
||||
Unit=$SERVICE_NAME.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "$SERVICE_NAME.timer"
|
||||
systemctl list-timers --all "$SERVICE_NAME.timer" --no-pager
|
||||
246
furniture-3d-system/scripts/sync-bom-cost-reference.ts
Normal file
246
furniture-3d-system/scripts/sync-bom-cost-reference.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
|
||||
loadEnvConfig(process.cwd())
|
||||
|
||||
export const BOM_REFERENCE_SYNC_CONFIRM = 'sync-bom-cost-reference'
|
||||
|
||||
type CliOptions = {
|
||||
apply: boolean
|
||||
allowLargeRemoval: boolean
|
||||
confirm: string
|
||||
expectedDatabaseIdentity: string
|
||||
expectedSourceDatabaseIdentity: string
|
||||
maxPages: number | undefined
|
||||
maxRetries: number | undefined
|
||||
startAfterId: string
|
||||
resumeBatchId: string
|
||||
help: boolean
|
||||
}
|
||||
|
||||
function parseIntegerOption(argument: string, prefix: string, bounds: { min: number; max: number }) {
|
||||
const value = Number(argument.slice(prefix.length))
|
||||
if (!Number.isSafeInteger(value) || value < bounds.min || value > bounds.max) {
|
||||
throw new Error(`${prefix.slice(0, -1)} must be an integer between ${bounds.min} and ${bounds.max}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
apply: false,
|
||||
allowLargeRemoval: false,
|
||||
confirm: '',
|
||||
expectedDatabaseIdentity: '',
|
||||
expectedSourceDatabaseIdentity: '',
|
||||
maxPages: undefined,
|
||||
maxRetries: undefined,
|
||||
startAfterId: '0',
|
||||
resumeBatchId: '',
|
||||
help: false,
|
||||
}
|
||||
for (const arg of argv) {
|
||||
if (arg === '--apply') options.apply = true
|
||||
else if (arg === '--dry-run') options.apply = false
|
||||
else if (arg === '--allow-large-removal') options.allowLargeRemoval = true
|
||||
else if (arg === '--help' || arg === '-h') options.help = true
|
||||
else if (arg.startsWith('--confirm=')) options.confirm = arg.slice('--confirm='.length)
|
||||
else if (arg.startsWith('--expected-database-identity=')) {
|
||||
options.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length)
|
||||
} else if (arg.startsWith('--expected-source-database-identity=')) {
|
||||
options.expectedSourceDatabaseIdentity = arg.slice('--expected-source-database-identity='.length)
|
||||
} else if (arg.startsWith('--max-pages=')) {
|
||||
options.maxPages = parseIntegerOption(arg, '--max-pages=', { min: 1, max: 10_000 })
|
||||
} else if (arg.startsWith('--max-retries=')) {
|
||||
options.maxRetries = parseIntegerOption(arg, '--max-retries=', { min: 0, max: 10 })
|
||||
} else if (arg.startsWith('--start-after-id=')) {
|
||||
const value = arg.slice('--start-after-id='.length).trim()
|
||||
if (!/^\d+$/.test(value)) throw new Error('--start-after-id must be a non-negative integer')
|
||||
options.startAfterId = BigInt(value).toString()
|
||||
} else if (arg.startsWith('--resume-batch-id=')) {
|
||||
const value = arg.slice('--resume-batch-id='.length).trim()
|
||||
if (!value || value.length > 200 || !/^[A-Za-z0-9_-]+$/.test(value)) {
|
||||
throw new Error('--resume-batch-id is invalid')
|
||||
}
|
||||
options.resumeBatchId = value
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (argv.includes('--apply') && argv.includes('--dry-run')) {
|
||||
throw new Error('Cannot pass --apply and --dry-run together')
|
||||
}
|
||||
if (!options.apply && options.allowLargeRemoval) {
|
||||
throw new Error('--allow-large-removal requires --apply')
|
||||
}
|
||||
if (options.apply && options.maxPages !== undefined) {
|
||||
throw new Error('--max-pages is dry-run only; apply must scan the complete source')
|
||||
}
|
||||
if (options.apply && options.startAfterId !== '0') {
|
||||
throw new Error('--start-after-id is dry-run only; apply must start at zero')
|
||||
}
|
||||
if (!options.apply && options.resumeBatchId) {
|
||||
throw new Error('--resume-batch-id requires --apply')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function databaseIdentityFromUrl(databaseUrl: string) {
|
||||
const parsed = new URL(databaseUrl)
|
||||
return [
|
||||
parsed.protocol.replace(/:$/, ''),
|
||||
decodeURIComponent(parsed.username || ''),
|
||||
parsed.hostname,
|
||||
parsed.port || '',
|
||||
parsed.pathname.replace(/^\//, ''),
|
||||
parsed.searchParams.get('schema') ?? '',
|
||||
].join('|')
|
||||
}
|
||||
|
||||
export function sourceDatabaseIdentity(env: NodeJS.ProcessEnv = process.env) {
|
||||
const host = (
|
||||
env.BOM_COSTING_UPSTREAM_HOST ||
|
||||
env.JST_PLANNING_SQLSERVER_HOST ||
|
||||
env.JST_REPORT_DB_HOST ||
|
||||
''
|
||||
).trim()
|
||||
const database = (
|
||||
env.BOM_COSTING_UPSTREAM_DATABASE ||
|
||||
env.JST_PLANNING_SQLSERVER_DATABASE ||
|
||||
env.JST_REPORT_DB_NAME ||
|
||||
''
|
||||
).trim()
|
||||
const user = (
|
||||
env.BOM_COSTING_UPSTREAM_USER ||
|
||||
env.JST_PLANNING_SQLSERVER_USERNAME ||
|
||||
env.JST_REPORT_DB_USER ||
|
||||
''
|
||||
).trim()
|
||||
if (!host || !database || !user) return ''
|
||||
return [
|
||||
'sqlserver',
|
||||
user,
|
||||
host,
|
||||
(
|
||||
env.BOM_COSTING_UPSTREAM_PORT ||
|
||||
env.JST_PLANNING_SQLSERVER_PORT ||
|
||||
env.JST_REPORT_DB_PORT ||
|
||||
'1433'
|
||||
).trim(),
|
||||
database,
|
||||
(
|
||||
env.BOM_COSTING_UPSTREAM_SCHEMA ||
|
||||
env.JST_PLANNING_SQLSERVER_SCHEMA ||
|
||||
env.JST_REPORT_DB_SCHEMA ||
|
||||
'rpt'
|
||||
).trim(),
|
||||
(env.BOM_COSTING_UPSTREAM_OBJECT || 'pm_bomBaseInfo').trim(),
|
||||
].join('|')
|
||||
}
|
||||
|
||||
export function assertApplyGuards(
|
||||
options: CliOptions,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
if (!options.apply) return
|
||||
if (options.confirm !== BOM_REFERENCE_SYNC_CONFIRM) {
|
||||
throw new Error(
|
||||
`真实写库必须显式传入 --confirm=${BOM_REFERENCE_SYNC_CONFIRM}。`,
|
||||
)
|
||||
}
|
||||
|
||||
const databaseUrl = env.DATABASE_URL?.trim() ?? ''
|
||||
if (!databaseUrl) throw new Error('真实写库必须先配置 DATABASE_URL。')
|
||||
const actualDatabaseIdentity = databaseIdentityFromUrl(databaseUrl)
|
||||
if (!options.expectedDatabaseIdentity) {
|
||||
throw new Error(
|
||||
`真实写库必须显式传入 --expected-database-identity=${actualDatabaseIdentity}。`,
|
||||
)
|
||||
}
|
||||
if (options.expectedDatabaseIdentity !== actualDatabaseIdentity) {
|
||||
throw new Error('目标 PostgreSQL 身份与 DATABASE_URL 不一致,禁止写库。')
|
||||
}
|
||||
|
||||
const actualSourceIdentity = sourceDatabaseIdentity(env)
|
||||
if (!actualSourceIdentity) {
|
||||
throw new Error(
|
||||
'真实写库必须先配置 BOM_COSTING_UPSTREAM_*、' +
|
||||
'JST_PLANNING_SQLSERVER_* 或 JST_REPORT_DB_*。',
|
||||
)
|
||||
}
|
||||
if (!options.expectedSourceDatabaseIdentity) {
|
||||
throw new Error(
|
||||
`真实写库必须显式传入 --expected-source-database-identity=${actualSourceIdentity}。`,
|
||||
)
|
||||
}
|
||||
if (options.expectedSourceDatabaseIdentity !== actualSourceIdentity) {
|
||||
throw new Error('来源 SQL Server 身份与当前上游配置不一致,禁止写库。')
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npm run sync:bom-cost-reference -- --dry-run
|
||||
npm run sync:bom-cost-reference -- --dry-run --max-pages=2 --start-after-id=0
|
||||
npm run sync:bom-cost-reference -- --apply --confirm=${BOM_REFERENCE_SYNC_CONFIRM} --expected-database-identity=... --expected-source-database-identity=...
|
||||
npm run sync:bom-cost-reference -- --apply --resume-batch-id=<failed batch id> --confirm=${BOM_REFERENCE_SYNC_CONFIRM} --expected-database-identity=... --expected-source-database-identity=...
|
||||
|
||||
默认只做低压只读预览。默认每页 100 行,并在完整页之间等待 250ms;可用 BOM_COSTING_REFERENCE_PAGE_SIZE / BOM_COSTING_REFERENCE_PAGE_DELAY_MS 做受限调整,不提供并发。
|
||||
只读预览可用 --max-pages 限定页数、用 --start-after-id 从已记录游标续查;完整页触顶时结果会标记 complete=false。
|
||||
分页读取默认自动重试 2 次,可用 --max-retries=0..10 调整。apply 按页写入不可见新快照,全部成功后切换;失败批次可用 --resume-batch-id 从持久化游标继续,旧成功快照始终可读。
|
||||
真实写库还会校验 SQL Server 登录是否没有 INSERT/UPDATE/DELETE/EXECUTE/DDL 权限。
|
||||
来源配置优先使用 BOM_COSTING_UPSTREAM_*;未设置时优先复用现有
|
||||
JST_PLANNING_SQLSERVER_* 的 report_reader,最后才兼容 JST_REPORT_DB_*:
|
||||
BOM_COSTING_UPSTREAM_HOST / DATABASE / USER / PASSWORD
|
||||
BOM_COSTING_UPSTREAM_SCHEMA=rpt
|
||||
BOM_COSTING_UPSTREAM_OBJECT=pm_bomBaseInfo
|
||||
BOM_COSTING_UPSTREAM_ID_COLUMN=Id
|
||||
`.trim())
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
assertApplyGuards(options)
|
||||
|
||||
const [
|
||||
{ readUpstreamBomReferenceConfig, MssqlUpstreamBomReferenceSource },
|
||||
{ PrismaBomReferenceMirrorStore, PostgresBomReferenceSyncLock },
|
||||
{ UpstreamBomReferenceSyncService },
|
||||
{ prisma },
|
||||
] = await Promise.all([
|
||||
import('../src/server/services/bom-costing/upstream-bom-reference-source'),
|
||||
import('../src/server/services/bom-costing/upstream-bom-reference-store'),
|
||||
import('../src/server/services/bom-costing/upstream-bom-reference-sync.service'),
|
||||
import('../src/lib/prisma'),
|
||||
])
|
||||
|
||||
const source = new MssqlUpstreamBomReferenceSource(readUpstreamBomReferenceConfig())
|
||||
const service = new UpstreamBomReferenceSyncService(
|
||||
source,
|
||||
new PrismaBomReferenceMirrorStore(prisma),
|
||||
new PostgresBomReferenceSyncLock(),
|
||||
)
|
||||
try {
|
||||
const result = await service.sync({
|
||||
apply: options.apply,
|
||||
allowLargeRemoval: options.allowLargeRemoval,
|
||||
maxPages: options.maxPages,
|
||||
maxRetries: options.maxRetries,
|
||||
startAfterId: options.startAfterId,
|
||||
resumeBatchId: options.resumeBatchId,
|
||||
onPage: (progress) => console.log(JSON.stringify({ progress })),
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(`BOM reference sync failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@ -2,6 +2,7 @@ import { 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'
|
||||
import { bomCostingScenarioService } from '@/server/services/bom-costing/bom-costing-scenario.service'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>
|
||||
@ -13,8 +14,14 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
try {
|
||||
const caseDetail = await bomCostingService.getCase(decodeURIComponent(id), ctx.user)
|
||||
return jsonSuccess(
|
||||
{ case: await bomCostingService.getCase(decodeURIComponent(id), ctx.user) },
|
||||
{
|
||||
case: {
|
||||
...caseDetail,
|
||||
dynamicCosting: await bomCostingScenarioService.getCaseDynamicCosting(caseDetail.id),
|
||||
},
|
||||
},
|
||||
200,
|
||||
ctx.requestId,
|
||||
)
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { bomCostingScenarioCalculationSchema } 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'
|
||||
import { bomCostingScenarioService } from '@/server/services/bom-costing/bom-costing-scenario.service'
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
const caseId = decodeURIComponent(id)
|
||||
const skuId = request.nextUrl.searchParams.get('skuId')?.trim() || undefined
|
||||
try {
|
||||
await bomCostingService.getCase(caseId, ctx.user)
|
||||
return jsonSuccess(
|
||||
{ scenarios: await bomCostingScenarioService.listScenarios({ caseId, skuId }) },
|
||||
200,
|
||||
ctx.requestId,
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
const caseId = decodeURIComponent(id)
|
||||
const body = await request.json().catch(() => null)
|
||||
const parsed = bomCostingScenarioCalculationSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_FAILED, '目标材质参数不正确', 400, ctx.requestId)
|
||||
}
|
||||
try {
|
||||
await bomCostingService.getCase(caseId, ctx.user)
|
||||
const result = await bomCostingScenarioService.calculate({ caseId, ...parsed.data })
|
||||
return jsonSuccess(result, 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,40 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { AppError } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { bomCostingAccessService } from '@/server/services/bom-costing/bom-costing-access.service'
|
||||
import { bomCostingScenarioService } from '@/server/services/bom-costing/bom-costing-scenario.service'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
try {
|
||||
bomCostingAccessService.assertCanView(ctx.user)
|
||||
const [materials, sync] = await Promise.all([
|
||||
bomCostingScenarioService.listMaterials(),
|
||||
bomCostingScenarioService.getSyncFreshness(),
|
||||
])
|
||||
return jsonSuccess(
|
||||
{
|
||||
materials: materials.map((material) => ({
|
||||
id: material.id,
|
||||
code: material.code,
|
||||
name: material.name,
|
||||
category: material.category,
|
||||
enabled: material.enabled,
|
||||
})),
|
||||
sync,
|
||||
},
|
||||
200,
|
||||
ctx.requestId,
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import BomCostingWorkbenchClient from './BomCostingWorkbenchClient'
|
||||
|
||||
jest.mock('@/components/platform/AssetWorkbenchEmptyBackdrop', () => ({
|
||||
@ -6,6 +6,68 @@ jest.mock('@/components/platform/AssetWorkbenchEmptyBackdrop', () => ({
|
||||
}))
|
||||
|
||||
describe('BomCostingWorkbenchClient', () => {
|
||||
it('详情读取超时后结束加载并提供重试入口', async () => {
|
||||
const originalFetch = global.fetch
|
||||
jest.useFakeTimers()
|
||||
global.fetch = jest.fn((_input: RequestInfo | URL, init?: RequestInit) => (
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
const error = new Error('aborted')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
)) as jest.Mock
|
||||
|
||||
try {
|
||||
render(
|
||||
<BomCostingWorkbenchClient
|
||||
initialCapabilities={{
|
||||
enabled: true,
|
||||
analysisConfigured: true,
|
||||
modelingConfigured: false,
|
||||
analysisProviderCode: 'fixture',
|
||||
targetErrorPercent: 10,
|
||||
fixtureAllowed: true,
|
||||
}}
|
||||
initialCases={[{
|
||||
id: 'case-timeout',
|
||||
title: '读取超时案例',
|
||||
sourceUrl: 'https://example.com/product/timeout',
|
||||
sourceHost: 'example.com',
|
||||
status: 'READY',
|
||||
progress: 100,
|
||||
statusMessage: '已完成',
|
||||
sourceCacheHit: true,
|
||||
storedImageCount: 1,
|
||||
failedImageCount: 0,
|
||||
createdById: 'user-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
skuCount: 1,
|
||||
estimatedCostRange: null,
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在读取核算记录')
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(15_000)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const loadError = screen.getByRole('alert')
|
||||
expect(loadError).toHaveTextContent('核算记录暂未读到')
|
||||
expect(
|
||||
within(loadError).getByText('核算记录读取超时,请重试;后台任务不会受影响'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新读取' })).toBeEnabled()
|
||||
} finally {
|
||||
jest.useRealTimers()
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('生产服务未接入且没有案例时只展示紧凑入口', () => {
|
||||
render(
|
||||
<BomCostingWorkbenchClient
|
||||
@ -738,8 +800,11 @@ describe('BomCostingWorkbenchClient', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('历史记录')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('搜索历史记录')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('筛选历史状态')).toBeInTheDocument()
|
||||
const historySearch = screen.getByLabelText('搜索历史记录')
|
||||
const historyFilter = screen.getByLabelText('筛选历史状态')
|
||||
expect(historySearch).toBeInTheDocument()
|
||||
expect(historyFilter).toBeInTheDocument()
|
||||
expect(historySearch.closest('.mb-2')).not.toHaveClass('hidden')
|
||||
expect(
|
||||
await screen.findByText('多模态分析服务暂时不可用'),
|
||||
).toBeInTheDocument()
|
||||
@ -999,6 +1064,9 @@ describe('BomCostingWorkbenchClient', () => {
|
||||
)
|
||||
|
||||
expect(await screen.findByText('桌面板')).toBeInTheDocument()
|
||||
const bomDetailTable = screen.getByRole('table', { name: 'BOM 核算明细' })
|
||||
expect(bomDetailTable).toHaveClass('block', 'md:table')
|
||||
expect(screen.getByText('桌面板').closest('tr')).toHaveClass('grid', 'md:table-row')
|
||||
expect(screen.getByText('主图可见桌面板尺寸与材质')).toBeInTheDocument()
|
||||
expect(screen.getByText('预计出厂成本')).toBeInTheDocument()
|
||||
expect(screen.getByText('选择 SKU')).toBeInTheDocument()
|
||||
@ -1283,13 +1351,22 @@ describe('BomCostingWorkbenchClient', () => {
|
||||
'只重新汇总结构、BOM 与资料库价格,复用已保存的逐图结果。',
|
||||
)
|
||||
expect(reuseFactsExplanation.closest('[role="tooltip"]')).toHaveClass('sr-only')
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看按现有事实重算说明' }))
|
||||
expect(reuseFactsExplanation.closest('[role="tooltip"]')).not.toHaveClass('sr-only')
|
||||
expect(
|
||||
screen.getByText('不读取竞品资料 · 不调用图片模型 · 仅汇总实际 SKU'),
|
||||
screen.getByText('重看 1 张图片'),
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '查看按现有事实重算说明' }),
|
||||
screen.getByRole('button', { name: '全部重新分析 重新读取 · 2 张图片' }),
|
||||
)
|
||||
expect(reuseFactsExplanation.closest('[role="tooltip"]')).not.toHaveClass('sr-only')
|
||||
expect(
|
||||
screen.getByText('重新读取 · 2 张图片'),
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始重新核算' }))
|
||||
expect(screen.getByText('确认全部重新分析')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '确认并开始' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回检查' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '仅重试失败图片' }))
|
||||
expect(screen.getByText('已选 1 张')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByPlaceholderText(/重点核对折叠机构/), {
|
||||
target: { value: '重点核对折叠机构和导轨' },
|
||||
@ -1511,6 +1588,9 @@ describe('BomCostingWorkbenchClient', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '记录橡胶木桌面板人工复核值' }),
|
||||
)
|
||||
expect(screen.getByText('材质与规格')).toBeInTheDocument()
|
||||
expect(screen.getByText('部件尺寸')).toBeInTheDocument()
|
||||
expect(screen.getByText('计价参数')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('材质名称'), {
|
||||
target: { value: '橡胶木实木板' },
|
||||
})
|
||||
@ -1575,4 +1655,383 @@ describe('BomCostingWorkbenchClient', () => {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('动态成本优先展示,并提交目标材质生成双场景且保留超售价金额', async () => {
|
||||
const originalFetch = global.fetch
|
||||
const now = new Date().toISOString()
|
||||
const scenario = (input: {
|
||||
id: string
|
||||
kind: 'SAME_MATERIAL' | 'MATERIAL_SUBSTITUTION'
|
||||
targetMaterialId: string | null
|
||||
targetMaterialName: string
|
||||
status: 'FINAL' | 'PROVISIONAL' | 'INSUFFICIENT_DATA' | 'STALE'
|
||||
totalCost: number | null
|
||||
knownCost: number
|
||||
unresolvedCount: number
|
||||
competitorPrice: number
|
||||
priceDelta: number | null
|
||||
unitPrice: number | null
|
||||
subtotal: number | null
|
||||
secondarySubtotal?: number
|
||||
}) => ({
|
||||
id: input.id,
|
||||
skuId: 'sku-dynamic',
|
||||
kind: input.kind,
|
||||
targetMaterialId: input.targetMaterialId,
|
||||
targetMaterialName: input.targetMaterialName,
|
||||
revision: 1,
|
||||
isCurrent: true,
|
||||
status: input.status,
|
||||
totalCost: input.totalCost,
|
||||
knownCost: input.knownCost,
|
||||
materialCost: input.totalCost === null ? null : input.totalCost - 30,
|
||||
processingCost: 10,
|
||||
laborCost: 8,
|
||||
packagingCost: 7,
|
||||
lossCost: 5,
|
||||
competitorPrice: input.competitorPrice,
|
||||
priceDelta: input.priceDelta,
|
||||
confidence: input.status === 'INSUFFICIENT_DATA' ? 0.62 : 0.82,
|
||||
warning: input.priceDelta !== null && input.priceDelta > 0
|
||||
? '成本高于竞品售价,需复核主要驱动项'
|
||||
: '',
|
||||
unresolvedCount: input.unresolvedCount,
|
||||
dataAsOf: now,
|
||||
syncRunId: 'run-success',
|
||||
syncStatus: 'SUCCEEDED',
|
||||
syncCompletedAt: now,
|
||||
rulesetVersion: 'dynamic-bom-cost/v1',
|
||||
createdAt: now,
|
||||
lines: [{
|
||||
id: `${input.id}-line`,
|
||||
sourceLineId: 'line-board',
|
||||
sourceRowId: 'reference-board',
|
||||
category: 'BOARD',
|
||||
partName: '桌面板',
|
||||
originalMaterial: '黑胡桃木',
|
||||
appliedMaterial: input.kind === 'MATERIAL_SUBSTITUTION' ? '橡胶木' : '黑胡桃木',
|
||||
materialSpec: '20 mm',
|
||||
unit: 'm2',
|
||||
quantity: 0.5,
|
||||
coefficient: 1.1,
|
||||
pricingQuantity: 0.5,
|
||||
unitPrice: input.unitPrice,
|
||||
subtotal: input.subtotal,
|
||||
priceSource: input.unitPrice === null ? '' : '公司历史 BOM 中位单价',
|
||||
sourceSku: 'REF-BOARD',
|
||||
referenceSkus: input.unitPrice === null ? [] : ['REF-BOARD'],
|
||||
referenceRowIds: input.unitPrice === null ? [] : ['reference-board'],
|
||||
sourceFingerprint: input.unitPrice === null ? '' : 'fingerprint-board',
|
||||
sourceCreatedAt: now,
|
||||
sourceSyncedAt: now,
|
||||
calculationRule: 'reference-weighted-median-unit-price/v1',
|
||||
confidence: input.status === 'INSUFFICIENT_DATA' ? null : 0.82,
|
||||
status: input.status === 'PROVISIONAL' ? 'FINAL' : input.status,
|
||||
warning: input.unitPrice === null ? '没有公司价格来源' : '',
|
||||
rawSnapshot: [],
|
||||
}, ...(input.secondarySubtotal === undefined ? [] : [{
|
||||
id: `${input.id}-secondary-line`,
|
||||
sourceLineId: 'line-processing',
|
||||
sourceRowId: 'reference-processing',
|
||||
category: 'PROCESSING',
|
||||
partName: '表面处理',
|
||||
originalMaterial: '木蜡油',
|
||||
appliedMaterial: '木蜡油',
|
||||
materialSpec: '',
|
||||
unit: '套',
|
||||
quantity: 1,
|
||||
coefficient: 1,
|
||||
pricingQuantity: 1,
|
||||
unitPrice: input.secondarySubtotal,
|
||||
subtotal: input.secondarySubtotal,
|
||||
priceSource: '公司历史 BOM 单价',
|
||||
sourceSku: 'REF-PROCESSING',
|
||||
referenceSkus: ['REF-PROCESSING'],
|
||||
referenceRowIds: ['reference-processing'],
|
||||
sourceFingerprint: 'fingerprint-processing',
|
||||
sourceCreatedAt: now,
|
||||
sourceSyncedAt: now,
|
||||
calculationRule: 'reference-weighted-median-unit-price/v1',
|
||||
confidence: 0.82,
|
||||
status: 'FINAL' as const,
|
||||
warning: '',
|
||||
rawSnapshot: [],
|
||||
}])],
|
||||
})
|
||||
const sync = {
|
||||
state: 'READY' as const,
|
||||
fresh: true,
|
||||
dataAsOf: now,
|
||||
sourceAsOf: now,
|
||||
lastSuccessfulAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
lastSuccessfulBatchId: 'run-success',
|
||||
lastFailedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastFailedMessage: '上游连接超时',
|
||||
maxAgeDays: 2,
|
||||
}
|
||||
const initialDynamicCosting = {
|
||||
materials: [{
|
||||
id: 'material-rubberwood',
|
||||
code: 'RUBBERWOOD',
|
||||
name: '橡胶木',
|
||||
category: 'BOARD',
|
||||
aliases: ['rubberwood'],
|
||||
}],
|
||||
scenarios: [
|
||||
scenario({
|
||||
id: 'scenario-same-incomplete',
|
||||
kind: 'SAME_MATERIAL',
|
||||
targetMaterialId: null,
|
||||
targetMaterialName: '',
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
totalCost: null,
|
||||
knownCost: 500,
|
||||
unresolvedCount: 1,
|
||||
competitorPrice: 600,
|
||||
priceDelta: null,
|
||||
unitPrice: null,
|
||||
subtotal: null,
|
||||
}),
|
||||
scenario({
|
||||
id: 'scenario-substitution-old',
|
||||
kind: 'MATERIAL_SUBSTITUTION',
|
||||
targetMaterialId: 'material-rubberwood',
|
||||
targetMaterialName: '橡胶木',
|
||||
status: 'FINAL',
|
||||
totalCost: 550,
|
||||
knownCost: 550,
|
||||
unresolvedCount: 0,
|
||||
competitorPrice: 600,
|
||||
priceDelta: -50,
|
||||
unitPrice: 80,
|
||||
subtotal: 44,
|
||||
}),
|
||||
],
|
||||
sync,
|
||||
}
|
||||
const updatedDynamicCosting = {
|
||||
...initialDynamicCosting,
|
||||
scenarios: [
|
||||
scenario({
|
||||
id: 'scenario-same-updated',
|
||||
kind: 'SAME_MATERIAL',
|
||||
targetMaterialId: null,
|
||||
targetMaterialName: '',
|
||||
status: 'PROVISIONAL',
|
||||
totalCost: 1_350,
|
||||
knownCost: 1_350,
|
||||
unresolvedCount: 0,
|
||||
competitorPrice: 600,
|
||||
priceDelta: 750,
|
||||
unitPrice: 1_600,
|
||||
subtotal: 800,
|
||||
secondarySubtotal: 550,
|
||||
}),
|
||||
scenario({
|
||||
id: 'scenario-substitution-updated',
|
||||
kind: 'MATERIAL_SUBSTITUTION',
|
||||
targetMaterialId: 'material-rubberwood',
|
||||
targetMaterialName: '橡胶木',
|
||||
status: 'PROVISIONAL',
|
||||
totalCost: 1_300,
|
||||
knownCost: 1_300,
|
||||
unresolvedCount: 0,
|
||||
competitorPrice: 600,
|
||||
priceDelta: 700,
|
||||
unitPrice: 1_600,
|
||||
subtotal: 800,
|
||||
secondarySubtotal: 500,
|
||||
}),
|
||||
],
|
||||
}
|
||||
const detail = {
|
||||
id: 'case-dynamic',
|
||||
sourceUrl: 'https://item.example.com/item?id=dynamic',
|
||||
sourceHost: 'item.example.com',
|
||||
title: '竞品实木书桌',
|
||||
providerCode: 'market-detail-multimodal',
|
||||
providerTaskId: 'task-dynamic',
|
||||
sourceCacheHit: true,
|
||||
status: 'READY',
|
||||
progress: 100,
|
||||
statusMessage: '核算完成',
|
||||
targetErrorPercent: 10,
|
||||
currency: 'CNY',
|
||||
createdById: 'user-1',
|
||||
processingStartedAt: now,
|
||||
processingFinishedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sourceSnapshot: {
|
||||
id: 'snapshot-dynamic',
|
||||
status: 'READY',
|
||||
channel: 'taobao',
|
||||
externalItemId: 'dynamic',
|
||||
title: '竞品实木书桌',
|
||||
shopName: '竞品店铺',
|
||||
skuCount: 1,
|
||||
imageCount: 0,
|
||||
storedImageCount: 0,
|
||||
failedImageCount: 0,
|
||||
errorMessage: '',
|
||||
fetchedAt: now,
|
||||
completedAt: now,
|
||||
assets: [],
|
||||
},
|
||||
skus: [{
|
||||
id: 'sku-dynamic',
|
||||
externalSkuId: 'COMPETITOR-SKU-DYNAMIC',
|
||||
name: '黑胡桃色 120cm',
|
||||
structureGroupKey: 'desk',
|
||||
imageUrl: null,
|
||||
attributes: { marketPriceRaw: '600' },
|
||||
lengthMm: 1_200,
|
||||
widthMm: 600,
|
||||
heightMm: 750,
|
||||
weightKg: null,
|
||||
packageLengthMm: null,
|
||||
packageWidthMm: null,
|
||||
packageHeightMm: null,
|
||||
packageWeightKg: null,
|
||||
estimatedCost: 1_300,
|
||||
costCalculatedAt: now,
|
||||
costSummary: {
|
||||
material: 1_000,
|
||||
processing: 100,
|
||||
labor: 100,
|
||||
packaging: 100,
|
||||
lossIncludedTotal: 1_300,
|
||||
unresolvedLineCount: 0,
|
||||
missingCostGroups: [],
|
||||
},
|
||||
bomLines: [],
|
||||
}],
|
||||
reviewInputs: [],
|
||||
analysisRuns: [],
|
||||
taskEvents: [],
|
||||
dynamicCosting: initialDynamicCosting,
|
||||
}
|
||||
global.fetch = jest.fn(async (_url, init) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: init?.method === 'POST'
|
||||
? { dynamicCosting: updatedDynamicCosting }
|
||||
: { case: detail },
|
||||
}),
|
||||
})) as jest.Mock
|
||||
|
||||
try {
|
||||
render(
|
||||
<BomCostingWorkbenchClient
|
||||
initialCapabilities={{
|
||||
enabled: true,
|
||||
analysisConfigured: true,
|
||||
modelingConfigured: false,
|
||||
analysisProviderCode: 'market-detail-multimodal',
|
||||
targetErrorPercent: 10,
|
||||
fixtureAllowed: false,
|
||||
}}
|
||||
initialCases={[{
|
||||
id: detail.id,
|
||||
title: detail.title,
|
||||
sourceUrl: detail.sourceUrl,
|
||||
sourceHost: detail.sourceHost,
|
||||
status: 'READY',
|
||||
progress: 100,
|
||||
statusMessage: detail.statusMessage,
|
||||
sourceCacheHit: true,
|
||||
storedImageCount: 0,
|
||||
failedImageCount: 0,
|
||||
createdById: 'user-1',
|
||||
createdAt: now,
|
||||
skuCount: 1,
|
||||
estimatedCostRange: { min: 1_300, max: 1_300 },
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('已知部分 ¥500.00')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('数据不足').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('1 行没有可验证价格来源')).toBeInTheDocument()
|
||||
expect(screen.getByText(/最近同步未成功:上游连接超时/)).toBeInTheDocument()
|
||||
const skuButton = screen.getByRole('button', { name: /黑胡桃色 120cm/ })
|
||||
expect(within(skuButton).getByText('已知 ¥500.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('旧规则留档:¥1,300.00')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('选择替换目标材质')).toHaveValue(
|
||||
'material-rubberwood',
|
||||
)
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '替换为 橡胶木' }))
|
||||
expect(screen.getAllByText('¥550.00').length).toBeGreaterThan(0)
|
||||
|
||||
const calculateButton = screen.getByRole('button', { name: '生成替换方案' })
|
||||
await waitFor(() => expect(calculateButton).toBeEnabled())
|
||||
fireEvent.click(calculateButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const postCall = (global.fetch as jest.Mock).mock.calls.find(
|
||||
([url, init]) => String(url).endsWith('/cases/case-dynamic/scenarios') && init?.method === 'POST',
|
||||
)
|
||||
expect(postCall).toBeDefined()
|
||||
expect(JSON.parse(postCall?.[1]?.body)).toEqual({
|
||||
skuId: 'sku-dynamic',
|
||||
targetMaterialId: 'material-rubberwood',
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText('公司成本 · 橡胶木')).toBeInTheDocument()
|
||||
const dynamicCostingHeading = screen.getByRole('heading', {
|
||||
name: '公司动态成本',
|
||||
level: 2,
|
||||
})
|
||||
const productHeading = screen.getByRole('heading', {
|
||||
name: detail.title,
|
||||
level: 2,
|
||||
})
|
||||
const gallery = document.getElementById('bom-product-gallery')
|
||||
expect(dynamicCostingHeading.closest('section')).toHaveClass(
|
||||
'order-2',
|
||||
'xl:order-none',
|
||||
)
|
||||
expect(productHeading.closest('aside')).toHaveClass(
|
||||
'order-3',
|
||||
'xl:order-none',
|
||||
)
|
||||
expect(gallery).toHaveClass('min-h-0')
|
||||
expect(gallery).not.toHaveClass('min-h-[520px]')
|
||||
expect(gallery?.firstElementChild).toHaveClass(
|
||||
'min-h-[148px]',
|
||||
'sm:min-h-[180px]',
|
||||
)
|
||||
expect(screen.queryByText('暂无图片')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '选择 SKU', level: 3 })).toBeInTheDocument()
|
||||
expect(screen.getAllByText('¥1,300.00').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('暂估 / 待复核').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/成本高于竞品售价,金额已保留/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('¥1,270.00').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('公司历史 BOM 中位单价').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/参考 SKU:REF-BOARD/).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('成本贡献明细')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('最大贡献').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/占(?:已知成本 )?62%/).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('较同材质').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('节省 ¥50.00').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('-¥50.00')).not.toBeInTheDocument()
|
||||
|
||||
const costSummaryAside = screen.getByText('公司成本 · 橡胶木').closest('aside')
|
||||
expect(costSummaryAside).not.toBeNull()
|
||||
expect(within(costSummaryAside as HTMLElement).getByText('竞品售价 ¥600.00')).toBeInTheDocument()
|
||||
expect(within(costSummaryAside as HTMLElement).getByText(/高于竞品售价 ¥700.00/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '同材质基准' }))
|
||||
expect(screen.queryByText('较同材质')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('替换后预计')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('节省 ¥50.00')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('有价格来源').length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
@ -7,10 +8,12 @@ import {
|
||||
ClipboardCheck,
|
||||
Home,
|
||||
Layers,
|
||||
MoreHorizontal,
|
||||
PackageOpen,
|
||||
Sparkles,
|
||||
Video,
|
||||
Warehouse,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
canAccessWorkspacePath,
|
||||
@ -44,35 +47,96 @@ export function MobileTabBar({ user }: MobileTabBarProps) {
|
||||
const visibleTabItems = user === undefined
|
||||
? MOBILE_TAB_ITEMS
|
||||
: MOBILE_TAB_ITEMS.filter((item) => canAccessWorkspacePath(item.href, user))
|
||||
const primaryTabItems = visibleTabItems.slice(0, 4)
|
||||
const overflowTabItems = visibleTabItems.slice(4)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const moreActive = overflowTabItems.some((item) => matchesHref(pathname, item.href))
|
||||
|
||||
const navigate = (href: string) => {
|
||||
setMoreOpen(false)
|
||||
router.push(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-50 lg:hidden flex items-end justify-around px-2"
|
||||
style={{
|
||||
height: '72px',
|
||||
backgroundColor: 'rgba(248, 249, 250, 0.60)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))',
|
||||
paddingTop: '12px',
|
||||
}}
|
||||
role="navigation"
|
||||
aria-label="主导航"
|
||||
>
|
||||
{visibleTabItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = matchesHref(pathname, item.href)
|
||||
<>
|
||||
{moreOpen && overflowTabItems.length > 0 ? (
|
||||
<div className="fixed inset-0 z-40 lg:hidden" data-testid="mobile-more-menu">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭更多导航"
|
||||
className="absolute inset-0 bg-black/20"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
/>
|
||||
<section
|
||||
id="mobile-more-navigation"
|
||||
aria-label="更多工作台"
|
||||
className="absolute bottom-[76px] left-3 right-3 max-h-[calc(100dvh-96px)] overflow-y-auto rounded-[8px] border border-black/[0.08] bg-white p-3 shadow-[0_14px_36px_rgba(32,39,41,0.18)]"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 px-1">
|
||||
<h2 className="text-[13px] font-bold text-[#2d3335]">更多工作台</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭更多导航"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-[6px] text-[#586064] outline-none hover:bg-[#eef1f1] focus-visible:ring-2 focus-visible:ring-[#40585d]"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{overflowTabItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = matchesHref(pathname, item.href)
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
navigate(item.href)
|
||||
}}
|
||||
className={active
|
||||
? 'flex min-h-12 min-w-0 items-center gap-2 rounded-[6px] bg-[#e8eded] px-3 py-2 text-[12px] font-bold text-[#2d3335] outline-none focus-visible:ring-2 focus-visible:ring-[#40585d]'
|
||||
: 'flex min-h-12 min-w-0 items-center gap-2 rounded-[6px] px-3 py-2 text-[12px] font-semibold text-[#586064] outline-none hover:bg-[#f2f4f4] focus-visible:ring-2 focus-visible:ring-[#40585d]'}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<Icon size={18} className="shrink-0" />
|
||||
<span className="min-w-0 break-words leading-4">{item.label}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-50 flex items-end justify-around px-2 lg:hidden"
|
||||
style={{
|
||||
height: '72px',
|
||||
backgroundColor: 'rgba(248, 249, 250, 0.88)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))',
|
||||
paddingTop: '12px',
|
||||
}}
|
||||
role="navigation"
|
||||
aria-label="主导航"
|
||||
>
|
||||
{primaryTabItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = matchesHref(pathname, item.href)
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
router.push(item.href)
|
||||
navigate(item.href)
|
||||
}}
|
||||
className="flex flex-1 flex-col items-center justify-center gap-1 active:scale-90 transition-transform duration-200"
|
||||
className="flex min-w-0 flex-1 flex-col items-center justify-center gap-1 transition-transform duration-200 active:scale-90"
|
||||
style={{
|
||||
color: '#2D3335',
|
||||
fontWeight: 700,
|
||||
@ -90,41 +154,65 @@ export function MobileTabBar({ user }: MobileTabBarProps) {
|
||||
<Icon size={20} strokeWidth={2.2} />
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] leading-none font-medium antialiased"
|
||||
className="max-w-full truncate text-[10px] font-medium leading-none antialiased"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
router.push(item.href)
|
||||
}}
|
||||
className="flex flex-1 flex-col items-center justify-center gap-1 hover:opacity-100 active:scale-90 transition duration-200"
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(item.href)
|
||||
}}
|
||||
className="flex min-w-0 flex-1 flex-col items-center justify-center gap-1 transition duration-200 hover:opacity-100 active:scale-90"
|
||||
style={{
|
||||
color: '#586064',
|
||||
opacity: 0.72,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.5} />
|
||||
<span
|
||||
className="max-w-full truncate text-[10px] font-normal leading-none antialiased"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
{overflowTabItems.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen((open) => !open)}
|
||||
aria-expanded={moreOpen}
|
||||
aria-controls="mobile-more-navigation"
|
||||
className="flex min-w-0 flex-1 flex-col items-center justify-center gap-1 outline-none transition duration-200 active:scale-90 focus-visible:ring-2 focus-visible:ring-[#40585d]"
|
||||
style={{
|
||||
color: '#586064',
|
||||
opacity: 0.6,
|
||||
textDecoration: 'none',
|
||||
color: moreActive || moreOpen ? '#2D3335' : '#586064',
|
||||
fontWeight: moreActive || moreOpen ? 700 : 400,
|
||||
opacity: moreActive || moreOpen ? 1 : 0.72,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.5} />
|
||||
<span
|
||||
className="text-[10px] leading-none font-normal antialiased"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
className={moreActive || moreOpen
|
||||
? 'flex items-center justify-center rounded-full bg-black/[0.08] px-4 py-1'
|
||||
: 'flex items-center justify-center py-1'}
|
||||
>
|
||||
{item.label}
|
||||
<MoreHorizontal size={20} strokeWidth={moreActive || moreOpen ? 2.2 : 1.5} />
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<span className="text-[10px] leading-none">更多</span>
|
||||
</button>
|
||||
) : null}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MobileTabBar } from '../MobileTabBar'
|
||||
|
||||
const mockPush = jest.fn()
|
||||
let mockPathname = '/'
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
describe('MobileTabBar', () => {
|
||||
beforeEach(() => {
|
||||
mockPathname = '/'
|
||||
mockPush.mockReset()
|
||||
})
|
||||
|
||||
it('主导航保持四个高频入口,其余入口收纳到可操作的更多菜单', () => {
|
||||
render(<MobileTabBar />)
|
||||
|
||||
const navigation = screen.getByRole('navigation', { name: '主导航' })
|
||||
expect(within(navigation).getAllByRole('link')).toHaveLength(4)
|
||||
expect(within(navigation).getByRole('button', { name: '更多' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: '仓库管理' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(navigation).getByRole('button', { name: '更多' }))
|
||||
|
||||
const menu = screen.getByTestId('mobile-more-menu')
|
||||
expect(within(menu).getByRole('link', { name: '产品资料' })).toBeInTheDocument()
|
||||
fireEvent.click(within(menu).getByRole('link', { name: '仓库管理' }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/warehouse')
|
||||
expect(screen.queryByTestId('mobile-more-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,61 @@
|
||||
import {
|
||||
BOARD_SHEET_CUTTING_RULESET_VERSION,
|
||||
calculateBoardSheetCuttingPlan,
|
||||
} from './board-sheet-cutting'
|
||||
|
||||
describe('板材整板裁切成本', () => {
|
||||
const sheet = {
|
||||
substrateId: 'substrate-rubberwood-18',
|
||||
materialName: '橡胶木',
|
||||
sheetLengthMm: 2_440,
|
||||
sheetWidthMm: 1_220,
|
||||
fullSheetPrice: 200,
|
||||
kerfMm: 4,
|
||||
edgeMarginMm: 0,
|
||||
}
|
||||
|
||||
it('多块板件共享同一张整板,并把整板成本分摊回明细', () => {
|
||||
const result = calculateBoardSheetCuttingPlan(sheet, [
|
||||
{ lineId: 'shelf', lengthMm: 600, widthMm: 600, quantity: 2 },
|
||||
{ lineId: 'side', lengthMm: 600, widthMm: 600, quantity: 2 },
|
||||
])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
rulesetVersion: BOARD_SHEET_CUTTING_RULESET_VERSION,
|
||||
sheetCount: 1,
|
||||
totalSheetCost: 200,
|
||||
usedAreaSqm: 1.44,
|
||||
})
|
||||
expect(result?.allocations).toEqual([
|
||||
{ lineId: 'shelf', pieceCount: 2, partAreaSqm: 0.72, allocatedCost: 100 },
|
||||
{ lineId: 'side', pieceCount: 2, partAreaSqm: 0.72, allocatedCost: 100 },
|
||||
])
|
||||
})
|
||||
|
||||
it('无法放入第一张整板时增加整板数量,而不是按零散平方米收费', () => {
|
||||
const result = calculateBoardSheetCuttingPlan(sheet, [
|
||||
{ lineId: 'panel', lengthMm: 1_200, widthMm: 1_200, quantity: 3 },
|
||||
])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
sheetCount: 2,
|
||||
totalSheetCost: 400,
|
||||
})
|
||||
expect(result?.allocations[0]?.allocatedCost).toBe(400)
|
||||
})
|
||||
|
||||
it('板件超出整板尺寸时拒绝生成虚假的裁切成本', () => {
|
||||
expect(calculateBoardSheetCuttingPlan(sheet, [
|
||||
{ lineId: 'oversize', lengthMm: 2_500, widthMm: 1_000, quantity: 1 },
|
||||
])).toBeNull()
|
||||
})
|
||||
|
||||
it('板件数量不是整数时按实际采购整块向上取整并记录说明', () => {
|
||||
const result = calculateBoardSheetCuttingPlan(sheet, [
|
||||
{ lineId: 'half-piece', lengthMm: 600, widthMm: 600, quantity: 1.2 },
|
||||
])
|
||||
|
||||
expect(result?.allocations[0]?.pieceCount).toBe(2)
|
||||
expect(result?.warnings[0]).toContain('向上取整')
|
||||
})
|
||||
})
|
||||
317
furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.ts
Normal file
317
furniture-3d-system/src/lib/bom-costing/board-sheet-cutting.ts
Normal file
@ -0,0 +1,317 @@
|
||||
export const BOARD_SHEET_CUTTING_RULESET_VERSION =
|
||||
'board-sheet-maxrects/v1' as const
|
||||
|
||||
export interface BoardCuttingPartInput {
|
||||
lineId: string
|
||||
lengthMm: number
|
||||
widthMm: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface BoardSheetInput {
|
||||
substrateId: string
|
||||
materialName: string
|
||||
sheetLengthMm: number
|
||||
sheetWidthMm: number
|
||||
fullSheetPrice: number
|
||||
kerfMm?: number
|
||||
edgeMarginMm?: number
|
||||
}
|
||||
|
||||
export interface BoardCuttingAllocation {
|
||||
lineId: string
|
||||
pieceCount: number
|
||||
partAreaSqm: number
|
||||
allocatedCost: number
|
||||
}
|
||||
|
||||
export interface BoardSheetCuttingPlan {
|
||||
rulesetVersion: typeof BOARD_SHEET_CUTTING_RULESET_VERSION
|
||||
substrateId: string
|
||||
materialName: string
|
||||
sheetLengthMm: number
|
||||
sheetWidthMm: number
|
||||
fullSheetPrice: number
|
||||
kerfMm: number
|
||||
edgeMarginMm: number
|
||||
sheetCount: number
|
||||
totalSheetCost: number
|
||||
usedAreaSqm: number
|
||||
purchasedAreaSqm: number
|
||||
utilization: number
|
||||
allocations: BoardCuttingAllocation[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
type Rect = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type Piece = {
|
||||
lineId: string
|
||||
width: number
|
||||
height: number
|
||||
physicalArea: number
|
||||
}
|
||||
|
||||
type Sheet = {
|
||||
freeRects: Rect[]
|
||||
}
|
||||
|
||||
function positiveNumber(value: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
function round(value: number, digits: number) {
|
||||
const factor = 10 ** digits
|
||||
return Math.round((value + Number.EPSILON) * factor) / factor
|
||||
}
|
||||
|
||||
function intersects(left: Rect, right: Rect) {
|
||||
return !(
|
||||
right.x >= left.x + left.width ||
|
||||
right.x + right.width <= left.x ||
|
||||
right.y >= left.y + left.height ||
|
||||
right.y + right.height <= left.y
|
||||
)
|
||||
}
|
||||
|
||||
function contains(outer: Rect, inner: Rect) {
|
||||
return (
|
||||
inner.x >= outer.x &&
|
||||
inner.y >= outer.y &&
|
||||
inner.x + inner.width <= outer.x + outer.width &&
|
||||
inner.y + inner.height <= outer.y + outer.height
|
||||
)
|
||||
}
|
||||
|
||||
function splitFreeRect(free: Rect, used: Rect) {
|
||||
if (!intersects(free, used)) return [free]
|
||||
const next: Rect[] = []
|
||||
if (used.x > free.x) {
|
||||
next.push({
|
||||
x: free.x,
|
||||
y: free.y,
|
||||
width: used.x - free.x,
|
||||
height: free.height,
|
||||
})
|
||||
}
|
||||
if (used.x + used.width < free.x + free.width) {
|
||||
next.push({
|
||||
x: used.x + used.width,
|
||||
y: free.y,
|
||||
width: free.x + free.width - used.x - used.width,
|
||||
height: free.height,
|
||||
})
|
||||
}
|
||||
if (used.y > free.y) {
|
||||
next.push({
|
||||
x: free.x,
|
||||
y: free.y,
|
||||
width: free.width,
|
||||
height: used.y - free.y,
|
||||
})
|
||||
}
|
||||
if (used.y + used.height < free.y + free.height) {
|
||||
next.push({
|
||||
x: free.x,
|
||||
y: used.y + used.height,
|
||||
width: free.width,
|
||||
height: free.y + free.height - used.y - used.height,
|
||||
})
|
||||
}
|
||||
return next.filter((rect) => rect.width > 0 && rect.height > 0)
|
||||
}
|
||||
|
||||
function pruneFreeRects(rects: Rect[]) {
|
||||
return rects.filter((rect, index) => !rects.some((candidate, candidateIndex) => (
|
||||
candidateIndex !== index && contains(candidate, rect)
|
||||
)))
|
||||
}
|
||||
|
||||
function placeOnSheet(sheet: Sheet, used: Rect) {
|
||||
sheet.freeRects = pruneFreeRects(
|
||||
sheet.freeRects.flatMap((free) => splitFreeRect(free, used)),
|
||||
)
|
||||
}
|
||||
|
||||
function findPlacement(
|
||||
sheets: Sheet[],
|
||||
piece: Piece,
|
||||
kerfMm: number,
|
||||
) {
|
||||
let best: {
|
||||
sheetIndex: number
|
||||
rect: Rect
|
||||
areaWaste: number
|
||||
shortSideWaste: number
|
||||
} | null = null
|
||||
for (let sheetIndex = 0; sheetIndex < sheets.length; sheetIndex += 1) {
|
||||
const sheet = sheets[sheetIndex]!
|
||||
for (const free of sheet.freeRects) {
|
||||
const orientations = piece.width === piece.height
|
||||
? [[piece.width, piece.height] as const]
|
||||
: [
|
||||
[piece.width, piece.height] as const,
|
||||
[piece.height, piece.width] as const,
|
||||
]
|
||||
for (const [physicalWidth, physicalHeight] of orientations) {
|
||||
const width = physicalWidth + kerfMm
|
||||
const height = physicalHeight + kerfMm
|
||||
if (width > free.width || height > free.height) continue
|
||||
const candidate = {
|
||||
sheetIndex,
|
||||
rect: { x: free.x, y: free.y, width, height },
|
||||
areaWaste: free.width * free.height - width * height,
|
||||
shortSideWaste: Math.min(free.width - width, free.height - height),
|
||||
}
|
||||
if (
|
||||
!best ||
|
||||
candidate.areaWaste < best.areaWaste ||
|
||||
(candidate.areaWaste === best.areaWaste &&
|
||||
candidate.shortSideWaste < best.shortSideWaste)
|
||||
) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function allocateCostByArea(
|
||||
totalCost: number,
|
||||
pieces: Piece[],
|
||||
): BoardCuttingAllocation[] {
|
||||
const byLine = new Map<string, { pieceCount: number; areaMm2: number }>()
|
||||
for (const piece of pieces) {
|
||||
const current = byLine.get(piece.lineId) ?? { pieceCount: 0, areaMm2: 0 }
|
||||
current.pieceCount += 1
|
||||
current.areaMm2 += piece.physicalArea
|
||||
byLine.set(piece.lineId, current)
|
||||
}
|
||||
const entries = Array.from(byLine.entries()).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)
|
||||
const totalArea = entries.reduce((sum, [, value]) => sum + value.areaMm2, 0)
|
||||
const totalCents = Math.round(totalCost * 100)
|
||||
const shares = entries.map(([lineId, value]) => {
|
||||
const exactCents = totalArea > 0
|
||||
? totalCents * value.areaMm2 / totalArea
|
||||
: totalCents / Math.max(1, entries.length)
|
||||
const cents = Math.floor(exactCents)
|
||||
return {
|
||||
lineId,
|
||||
pieceCount: value.pieceCount,
|
||||
areaMm2: value.areaMm2,
|
||||
exactCents,
|
||||
cents,
|
||||
}
|
||||
})
|
||||
let remainder = totalCents - shares.reduce((sum, item) => sum + item.cents, 0)
|
||||
for (const share of [...shares].sort((left, right) => (
|
||||
(right.exactCents - right.cents) - (left.exactCents - left.cents) ||
|
||||
left.lineId.localeCompare(right.lineId)
|
||||
))) {
|
||||
if (remainder <= 0) break
|
||||
share.cents += 1
|
||||
remainder -= 1
|
||||
}
|
||||
return shares.map((share) => ({
|
||||
lineId: share.lineId,
|
||||
pieceCount: share.pieceCount,
|
||||
partAreaSqm: round(share.areaMm2 / 1_000_000, 6),
|
||||
allocatedCost: share.cents / 100,
|
||||
}))
|
||||
}
|
||||
|
||||
export function calculateBoardSheetCuttingPlan(
|
||||
sheetInput: BoardSheetInput,
|
||||
partInputs: BoardCuttingPartInput[],
|
||||
): BoardSheetCuttingPlan | null {
|
||||
const sheetLengthMm = positiveNumber(sheetInput.sheetLengthMm)
|
||||
const sheetWidthMm = positiveNumber(sheetInput.sheetWidthMm)
|
||||
const fullSheetPrice = positiveNumber(sheetInput.fullSheetPrice)
|
||||
if (!sheetLengthMm || !sheetWidthMm || !fullSheetPrice) return null
|
||||
|
||||
const kerfMm = Math.max(0, Number.isFinite(sheetInput.kerfMm ?? 4)
|
||||
? sheetInput.kerfMm ?? 4
|
||||
: 4)
|
||||
const edgeMarginMm = Math.max(0, Number.isFinite(sheetInput.edgeMarginMm ?? 10)
|
||||
? sheetInput.edgeMarginMm ?? 10
|
||||
: 10)
|
||||
const usableLength = sheetLengthMm - edgeMarginMm * 2 + kerfMm
|
||||
const usableWidth = sheetWidthMm - edgeMarginMm * 2 + kerfMm
|
||||
if (usableLength <= 0 || usableWidth <= 0) return null
|
||||
|
||||
const warnings: string[] = []
|
||||
const pieces: Piece[] = []
|
||||
for (const part of partInputs) {
|
||||
const lengthMm = positiveNumber(part.lengthMm)
|
||||
const widthMm = positiveNumber(part.widthMm)
|
||||
const quantity = positiveNumber(part.quantity)
|
||||
if (!lengthMm || !widthMm || !quantity || !part.lineId.trim()) return null
|
||||
const pieceCount = Math.ceil(quantity)
|
||||
if (pieceCount !== quantity) {
|
||||
warnings.push(`${part.lineId} 的板件数量 ${quantity} 已按整块向上取整为 ${pieceCount}`)
|
||||
}
|
||||
if (pieceCount > 1_000 || pieces.length + pieceCount > 2_000) return null
|
||||
const fits = (
|
||||
(lengthMm + kerfMm <= usableLength && widthMm + kerfMm <= usableWidth) ||
|
||||
(widthMm + kerfMm <= usableLength && lengthMm + kerfMm <= usableWidth)
|
||||
)
|
||||
if (!fits) return null
|
||||
for (let index = 0; index < pieceCount; index += 1) {
|
||||
pieces.push({
|
||||
lineId: part.lineId,
|
||||
width: lengthMm,
|
||||
height: widthMm,
|
||||
physicalArea: lengthMm * widthMm,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (pieces.length === 0) return null
|
||||
|
||||
pieces.sort((left, right) => (
|
||||
Math.max(right.width, right.height) - Math.max(left.width, left.height) ||
|
||||
right.physicalArea - left.physicalArea ||
|
||||
left.lineId.localeCompare(right.lineId)
|
||||
))
|
||||
const sheets: Sheet[] = []
|
||||
for (const piece of pieces) {
|
||||
let placement = findPlacement(sheets, piece, kerfMm)
|
||||
if (!placement) {
|
||||
sheets.push({
|
||||
freeRects: [{ x: 0, y: 0, width: usableLength, height: usableWidth }],
|
||||
})
|
||||
placement = findPlacement(sheets, piece, kerfMm)
|
||||
}
|
||||
if (!placement) return null
|
||||
placeOnSheet(sheets[placement.sheetIndex]!, placement.rect)
|
||||
}
|
||||
|
||||
const sheetCount = sheets.length
|
||||
const totalSheetCost = round(sheetCount * fullSheetPrice, 2)
|
||||
const usedAreaMm2 = pieces.reduce((sum, piece) => sum + piece.physicalArea, 0)
|
||||
const purchasedAreaMm2 = sheetCount * sheetLengthMm * sheetWidthMm
|
||||
return {
|
||||
rulesetVersion: BOARD_SHEET_CUTTING_RULESET_VERSION,
|
||||
substrateId: sheetInput.substrateId,
|
||||
materialName: sheetInput.materialName,
|
||||
sheetLengthMm,
|
||||
sheetWidthMm,
|
||||
fullSheetPrice,
|
||||
kerfMm,
|
||||
edgeMarginMm,
|
||||
sheetCount,
|
||||
totalSheetCost,
|
||||
usedAreaSqm: round(usedAreaMm2 / 1_000_000, 6),
|
||||
purchasedAreaSqm: round(purchasedAreaMm2 / 1_000_000, 6),
|
||||
utilization: round(usedAreaMm2 / purchasedAreaMm2, 4),
|
||||
allocations: allocateCostByArea(totalSheetCost, pieces),
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
}
|
||||
}
|
||||
@ -217,6 +217,98 @@ export interface BomCostingCaseDetail {
|
||||
payload: unknown
|
||||
createdAt: string
|
||||
}>
|
||||
dynamicCosting?: BomCostingDynamicCostingDetail
|
||||
}
|
||||
|
||||
export type BomCostingDynamicScenarioKindValue =
|
||||
| 'SAME_MATERIAL'
|
||||
| 'MATERIAL_SUBSTITUTION'
|
||||
|
||||
export type BomCostingDynamicStatusValue =
|
||||
| 'FINAL'
|
||||
| 'PROVISIONAL'
|
||||
| 'INSUFFICIENT_DATA'
|
||||
| 'STALE'
|
||||
|
||||
export interface BomCostingScenarioLineDetail {
|
||||
id: string
|
||||
sourceLineId: string | null
|
||||
sourceRowId: string
|
||||
category: string
|
||||
partName: string
|
||||
originalMaterial: string
|
||||
appliedMaterial: string
|
||||
materialSpec: string
|
||||
unit: string
|
||||
quantity: number | null
|
||||
coefficient: number | null
|
||||
pricingQuantity: number | null
|
||||
unitPrice: number | null
|
||||
subtotal: number | null
|
||||
priceSource: string
|
||||
sourceSku: string
|
||||
referenceSkus: string[]
|
||||
referenceRowIds: string[]
|
||||
sourceFingerprint: string
|
||||
sourceCreatedAt: string | null
|
||||
sourceSyncedAt: string | null
|
||||
calculationRule: string
|
||||
confidence: number | null
|
||||
status: BomCostingDynamicStatusValue
|
||||
warning: string
|
||||
rawSnapshot: unknown
|
||||
}
|
||||
|
||||
export interface BomCostingScenarioDetail {
|
||||
id: string
|
||||
skuId: string
|
||||
kind: BomCostingDynamicScenarioKindValue
|
||||
targetMaterialId: string | null
|
||||
targetMaterialName: string
|
||||
revision: number
|
||||
isCurrent: boolean
|
||||
status: BomCostingDynamicStatusValue
|
||||
totalCost: number | null
|
||||
knownCost: number | null
|
||||
materialCost: number | null
|
||||
processingCost: number | null
|
||||
laborCost: number | null
|
||||
packagingCost: number | null
|
||||
lossCost: number | null
|
||||
competitorPrice: number | null
|
||||
priceDelta: number | null
|
||||
confidence: number | null
|
||||
warning: string
|
||||
unresolvedCount: number
|
||||
dataAsOf: string | null
|
||||
syncRunId: string | null
|
||||
syncStatus: string | null
|
||||
syncCompletedAt: string | null
|
||||
rulesetVersion: string
|
||||
createdAt: string
|
||||
lines: BomCostingScenarioLineDetail[]
|
||||
}
|
||||
|
||||
export interface BomCostingDynamicCostingDetail {
|
||||
materials: Array<{
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
category: string
|
||||
aliases: string[]
|
||||
}>
|
||||
scenarios: BomCostingScenarioDetail[]
|
||||
sync: {
|
||||
state: 'EMPTY' | 'READY' | 'MIXED' | 'STALE'
|
||||
fresh: boolean
|
||||
dataAsOf: string | null
|
||||
sourceAsOf: string | null
|
||||
lastSuccessfulAt: string | null
|
||||
lastSuccessfulBatchId: string | null
|
||||
lastFailedAt: string | null
|
||||
lastFailedMessage: string
|
||||
maxAgeDays: number
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
@ -265,3 +265,17 @@ export const updateBomCostingLineSchema = z.object({
|
||||
})
|
||||
|
||||
export type UpdateBomCostingLineInput = z.infer<typeof updateBomCostingLineSchema>
|
||||
|
||||
/**
|
||||
* A scenario request is intentionally small: material identity comes from the
|
||||
* enabled company directory, never from free-form model output or a browser
|
||||
* supplied price.
|
||||
*/
|
||||
export const bomCostingScenarioCalculationSchema = z.object({
|
||||
skuId: z.string().trim().min(1).max(200),
|
||||
targetMaterialId: z.string().trim().min(1).max(200),
|
||||
})
|
||||
|
||||
export type BomCostingScenarioCalculationInput = z.infer<
|
||||
typeof bomCostingScenarioCalculationSchema
|
||||
>
|
||||
|
||||
@ -0,0 +1,292 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
buildBomCostBacktestReport,
|
||||
evaluateBomCostBacktestCase,
|
||||
selectHistoricalCostTotal,
|
||||
type BomCostBacktestEvaluation,
|
||||
type BomCostBacktestMirrorRow,
|
||||
} from './dynamic-bom-cost-backtest'
|
||||
|
||||
const now = new Date('2026-08-07T00:00:00.000Z')
|
||||
|
||||
function row(input: Partial<BomCostBacktestMirrorRow> & Pick<BomCostBacktestMirrorRow, 'sourceRowId'>): BomCostBacktestMirrorRow {
|
||||
return {
|
||||
sourceRowId: input.sourceRowId,
|
||||
sourceSku: input.sourceSku ?? 'SKU-1',
|
||||
bomVersionKey: input.bomVersionKey ?? 'V1',
|
||||
category: input.category ?? '板材',
|
||||
categoryCode: input.categoryCode ?? 'BOARD',
|
||||
partNumber: input.partNumber ?? 'P-001',
|
||||
partName: input.partName ?? '桌面板',
|
||||
materialName: input.materialName ?? '橡胶木',
|
||||
materialCode: input.materialCode ?? 'RW-01',
|
||||
materialSpec: input.materialSpec ?? '20 mm',
|
||||
processName: input.processName ?? '',
|
||||
unit: input.unit ?? '㎡',
|
||||
rawNote: input.rawNote ?? '',
|
||||
lengthMm: input.lengthMm ?? null,
|
||||
widthMm: input.widthMm ?? null,
|
||||
quantity: input.quantity === undefined ? 1 : input.quantity,
|
||||
coefficient: input.coefficient ?? 1,
|
||||
areaSqm: input.areaSqm === undefined ? 1 : input.areaSqm,
|
||||
edgeLengthM: input.edgeLengthM ?? null,
|
||||
unitPrice: input.unitPrice === undefined ? 50 : input.unitPrice,
|
||||
amount: input.amount === undefined ? 50 : input.amount,
|
||||
sourceCreatedAt: input.sourceCreatedAt ?? now,
|
||||
syncedAt: input.syncedAt ?? now,
|
||||
isSummaryRow: input.isSummaryRow ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dynamic BOM cost backtest', () => {
|
||||
it('prefers total cost and never treats total material cost as the historical total', () => {
|
||||
const selected = selectHistoricalCostTotal([
|
||||
row({ sourceRowId: '10', category: '总材料费', partName: '总材料费', amount: 50, isSummaryRow: true }),
|
||||
row({ sourceRowId: '11', category: '总成本', partName: '总成本', amount: 65, isSummaryRow: true }),
|
||||
])
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
historicalTotal: 65,
|
||||
historicalTotalSourceRowId: '11',
|
||||
})
|
||||
})
|
||||
|
||||
it('recalculates detail rows while excluding the historical summary row', () => {
|
||||
const evaluation = evaluateBomCostBacktestCase([
|
||||
row({
|
||||
sourceRowId: '1',
|
||||
areaSqm: 0.5,
|
||||
quantity: 1,
|
||||
coefficient: 1.1,
|
||||
unitPrice: 100,
|
||||
amount: 55,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '2',
|
||||
category: '人工',
|
||||
categoryCode: 'LABOR',
|
||||
partName: '装配人工',
|
||||
materialName: '装配人工',
|
||||
unit: '件',
|
||||
areaSqm: null,
|
||||
quantity: 1,
|
||||
coefficient: 1,
|
||||
unitPrice: 10,
|
||||
amount: 10,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '3',
|
||||
category: '总成本',
|
||||
categoryCode: '',
|
||||
partName: '总成本',
|
||||
isSummaryRow: true,
|
||||
amount: 65,
|
||||
}),
|
||||
])
|
||||
|
||||
expect(evaluation).toMatchObject({
|
||||
status: 'COMPARABLE',
|
||||
historicalTotal: 65,
|
||||
recalculatedTotal: 65,
|
||||
difference: 0,
|
||||
absoluteErrorPercent: 0,
|
||||
excludedSummaryRowCount: 1,
|
||||
primaryMaterial: '橡胶木',
|
||||
primaryCategory: 'BOARD',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a board on piece pricing when its historical amount proves that basis', () => {
|
||||
const evaluation = evaluateBomCostBacktestCase([
|
||||
row({
|
||||
sourceRowId: '1',
|
||||
unit: '块',
|
||||
quantity: 2,
|
||||
areaSqm: 1,
|
||||
unitPrice: 50,
|
||||
amount: 100,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '2',
|
||||
category: '总成本',
|
||||
categoryCode: '',
|
||||
partName: '总成本',
|
||||
isSummaryRow: true,
|
||||
amount: 100,
|
||||
}),
|
||||
])
|
||||
|
||||
expect(evaluation).toMatchObject({
|
||||
status: 'COMPARABLE',
|
||||
historicalTotal: 100,
|
||||
recalculatedTotal: 100,
|
||||
absoluteErrorPercent: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('replays company volume, effective count, amount-only and blank rows from audit data', () => {
|
||||
const evaluation = evaluateBomCostBacktestCase([
|
||||
row({
|
||||
sourceRowId: '1',
|
||||
category: '包材',
|
||||
categoryCode: 'PACKAGING',
|
||||
partName: '泡沫包材',
|
||||
materialName: '泡沫',
|
||||
unit: '立方数',
|
||||
quantity: null,
|
||||
areaSqm: 0.002556,
|
||||
unitPrice: 120,
|
||||
amount: 0.30672,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '2',
|
||||
category: '包材',
|
||||
categoryCode: 'PACKAGING',
|
||||
partName: '泡沫护角',
|
||||
materialName: '泡沫护角',
|
||||
unit: '个',
|
||||
quantity: 4,
|
||||
areaSqm: 0.12,
|
||||
unitPrice: 2,
|
||||
amount: 0.96,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '3',
|
||||
category: '人工+杂费',
|
||||
categoryCode: 'LABOR',
|
||||
partName: '人工',
|
||||
materialName: '人工',
|
||||
unit: '项',
|
||||
quantity: null,
|
||||
areaSqm: null,
|
||||
unitPrice: null,
|
||||
amount: 18.25,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '4',
|
||||
category: '包材',
|
||||
categoryCode: 'PACKAGING',
|
||||
partName: '安装图+胶带',
|
||||
materialName: '安装图+胶带',
|
||||
unit: '项',
|
||||
quantity: null,
|
||||
areaSqm: null,
|
||||
unitPrice: 0,
|
||||
amount: 0.15,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '5',
|
||||
category: '说明',
|
||||
categoryCode: 'OTHER',
|
||||
partName: '包装说明',
|
||||
materialName: '',
|
||||
unit: '',
|
||||
quantity: null,
|
||||
areaSqm: null,
|
||||
unitPrice: null,
|
||||
amount: null,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '6',
|
||||
category: '总成本',
|
||||
categoryCode: '',
|
||||
partName: '总成本',
|
||||
isSummaryRow: true,
|
||||
amount: 19.66672,
|
||||
}),
|
||||
])
|
||||
|
||||
expect(evaluation).toMatchObject({
|
||||
status: 'COMPARABLE',
|
||||
historicalTotal: 19.67,
|
||||
recalculatedTotal: 19.67,
|
||||
absoluteErrorPercent: 0,
|
||||
unresolvedLineCount: 0,
|
||||
})
|
||||
expect(evaluation?.warnings).toContain('历史回测忽略 1 条无单价且无金额的说明行')
|
||||
})
|
||||
|
||||
it('marks an unexplained historical summary gap as unresolved instead of inventing cost', () => {
|
||||
const evaluation = evaluateBomCostBacktestCase([
|
||||
row({
|
||||
sourceRowId: '1',
|
||||
category: '钢材',
|
||||
categoryCode: 'STEEL',
|
||||
partName: '50*25*0.8扁管',
|
||||
materialName: '50*25*0.8扁管',
|
||||
unit: '根',
|
||||
quantity: 2,
|
||||
areaSqm: null,
|
||||
edgeLengthM: null,
|
||||
unitPrice: 4,
|
||||
amount: 25.25,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '2',
|
||||
category: '人工+杂费',
|
||||
categoryCode: 'LABOR',
|
||||
partName: '人工+杂费',
|
||||
materialName: '人工+杂费',
|
||||
unit: '',
|
||||
quantity: null,
|
||||
areaSqm: null,
|
||||
unitPrice: 0,
|
||||
amount: 0,
|
||||
}),
|
||||
row({
|
||||
sourceRowId: '3',
|
||||
category: '总成本',
|
||||
categoryCode: '',
|
||||
partName: '总成本',
|
||||
isSummaryRow: true,
|
||||
amount: 55,
|
||||
}),
|
||||
])
|
||||
|
||||
expect(evaluation).toMatchObject({
|
||||
status: 'UNRESOLVED',
|
||||
historicalTotal: 55,
|
||||
recalculatedTotal: null,
|
||||
absoluteErrorPercent: null,
|
||||
unresolvedLineCount: 1,
|
||||
})
|
||||
expect(evaluation?.warnings[0]).toContain('历史总成本无法由逐行金额对账')
|
||||
expect(evaluation?.warnings[0]).toContain('有效明细金额合计 25.25 元')
|
||||
})
|
||||
|
||||
it('fails the fixed 10 percent gate instead of hiding or relaxing an outlier', () => {
|
||||
const evaluation = (input: Partial<BomCostBacktestEvaluation> & Pick<BomCostBacktestEvaluation, 'sourceSku' | 'absoluteErrorPercent'>): BomCostBacktestEvaluation => ({
|
||||
sourceSku: input.sourceSku,
|
||||
bomVersionKey: 'V1',
|
||||
historicalTotal: 600,
|
||||
recalculatedTotal: input.absoluteErrorPercent === null ? null : 1_300,
|
||||
difference: input.absoluteErrorPercent === null ? null : 700,
|
||||
absoluteErrorPercent: input.absoluteErrorPercent,
|
||||
status: input.absoluteErrorPercent === null ? 'UNRESOLVED' : 'COMPARABLE',
|
||||
unresolvedLineCount: input.absoluteErrorPercent === null ? 1 : 0,
|
||||
excludedSummaryRowCount: 1,
|
||||
primaryMaterial: input.primaryMaterial ?? '橡胶木',
|
||||
primaryCategory: input.primaryCategory ?? 'BOARD',
|
||||
warnings: [],
|
||||
})
|
||||
const evaluations = Array.from({ length: 20 }, (_, index) => evaluation({
|
||||
sourceSku: `SKU-${index + 1}`,
|
||||
absoluteErrorPercent: index === 19 ? 116.6667 : 5,
|
||||
primaryMaterial: index % 2 === 0 ? '橡胶木' : '黑胡桃木',
|
||||
}))
|
||||
const report = buildBomCostBacktestReport({ evaluations, requestedSampleSize: 20 })
|
||||
|
||||
expect(report.passed).toBe(false)
|
||||
expect(report.overall).toMatchObject({
|
||||
count: 20,
|
||||
medianErrorPercent: 5,
|
||||
maxErrorPercent: 116.6667,
|
||||
withinThresholdCount: 19,
|
||||
withinThresholdRatePercent: 95,
|
||||
})
|
||||
expect(report.blockingReasons).toContain('最大误差超过 10% 门槛')
|
||||
expect(report.byMaterial).toHaveLength(2)
|
||||
expect(report.cases.find((item) => item.recalculatedTotal === 1_300)).toBeDefined()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,411 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import {
|
||||
calculateDynamicBomScenarios,
|
||||
DYNAMIC_BOM_COST_RULESET_VERSION,
|
||||
type DynamicBomCategory,
|
||||
type DynamicBomSourceRow,
|
||||
} from './dynamic-bom-cost-domain'
|
||||
|
||||
export const BOM_COST_BACKTEST_ERROR_THRESHOLD_PERCENT = 10
|
||||
export const BOM_COST_BACKTEST_MIN_SAMPLE_SIZE = 20
|
||||
export const BOM_COST_BACKTEST_MAX_SAMPLE_SIZE = 50
|
||||
|
||||
type DecimalInput = Prisma.Decimal | string | number | null
|
||||
|
||||
export type BomCostBacktestMirrorRow = {
|
||||
sourceRowId: string
|
||||
sourceSku: string
|
||||
bomVersionKey: string
|
||||
category: string
|
||||
categoryCode: string
|
||||
partNumber: string
|
||||
partName: string
|
||||
materialName: string
|
||||
materialCode: string
|
||||
materialSpec: string
|
||||
processName: string
|
||||
unit: string
|
||||
rawNote: string
|
||||
lengthMm: DecimalInput
|
||||
widthMm: DecimalInput
|
||||
quantity: DecimalInput
|
||||
coefficient: DecimalInput
|
||||
areaSqm: DecimalInput
|
||||
edgeLengthM: DecimalInput
|
||||
unitPrice: DecimalInput
|
||||
amount: DecimalInput
|
||||
sourceCreatedAt: Date | null
|
||||
syncedAt: Date
|
||||
isSummaryRow: boolean
|
||||
}
|
||||
|
||||
export type BomCostBacktestCandidate = {
|
||||
sourceSku: string
|
||||
bomVersionKey: string
|
||||
historicalTotal: number
|
||||
historicalTotalSourceRowId: string
|
||||
sourceCreatedAt: Date | null
|
||||
}
|
||||
|
||||
export type BomCostBacktestEvaluation = {
|
||||
sourceSku: string
|
||||
bomVersionKey: string
|
||||
historicalTotal: number
|
||||
recalculatedTotal: number | null
|
||||
difference: number | null
|
||||
absoluteErrorPercent: number | null
|
||||
status: 'COMPARABLE' | 'UNRESOLVED'
|
||||
unresolvedLineCount: number
|
||||
excludedSummaryRowCount: number
|
||||
primaryMaterial: string
|
||||
primaryCategory: DynamicBomCategory | 'UNKNOWN'
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export type BomCostBacktestMetrics = {
|
||||
count: number
|
||||
meanErrorPercent: number | null
|
||||
medianErrorPercent: number | null
|
||||
maxErrorPercent: number | null
|
||||
withinThresholdCount: number
|
||||
withinThresholdRatePercent: number | null
|
||||
}
|
||||
|
||||
export type BomCostBacktestReport = {
|
||||
rulesetVersion: string
|
||||
thresholdPercent: number
|
||||
requestedSampleSize: number
|
||||
comparableCount: number
|
||||
unresolvedCount: number
|
||||
passed: boolean
|
||||
blockingReasons: string[]
|
||||
overall: BomCostBacktestMetrics
|
||||
byMaterial: Array<BomCostBacktestMetrics & { key: string }>
|
||||
byCategory: Array<BomCostBacktestMetrics & { key: string }>
|
||||
cases: BomCostBacktestEvaluation[]
|
||||
}
|
||||
|
||||
function decimal(value: DecimalInput) {
|
||||
if (value === null) return null
|
||||
try {
|
||||
const parsed = new Prisma.Decimal(value)
|
||||
return parsed.isFinite() ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function positiveDecimal(value: DecimalInput) {
|
||||
const parsed = decimal(value)
|
||||
return parsed?.greaterThan(0) ? parsed : null
|
||||
}
|
||||
|
||||
function scalar(value: DecimalInput) {
|
||||
const parsed = decimal(value)
|
||||
return parsed === null ? null : parsed.toString()
|
||||
}
|
||||
|
||||
function roundMoney(value: Prisma.Decimal) {
|
||||
return value.toDecimalPlaces(2).toNumber()
|
||||
}
|
||||
|
||||
function roundPercent(value: Prisma.Decimal) {
|
||||
return value.toDecimalPlaces(4).toNumber()
|
||||
}
|
||||
|
||||
function reconcileHistoricalDetailAmounts(
|
||||
rows: readonly BomCostBacktestMirrorRow[],
|
||||
historicalTotal: number,
|
||||
) {
|
||||
const detailAmountTotal = rows.reduce((total, row) => {
|
||||
if (row.isSummaryRow) return total
|
||||
const amount = positiveDecimal(row.amount)
|
||||
return amount === null ? total : total.plus(amount)
|
||||
}, new Prisma.Decimal(0))
|
||||
const historical = new Prisma.Decimal(historicalTotal)
|
||||
const difference = historical.minus(detailAmountTotal)
|
||||
const absoluteDifferencePercent = difference.abs().div(historical).times(100)
|
||||
return {
|
||||
detailAmountTotal,
|
||||
difference,
|
||||
absoluteDifferencePercent,
|
||||
}
|
||||
}
|
||||
|
||||
function summaryText(row: BomCostBacktestMirrorRow) {
|
||||
return `${row.category} ${row.partName} ${row.materialName} ${row.rawNote}`
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function summaryPriority(row: BomCostBacktestMirrorRow) {
|
||||
const value = summaryText(row)
|
||||
if (/总成本|成本合计|总费用|不包含开票价格|grandtotal/.test(value)) return 0
|
||||
if (/合计金额|合计|(?:^|[^a-z])total(?:$|[^a-z])/.test(value)) return 1
|
||||
return null
|
||||
}
|
||||
|
||||
function compareSourceRecency(left: BomCostBacktestMirrorRow, right: BomCostBacktestMirrorRow) {
|
||||
const dateDifference = (right.sourceCreatedAt?.getTime() ?? 0) - (left.sourceCreatedAt?.getTime() ?? 0)
|
||||
if (dateDifference !== 0) return dateDifference
|
||||
try {
|
||||
const leftId = BigInt(left.sourceRowId)
|
||||
const rightId = BigInt(right.sourceRowId)
|
||||
return rightId > leftId ? 1 : rightId < leftId ? -1 : 0
|
||||
} catch {
|
||||
return right.sourceRowId.localeCompare(left.sourceRowId)
|
||||
}
|
||||
}
|
||||
|
||||
export function selectHistoricalCostTotal(
|
||||
rows: readonly BomCostBacktestMirrorRow[],
|
||||
): BomCostBacktestCandidate | null {
|
||||
const ranked = rows
|
||||
.flatMap((row) => {
|
||||
if (!row.isSummaryRow) return []
|
||||
const priority = summaryPriority(row)
|
||||
const amount = positiveDecimal(row.amount)
|
||||
return priority === null || amount === null ? [] : [{ row, priority, amount }]
|
||||
})
|
||||
.sort((left, right) => left.priority - right.priority || compareSourceRecency(left.row, right.row))
|
||||
const selected = ranked[0]
|
||||
if (!selected) return null
|
||||
return {
|
||||
sourceSku: selected.row.sourceSku,
|
||||
bomVersionKey: selected.row.bomVersionKey,
|
||||
historicalTotal: roundMoney(selected.amount),
|
||||
historicalTotalSourceRowId: selected.row.sourceRowId,
|
||||
sourceCreatedAt: selected.row.sourceCreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectBomCostBacktestCandidates(
|
||||
summaryRows: readonly BomCostBacktestMirrorRow[],
|
||||
) {
|
||||
const bySkuAndVersion = new Map<string, BomCostBacktestMirrorRow[]>()
|
||||
for (const row of summaryRows) {
|
||||
if (!row.sourceSku || !row.bomVersionKey) continue
|
||||
const key = `${row.sourceSku}\u0000${row.bomVersionKey}`
|
||||
const rows = bySkuAndVersion.get(key) ?? []
|
||||
rows.push(row)
|
||||
bySkuAndVersion.set(key, rows)
|
||||
}
|
||||
const candidates = Array.from(bySkuAndVersion.values())
|
||||
.flatMap((rows) => {
|
||||
const candidate = selectHistoricalCostTotal(rows)
|
||||
return candidate ? [candidate] : []
|
||||
})
|
||||
const bySku = new Map<string, BomCostBacktestCandidate>()
|
||||
for (const candidate of candidates) {
|
||||
const current = bySku.get(candidate.sourceSku)
|
||||
if (!current || (candidate.sourceCreatedAt?.getTime() ?? 0) > (current.sourceCreatedAt?.getTime() ?? 0)) {
|
||||
bySku.set(candidate.sourceSku, candidate)
|
||||
}
|
||||
}
|
||||
return Array.from(bySku.values())
|
||||
}
|
||||
|
||||
function toDynamicRow(row: BomCostBacktestMirrorRow): DynamicBomSourceRow {
|
||||
return {
|
||||
Id: row.sourceRowId,
|
||||
skucode: row.sourceSku,
|
||||
category: row.categoryCode || row.category,
|
||||
number: row.partNumber,
|
||||
name: row.partName,
|
||||
partName: row.partName,
|
||||
materialName: row.materialName,
|
||||
materialCode: row.materialCode,
|
||||
materialSpec: row.materialSpec,
|
||||
processName: row.processName,
|
||||
unit: row.unit,
|
||||
lengthMm: scalar(row.lengthMm),
|
||||
widthMm: scalar(row.widthMm),
|
||||
quantity: scalar(row.quantity),
|
||||
coefficient: scalar(row.coefficient),
|
||||
areaSqm: scalar(row.areaSqm),
|
||||
edgeLengthM: scalar(row.edgeLengthM),
|
||||
unitPrice: scalar(row.unitPrice),
|
||||
amount: scalar(row.amount),
|
||||
sourceCreatedAt: row.sourceCreatedAt,
|
||||
sourceType: 'LOCAL_MIRROR',
|
||||
isSummaryRow: row.isSummaryRow,
|
||||
note: row.rawNote,
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateBomCostBacktestCase(
|
||||
rows: readonly BomCostBacktestMirrorRow[],
|
||||
): BomCostBacktestEvaluation | null {
|
||||
const historical = selectHistoricalCostTotal(rows)
|
||||
if (!historical) return null
|
||||
const costingRows = rows.filter((row) => (
|
||||
row.isSummaryRow ||
|
||||
positiveDecimal(row.unitPrice) !== null ||
|
||||
positiveDecimal(row.amount) !== null
|
||||
))
|
||||
const ignoredNoCostSignalCount = rows.length - costingRows.length
|
||||
const calculation = calculateDynamicBomScenarios({
|
||||
rows: costingRows.map(toDynamicRow),
|
||||
allowSourceRowPrices: true,
|
||||
asOf: rows.reduce<Date | null>((latest, row) => (
|
||||
!latest || row.syncedAt > latest ? row.syncedAt : latest
|
||||
), null),
|
||||
}).sameMaterial
|
||||
const dominantLine = calculation.lines
|
||||
.filter((line) => line.subtotal !== null)
|
||||
.sort((left, right) => (right.subtotal ?? 0) - (left.subtotal ?? 0))[0]
|
||||
const primaryMaterial = dominantLine?.materialName ||
|
||||
calculation.lines.find((line) => ['BOARD', 'STEEL', 'ACCESSORY'].includes(line.category))?.materialName ||
|
||||
'未标注材质'
|
||||
const reconciliation = reconcileHistoricalDetailAmounts(rows, historical.historicalTotal)
|
||||
if (reconciliation.absoluteDifferencePercent.greaterThan(BOM_COST_BACKTEST_ERROR_THRESHOLD_PERCENT)) {
|
||||
return {
|
||||
sourceSku: historical.sourceSku,
|
||||
bomVersionKey: historical.bomVersionKey,
|
||||
historicalTotal: historical.historicalTotal,
|
||||
recalculatedTotal: null,
|
||||
difference: null,
|
||||
absoluteErrorPercent: null,
|
||||
status: 'UNRESOLVED',
|
||||
unresolvedLineCount: Math.max(1, calculation.unresolvedLineCount),
|
||||
excludedSummaryRowCount: calculation.excludedRows.length,
|
||||
primaryMaterial,
|
||||
primaryCategory: dominantLine?.category ?? 'UNKNOWN',
|
||||
warnings: [
|
||||
`历史总成本无法由逐行金额对账:总成本 ${historical.historicalTotal.toFixed(2)} 元,` +
|
||||
`有效明细金额合计 ${roundMoney(reconciliation.detailAmountTotal).toFixed(2)} 元,` +
|
||||
`差额 ${roundMoney(reconciliation.difference).toFixed(2)} 元` +
|
||||
`(${roundPercent(reconciliation.absoluteDifferencePercent).toFixed(4)}%)`,
|
||||
...calculation.warnings,
|
||||
...(ignoredNoCostSignalCount > 0
|
||||
? [`历史回测忽略 ${ignoredNoCostSignalCount} 条无单价且无金额的说明行`]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
const recalculated = calculation.total
|
||||
if (recalculated === null) {
|
||||
return {
|
||||
sourceSku: historical.sourceSku,
|
||||
bomVersionKey: historical.bomVersionKey,
|
||||
historicalTotal: historical.historicalTotal,
|
||||
recalculatedTotal: null,
|
||||
difference: null,
|
||||
absoluteErrorPercent: null,
|
||||
status: 'UNRESOLVED',
|
||||
unresolvedLineCount: calculation.unresolvedLineCount,
|
||||
excludedSummaryRowCount: calculation.excludedRows.length,
|
||||
primaryMaterial,
|
||||
primaryCategory: dominantLine?.category ?? 'UNKNOWN',
|
||||
warnings: [
|
||||
...calculation.warnings,
|
||||
...(ignoredNoCostSignalCount > 0
|
||||
? [`历史回测忽略 ${ignoredNoCostSignalCount} 条无单价且无金额的说明行`]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
const historicalDecimal = new Prisma.Decimal(historical.historicalTotal)
|
||||
const recalculatedDecimal = new Prisma.Decimal(recalculated)
|
||||
const difference = recalculatedDecimal.minus(historicalDecimal)
|
||||
const absoluteErrorPercent = difference.abs().div(historicalDecimal).times(100)
|
||||
return {
|
||||
sourceSku: historical.sourceSku,
|
||||
bomVersionKey: historical.bomVersionKey,
|
||||
historicalTotal: historical.historicalTotal,
|
||||
recalculatedTotal: roundMoney(recalculatedDecimal),
|
||||
difference: roundMoney(difference),
|
||||
absoluteErrorPercent: roundPercent(absoluteErrorPercent),
|
||||
status: 'COMPARABLE',
|
||||
unresolvedLineCount: calculation.unresolvedLineCount,
|
||||
excludedSummaryRowCount: calculation.excludedRows.length,
|
||||
primaryMaterial,
|
||||
primaryCategory: dominantLine?.category ?? 'UNKNOWN',
|
||||
warnings: [
|
||||
...calculation.warnings,
|
||||
...(ignoredNoCostSignalCount > 0
|
||||
? [`历史回测忽略 ${ignoredNoCostSignalCount} 条无单价且无金额的说明行`]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function metrics(evaluations: readonly BomCostBacktestEvaluation[]): BomCostBacktestMetrics {
|
||||
const values = evaluations
|
||||
.flatMap((evaluation) => evaluation.absoluteErrorPercent === null ? [] : [evaluation.absoluteErrorPercent])
|
||||
.sort((left, right) => left - right)
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
meanErrorPercent: null,
|
||||
medianErrorPercent: null,
|
||||
maxErrorPercent: null,
|
||||
withinThresholdCount: 0,
|
||||
withinThresholdRatePercent: null,
|
||||
}
|
||||
}
|
||||
const sum = values.reduce((result, value) => result.plus(value), new Prisma.Decimal(0))
|
||||
const middle = Math.floor(values.length / 2)
|
||||
const median = values.length % 2 === 0
|
||||
? new Prisma.Decimal(values[middle - 1]!).plus(values[middle]!).div(2)
|
||||
: new Prisma.Decimal(values[middle]!)
|
||||
const withinThresholdCount = values.filter((value) => (
|
||||
value <= BOM_COST_BACKTEST_ERROR_THRESHOLD_PERCENT
|
||||
)).length
|
||||
return {
|
||||
count: values.length,
|
||||
meanErrorPercent: roundPercent(sum.div(values.length)),
|
||||
medianErrorPercent: roundPercent(median),
|
||||
maxErrorPercent: roundPercent(new Prisma.Decimal(values[values.length - 1]!)),
|
||||
withinThresholdCount,
|
||||
withinThresholdRatePercent: roundPercent(
|
||||
new Prisma.Decimal(withinThresholdCount).div(values.length).times(100),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function groupedMetrics(
|
||||
evaluations: readonly BomCostBacktestEvaluation[],
|
||||
keyOf: (evaluation: BomCostBacktestEvaluation) => string,
|
||||
) {
|
||||
const groups = new Map<string, BomCostBacktestEvaluation[]>()
|
||||
for (const evaluation of evaluations) {
|
||||
const key = keyOf(evaluation) || '未知'
|
||||
const group = groups.get(key) ?? []
|
||||
group.push(evaluation)
|
||||
groups.set(key, group)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([key, values]) => ({ key, ...metrics(values) }))
|
||||
.sort((left, right) => right.count - left.count || left.key.localeCompare(right.key, 'zh-CN'))
|
||||
}
|
||||
|
||||
export function buildBomCostBacktestReport(input: {
|
||||
evaluations: readonly BomCostBacktestEvaluation[]
|
||||
requestedSampleSize: number
|
||||
}): BomCostBacktestReport {
|
||||
const comparable = input.evaluations.filter((evaluation) => evaluation.status === 'COMPARABLE')
|
||||
const unresolved = input.evaluations.filter((evaluation) => evaluation.status === 'UNRESOLVED')
|
||||
const overall = metrics(comparable)
|
||||
const blockingReasons: string[] = []
|
||||
if (comparable.length < input.requestedSampleSize) {
|
||||
blockingReasons.push(`可比较 SKU 只有 ${comparable.length} 个,少于要求的 ${input.requestedSampleSize} 个`)
|
||||
}
|
||||
if ((overall.maxErrorPercent ?? Number.POSITIVE_INFINITY) > BOM_COST_BACKTEST_ERROR_THRESHOLD_PERCENT) {
|
||||
blockingReasons.push(`最大误差超过 ${BOM_COST_BACKTEST_ERROR_THRESHOLD_PERCENT}% 门槛`)
|
||||
}
|
||||
return {
|
||||
rulesetVersion: DYNAMIC_BOM_COST_RULESET_VERSION,
|
||||
thresholdPercent: BOM_COST_BACKTEST_ERROR_THRESHOLD_PERCENT,
|
||||
requestedSampleSize: input.requestedSampleSize,
|
||||
comparableCount: comparable.length,
|
||||
unresolvedCount: unresolved.length,
|
||||
passed: blockingReasons.length === 0,
|
||||
blockingReasons,
|
||||
overall,
|
||||
byMaterial: groupedMetrics(comparable, (evaluation) => evaluation.primaryMaterial),
|
||||
byCategory: groupedMetrics(comparable, (evaluation) => evaluation.primaryCategory),
|
||||
cases: [...input.evaluations],
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,414 @@
|
||||
import {
|
||||
calculateDynamicBomLineCost,
|
||||
calculateDynamicBomScenarios,
|
||||
convertDynamicQuantity,
|
||||
filterDynamicBomDetailRows,
|
||||
isDynamicBomSummaryRow,
|
||||
mapDynamicBomCategory,
|
||||
normalizeDynamicBomRows,
|
||||
parseDynamicNumber,
|
||||
parseDynamicNumberDetailed,
|
||||
resolveDynamicBomQuantity,
|
||||
} from './dynamic-bom-cost-domain'
|
||||
|
||||
describe('动态 BOM 成本纯领域内核', () => {
|
||||
it('清洗报表文本数字、中文倍率和非法占位符', () => {
|
||||
expect(parseDynamicNumber(' 1,234.50 元/㎡ ')).toBe(1234.5)
|
||||
expect(parseDynamicNumber('1.2万')).toBe(12_000)
|
||||
expect(parseDynamicNumber('2M')).toBe(2_000_000)
|
||||
expect(parseDynamicNumber('2 m²')).toBe(2)
|
||||
expect(parseDynamicNumber('5%')).toBe(0.05)
|
||||
expect(parseDynamicNumber('N/A')).toBeNull()
|
||||
expect(parseDynamicNumber('')).toBeNull()
|
||||
expect(parseDynamicNumberDetailed('(1,200)')?.value).toBe(-1200)
|
||||
expect(parseDynamicNumberDetailed('(-1,200)')?.value).toBe(-1200)
|
||||
})
|
||||
|
||||
it('识别类别并排除总材料费、总成本等汇总行', () => {
|
||||
expect(mapDynamicBomCategory('人工+杂费')).toBe('LABOR')
|
||||
expect(mapDynamicBomCategory('封边')).toBe('PROCESSING')
|
||||
expect(mapDynamicBomCategory('BOARD')).toBe('BOARD')
|
||||
expect(mapDynamicBomCategory('STEEL')).toBe('STEEL')
|
||||
expect(mapDynamicBomCategory('ACCESSORY')).toBe('ACCESSORY')
|
||||
expect(mapDynamicBomCategory('PACKAGING')).toBe('PACKAGING')
|
||||
expect(isDynamicBomSummaryRow({ category: '总材料费', amount: '100' })).toBe(true)
|
||||
const normalized = normalizeDynamicBomRows([
|
||||
{ Id: 1, skucode: 'SKU-1', category: '板材', name: '橡胶木', area: '0.2', quantity: '1', coefficient: '1.05', unitPrice: '60', amount: '12.6' },
|
||||
{ Id: 2, skucode: 'SKU-1', category: '总材料费', name: '总材料费', amount: '12.6' },
|
||||
{ Id: 3, skucode: 'SKU-1', category: '配件', name: '合计螺丝', amount: '3' },
|
||||
])
|
||||
expect(normalized.lines).toHaveLength(1)
|
||||
expect(normalized.excludedRows).toHaveLength(2)
|
||||
expect(filterDynamicBomDetailRows([{ category: '总成本', amount: '1' }])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('按面积、长度、件数和体积做单位换算', () => {
|
||||
expect(convertDynamicQuantity('1,000', 'mm', 'm')).toBe(1)
|
||||
expect(convertDynamicQuantity(2, 'm2', 'cm2')).toBe(20_000)
|
||||
expect(convertDynamicQuantity(1.5, 'kg', 'g')).toBe(1500)
|
||||
expect(convertDynamicQuantity(2, '平方数', 'm2')).toBe(2)
|
||||
expect(convertDynamicQuantity(0.5, '立方数', 'm3')).toBe(0.5)
|
||||
expect(convertDynamicQuantity(3, '副', '件')).toBe(3)
|
||||
expect(resolveDynamicBomQuantity({ category: '钢材', length: '1 m', quantity: '2', unit: '根' }, 'm')).toBeCloseTo(2)
|
||||
expect(resolveDynamicBomQuantity({ category: '板材', area: '2 m²', quantity: '1', unit: '块' }, '㎡')).toBeCloseTo(2)
|
||||
expect(resolveDynamicBomQuantity({ category: '板材', length: '500', width: '400', quantity: '2', unit: '块' }, '㎡')).toBeCloseTo(0.4)
|
||||
expect(resolveDynamicBomQuantity({ category: '钢材', length: '2500', quantity: '2', unit: '根' }, 'm')).toBeCloseTo(5)
|
||||
expect(resolveDynamicBomQuantity({ category: '包材', length: '1000', width: '500', height: '20', quantity: '2', unit: '块' }, 'm3')).toBeCloseTo(0.02)
|
||||
expect(resolveDynamicBomQuantity({ category: '包材', area: '0.002556', unit: '立方数' }, 'm3')).toBeCloseTo(0.002556)
|
||||
expect(resolveDynamicBomQuantity({ category: '包材', area: '1.25', unit: '平方数' }, 'm2')).toBeCloseTo(1.25)
|
||||
expect(resolveDynamicBomQuantity({ category: '封边', length: '280', width: '150', longEdge: '2', shortEdge: '2', quantity: '2' }, 'm')).toBeCloseTo(1.72)
|
||||
})
|
||||
|
||||
it('识别上游包材和五金计价单位,不把真实公司价格误判为缺失', () => {
|
||||
const packaging = calculateDynamicBomLineCost(
|
||||
{
|
||||
Id: 1,
|
||||
category: '包材',
|
||||
name: '瓦楞纸箱',
|
||||
length: '1000',
|
||||
width: '500',
|
||||
height: '200',
|
||||
quantity: '1',
|
||||
unit: '个',
|
||||
},
|
||||
{
|
||||
unitPrice: '100',
|
||||
priceUnit: '立方数',
|
||||
sourceType: 'LOCAL_MIRROR',
|
||||
},
|
||||
)
|
||||
const hardware = calculateDynamicBomLineCost(
|
||||
{ Id: 2, category: '配件', name: '导轨', quantity: '2', unit: '副' },
|
||||
{
|
||||
unitPrice: '15',
|
||||
priceUnit: '副',
|
||||
sourceType: 'LOCAL_MIRROR',
|
||||
},
|
||||
)
|
||||
|
||||
expect(packaging).toMatchObject({
|
||||
quantity: 0.1,
|
||||
quantityUnit: 'm3',
|
||||
unitPrice: 100,
|
||||
subtotal: 10,
|
||||
})
|
||||
expect(hardware).toMatchObject({
|
||||
quantity: 2,
|
||||
quantityUnit: 'piece',
|
||||
unitPrice: 15,
|
||||
subtotal: 30,
|
||||
})
|
||||
})
|
||||
|
||||
it('同材质与替代材质分别计算,且系数只应用一次', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [
|
||||
{
|
||||
Id: 1,
|
||||
skucode: 'SKU-1',
|
||||
category: '板材',
|
||||
name: '橡胶木 12mm',
|
||||
area: '0.2',
|
||||
quantity: '2',
|
||||
coefficient: '1.05',
|
||||
unit: '㎡',
|
||||
// 0.2 × 60 × 1.05 = 12.6. The amount is an audit value, not an
|
||||
// additional amount to add or a second coefficient source.
|
||||
amount: '12.6',
|
||||
},
|
||||
{ Id: 2, skucode: 'SKU-1', category: '配件', name: '螺丝', quantity: '4', unit: '件' },
|
||||
{ Id: 3, skucode: 'SKU-1', category: '总材料费', name: '总材料费', amount: '20' },
|
||||
],
|
||||
sameMaterialPrices: {
|
||||
'橡胶木12mm': { unitPrice: '60', priceUnit: '㎡', sourceType: 'LOCAL_MIRROR', sourceId: 'price-rubber', materialName: '橡胶木', confidence: 0.95 },
|
||||
螺丝: { unitPrice: '1', priceUnit: '件', sourceType: 'LOCAL_MIRROR', sourceId: 'price-screw', confidence: 0.95 },
|
||||
},
|
||||
substitution: {
|
||||
targetMaterial: '黑胡桃木',
|
||||
prices: {
|
||||
黑胡桃木: { unitPrice: '90', priceUnit: '㎡', sourceType: 'LOCAL_MIRROR', sourceId: 'price-walnut', materialName: '黑胡桃木', confidence: 0.95 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.sameMaterial.total).toBe(16.6)
|
||||
expect(result.materialSubstitution.total).toBe(22.9)
|
||||
expect(result.difference).toBe(6.3)
|
||||
expect(result.sameMaterial.lines[0]).toMatchObject({
|
||||
coefficient: 1.05,
|
||||
coefficientApplied: 1.05,
|
||||
subtotal: 12.6,
|
||||
status: 'FINAL',
|
||||
})
|
||||
expect(result.sameMaterial.lines[0]?.explanation).toContain('系数 1.05')
|
||||
expect(result.sameMaterial.excludedRows[0]?.reason).toBe('SUMMARY_CATEGORY')
|
||||
})
|
||||
|
||||
it('上游面积已经含系数时不再重复乘系数', () => {
|
||||
const line = calculateDynamicBomLineCost(
|
||||
{
|
||||
Id: 1,
|
||||
category: '板材',
|
||||
length: '1000',
|
||||
width: '500',
|
||||
quantity: '2',
|
||||
area: '1.1',
|
||||
coefficient: '1.1',
|
||||
unit: '块',
|
||||
amount: '11',
|
||||
},
|
||||
{ unitPrice: '10', priceUnit: '㎡', sourceType: 'LOCAL_MIRROR', confidence: 0.95 },
|
||||
)
|
||||
expect(line.subtotal).toBe(11)
|
||||
expect(line.coefficientApplied).toBe(1)
|
||||
expect(line.explanation).toContain('已含系数')
|
||||
})
|
||||
|
||||
it('价格来源明确已含系数时不再重复乘系数', () => {
|
||||
const line = calculateDynamicBomLineCost(
|
||||
{ Id: 1, category: '配件', name: '脚轮', quantity: '2', coefficient: '1.1', unit: '件' },
|
||||
{
|
||||
unitPrice: '10',
|
||||
priceUnit: '件',
|
||||
sourceType: 'MANUAL',
|
||||
coefficientIncluded: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(line.subtotal).toBe(20)
|
||||
expect(line.coefficientApplied).toBe(1)
|
||||
expect(line.explanation).toContain('已含系数')
|
||||
})
|
||||
|
||||
it('公司历史 BOM 系数优先于结构阶段的暂估系数,并且只应用一次', () => {
|
||||
const line = calculateDynamicBomLineCost(
|
||||
{ Id: 1, category: '配件', name: '脚轮', quantity: '2', coefficient: '1.2', unit: '件' },
|
||||
{
|
||||
unitPrice: '10',
|
||||
priceUnit: '件',
|
||||
sourceType: 'LOCAL_MIRROR',
|
||||
sourceId: 'reference-row-1',
|
||||
lossFactor: '1.05',
|
||||
},
|
||||
)
|
||||
|
||||
expect(line).toMatchObject({
|
||||
coefficient: 1.05,
|
||||
coefficientApplied: 1.05,
|
||||
subtotal: 21,
|
||||
})
|
||||
expect(line.explanation).toContain('公司历史 BOM 系数')
|
||||
})
|
||||
|
||||
it('真实镜像板材即使 unit 是块,仍按面积单价重算并校验金额', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{
|
||||
Id: 475_893,
|
||||
skucode: 'DNZ008648N415V01',
|
||||
category: '板材',
|
||||
name: '11E1级密度板粉色+粉色',
|
||||
length: '280',
|
||||
width: '150',
|
||||
unit: '块',
|
||||
quantity: '2',
|
||||
coefficient: '1.05',
|
||||
area: '0.08',
|
||||
unitPrice: '17.85',
|
||||
amount: '1.57',
|
||||
}],
|
||||
targetMaterial: '橡胶木',
|
||||
allowSourceRowPrices: true,
|
||||
})
|
||||
expect(result.sameMaterial.lines[0]).toMatchObject({
|
||||
quantity: 0.084,
|
||||
quantityUnit: 'm2',
|
||||
unitPrice: 17.85,
|
||||
coefficientApplied: 1.05,
|
||||
subtotal: 1.57,
|
||||
status: 'PROVISIONAL',
|
||||
})
|
||||
expect(result.sameMaterial.lines[0]?.explanation).toContain('历史金额校验')
|
||||
})
|
||||
|
||||
it('公司历史候选仍带块单位时,按板材面积口径而不是件数口径', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{ Id: 'line-1', category: '板材', name: '橡胶木', lengthMm: 500, widthMm: 400, quantity: 2, unit: '块' }],
|
||||
sameMaterialPrices: {
|
||||
'line-1': {
|
||||
unitPrice: 60,
|
||||
// Historical mirror rows use the physical part unit here even
|
||||
// though their amount is calculated from square metres.
|
||||
priceUnit: '块',
|
||||
sourceType: 'LOCAL_MIRROR',
|
||||
sourceId: 'reference-row-1',
|
||||
confidence: 0.9,
|
||||
},
|
||||
},
|
||||
targetMaterial: '橡胶木',
|
||||
})
|
||||
expect(result.sameMaterial.lines[0]).toMatchObject({
|
||||
quantity: 0.4,
|
||||
quantityUnit: 'm2',
|
||||
subtotal: 24,
|
||||
})
|
||||
})
|
||||
|
||||
it('替代场景未选择目标材质时不把同材质单价冒充替代价格', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{ Id: 1, category: '板材', name: '橡胶木', area: 1, quantity: 1, unit: '㎡' }],
|
||||
sameMaterialPrices: { 橡胶木: { unitPrice: 60, priceUnit: '㎡', sourceType: 'LOCAL_MIRROR' } },
|
||||
})
|
||||
expect(result.sameMaterial.total).toBe(60)
|
||||
expect(result.materialSubstitution.total).toBeNull()
|
||||
expect(result.materialSubstitution.status).toBe('INSUFFICIENT_DATA')
|
||||
})
|
||||
|
||||
it('无单价但有有效公司明细金额时只作同材质金额来源', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{ Id: 1, category: '人工+杂费', name: '人工', quantity: '1', unit: '项', amount: '18.25' }],
|
||||
targetMaterial: '橡胶木',
|
||||
allowSourceRowPrices: true,
|
||||
})
|
||||
expect(result.sameMaterial.lines[0]).toMatchObject({
|
||||
subtotal: 18.25,
|
||||
coefficientApplied: 1,
|
||||
status: 'FINAL',
|
||||
})
|
||||
expect(result.materialSubstitution.lines[0]?.subtotal).toBe(18.25)
|
||||
})
|
||||
|
||||
it('历史计件行的单价公式与审计金额不一致时使用审计金额', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{
|
||||
Id: 1,
|
||||
category: '包材',
|
||||
name: '泡沫护角',
|
||||
unit: '个',
|
||||
quantity: '4',
|
||||
unitPrice: '2',
|
||||
amount: '0.96',
|
||||
}],
|
||||
allowSourceRowPrices: true,
|
||||
})
|
||||
|
||||
expect(result.sameMaterial).toMatchObject({
|
||||
total: 0.96,
|
||||
status: 'PROVISIONAL',
|
||||
unresolvedLineCount: 0,
|
||||
})
|
||||
expect(result.sameMaterial.lines[0]).toMatchObject({
|
||||
quantity: 4,
|
||||
subtotal: 0.96,
|
||||
status: 'PROVISIONAL',
|
||||
priceSource: {
|
||||
calculationRule: 'historical-source-row-audit-amount/v2',
|
||||
},
|
||||
})
|
||||
expect(result.sameMaterial.lines[0]?.explanation).not.toContain('= 8 元')
|
||||
})
|
||||
|
||||
it('正式竞品场景不使用来源整行金额兜底', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{
|
||||
Id: 1,
|
||||
category: '包材',
|
||||
name: '泡沫护角',
|
||||
unit: '个',
|
||||
quantity: '4',
|
||||
unitPrice: '2',
|
||||
amount: '0.96',
|
||||
}],
|
||||
allowSourceRowPrices: false,
|
||||
})
|
||||
|
||||
expect(result.sameMaterial).toMatchObject({
|
||||
total: null,
|
||||
knownTotal: 0,
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
})
|
||||
expect(result.sameMaterial.lines[0]).toMatchObject({
|
||||
unitPrice: null,
|
||||
subtotal: null,
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
})
|
||||
})
|
||||
|
||||
it('缺少公司价格时保留可解释的待补数据状态,不调用模型估价', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{ Id: 1, category: '板材', name: '橡胶木', area: '0.2', quantity: '1', unit: '㎡', estimatedUnitPrice: 999 }],
|
||||
sameMaterialPrices: {},
|
||||
substitution: { targetMaterial: '黑胡桃木', prices: {} },
|
||||
})
|
||||
expect(result.sameMaterial.total).toBeNull()
|
||||
expect(result.sameMaterial.status).toBe('INSUFFICIENT_DATA')
|
||||
expect(result.sameMaterial.lines[0]?.subtotal).toBeNull()
|
||||
expect(result.sameMaterial.lines[0]?.warnings).toContain('没有公司价格来源')
|
||||
expect(result.materialSubstitution.lines[0]?.warnings).toContain('替代材质没有公司价格来源')
|
||||
})
|
||||
|
||||
it('部分明细缺价时只返回已知小计,不冒充总成本', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [
|
||||
{ Id: 1, category: '配件', name: '脚轮', quantity: '2', unit: '件' },
|
||||
{ Id: 2, category: '配件', name: '拉手', quantity: '1', unit: '件' },
|
||||
],
|
||||
sameMaterialPrices: {
|
||||
脚轮: { unitPrice: 20, priceUnit: '件', sourceType: 'LOCAL_MIRROR' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.sameMaterial.status).toBe('INSUFFICIENT_DATA')
|
||||
expect(result.sameMaterial.total).toBeNull()
|
||||
expect(result.sameMaterial.knownTotal).toBe(40)
|
||||
expect(result.sameMaterial.unresolvedLineCount).toBe(1)
|
||||
expect(result.difference).toBeNull()
|
||||
})
|
||||
|
||||
it('低置信度、过期价格和高于竞品售价只标记状态,不隐藏金额', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{ Id: 1, category: '配件', name: '脚轮', quantity: '1', unit: '件' }],
|
||||
sameMaterialPrices: {
|
||||
脚轮: { unitPrice: 1_300, priceUnit: '件', sourceType: 'REFERENCE_SKU', confidence: 0.5, effectiveAt: '2025-01-01' },
|
||||
},
|
||||
marketPrice: '600',
|
||||
maxPriceAgeDays: 30,
|
||||
now: '2026-08-07',
|
||||
})
|
||||
expect(result.sameMaterial.total).toBe(1_300)
|
||||
expect(result.sameMaterial.status).toBe('STALE')
|
||||
expect(result.sameMaterial.marketPriceAlert).toBe(true)
|
||||
expect(result.sameMaterial.lines[0]).toMatchObject({
|
||||
partName: '脚轮',
|
||||
unitPrice: 1_300,
|
||||
subtotal: 1_300,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses decimal money multiplication without binary floating-point residue', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{ Id: 1, category: '配件', name: '脚轮', quantity: 3, unit: '件' }],
|
||||
sameMaterialPrices: {
|
||||
脚轮: { unitPrice: '0.1', priceUnit: '件', sourceType: 'LOCAL_MIRROR' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.sameMaterial.total).toBe(0.3)
|
||||
expect(result.sameMaterial.lines[0]?.rawSubtotal).toBe(0.3)
|
||||
})
|
||||
|
||||
it('单条价格缺少时间时按镜像数据时间判断过期', () => {
|
||||
const result = calculateDynamicBomScenarios({
|
||||
rows: [{ Id: 1, category: '配件', name: '脚轮', quantity: '1', unit: '件' }],
|
||||
sameMaterialPrices: {
|
||||
脚轮: { unitPrice: 20, priceUnit: '件', sourceType: 'LOCAL_MIRROR' },
|
||||
},
|
||||
asOf: '2026-07-01',
|
||||
maxPriceAgeDays: 30,
|
||||
now: '2026-08-07',
|
||||
})
|
||||
|
||||
expect(result.sameMaterial.total).toBe(20)
|
||||
expect(result.sameMaterial.status).toBe('STALE')
|
||||
expect(result.sameMaterial.lines[0]?.warnings).toContain('价格来源已超过允许的新鲜度')
|
||||
})
|
||||
})
|
||||
1610
furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts
Normal file
1610
furniture-3d-system/src/lib/bom-costing/dynamic-bom-cost-domain.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
||||
export * from './dynamic-bom-cost-domain'
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './dynamic-bom-cost-domain'
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
const NON_BOARD_SUBSTRATE_PATTERN = /(?:纸箱|纸盒|包材|包装|泡沫|珍珠棉|胶带|护角|打包带|气泡膜|塑料袋)/i
|
||||
|
||||
export function normalizeBomMaterialName(value: string) {
|
||||
return value.normalize('NFKC').trim().toLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
export function isNonBoardSubstrate(...values: Array<string | null | undefined>) {
|
||||
return values.some((value) => NON_BOARD_SUBSTRATE_PATTERN.test(value?.normalize('NFKC') ?? ''))
|
||||
}
|
||||
@ -0,0 +1,318 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
bomCostingReferenceRow: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
bomCostingReferenceSyncRun: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
substrate: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { BomCostingReferenceSyncStatus } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { BomCostingScenarioRepository } from './bom-costing-scenario.repository'
|
||||
|
||||
const referenceFindMany = prisma.bomCostingReferenceRow.findMany as jest.Mock
|
||||
const referenceFindFirst = prisma.bomCostingReferenceRow.findFirst as jest.Mock
|
||||
const syncFindFirst = prisma.bomCostingReferenceSyncRun.findFirst as jest.Mock
|
||||
const substrateFindMany = prisma.substrate.findMany as jest.Mock
|
||||
|
||||
describe('BomCostingScenarioRepository', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('requires target-material matches instead of filling substitution candidates with the original material', async () => {
|
||||
referenceFindMany.mockResolvedValue([])
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
await repository.findReferenceCandidates({
|
||||
syncRunId: 'run-success',
|
||||
category: 'BOARD',
|
||||
partName: '桌面板',
|
||||
materialName: '黑胡桃木',
|
||||
materialCode: 'WALNUT',
|
||||
materialSpec: '20 mm',
|
||||
unit: '块',
|
||||
lengthMm: 1_200,
|
||||
widthMm: 600,
|
||||
targetMaterialNames: ['橡胶木'],
|
||||
})
|
||||
|
||||
const query = referenceFindMany.mock.calls[0]?.[0]
|
||||
expect(query.where).toMatchObject({
|
||||
syncRunId: 'run-success',
|
||||
syncRun: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
isSummaryRow: false,
|
||||
unitPrice: { gt: 0 },
|
||||
})
|
||||
const serialized = JSON.stringify(query.where.AND)
|
||||
expect(serialized).toContain('橡胶木')
|
||||
expect(serialized).toContain('桌面板')
|
||||
expect(serialized).toContain('20')
|
||||
expect(serialized).not.toContain('黑胡桃木')
|
||||
expect(serialized).not.toContain('WALNUT')
|
||||
expect(serialized).toContain('960')
|
||||
expect(serialized).toContain('1440')
|
||||
expect(serialized).toContain('480')
|
||||
expect(serialized).toContain('720')
|
||||
})
|
||||
|
||||
it('读取基础资料库中的整板尺寸和整板价格作为裁切输入', async () => {
|
||||
const updatedAt = new Date('2026-08-09T00:00:00.000Z')
|
||||
substrateFindMany.mockResolvedValue([{
|
||||
id: 'substrate-1',
|
||||
code: 'RW-18',
|
||||
name: '橡胶木指接板',
|
||||
metadata: { systemName: '橡胶木' },
|
||||
suggestedPrice: { toNumber: () => 198 },
|
||||
updatedAt,
|
||||
category: { name: '实木板' },
|
||||
environmentGrade: { name: 'E0' },
|
||||
systemNameOption: { name: '橡胶木' },
|
||||
thicknessSpec: {
|
||||
thicknessMm: 18,
|
||||
sheetLengthMm: 2_440,
|
||||
sheetWidthMm: 1_220,
|
||||
},
|
||||
}])
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
await expect(repository.listSubstrateSheetCandidates()).resolves.toEqual([{
|
||||
id: 'substrate-1',
|
||||
code: 'RW-18',
|
||||
name: '橡胶木指接板',
|
||||
categoryName: '实木板',
|
||||
systemName: '橡胶木',
|
||||
environmentGrade: 'E0',
|
||||
aliases: ['RW-18', '橡胶木指接板', '实木板', '橡胶木'],
|
||||
thicknessMm: 18,
|
||||
sheetLengthMm: 2_440,
|
||||
sheetWidthMm: 1_220,
|
||||
fullSheetPrice: 198,
|
||||
updatedAt,
|
||||
}])
|
||||
expect(substrateFindMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { isActive: true, suggestedPrice: { gt: 0 } },
|
||||
}))
|
||||
})
|
||||
|
||||
it('normalizes material aliases and codes into lookup tokens', async () => {
|
||||
referenceFindMany.mockResolvedValue([])
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
await repository.findReferenceCandidates({
|
||||
syncRunId: 'run-success',
|
||||
category: 'BOARD',
|
||||
partName: '桌面板',
|
||||
targetMaterialNames: ['Rubber Wood', '橡胶-木'],
|
||||
})
|
||||
|
||||
const query = referenceFindMany.mock.calls[0]?.[0]
|
||||
const serialized = JSON.stringify(query.where.AND)
|
||||
expect(serialized).toContain('rubberwood')
|
||||
expect(serialized).toContain('橡胶木')
|
||||
})
|
||||
|
||||
it('can restrict related processing prices to the target-material reference SKUs', async () => {
|
||||
referenceFindMany.mockResolvedValue([])
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
await repository.findReferenceCandidates({
|
||||
syncRunId: 'run-success',
|
||||
category: 'PROCESSING',
|
||||
partName: '表面处理',
|
||||
processName: '木蜡油',
|
||||
referenceSkus: ['REF-RUBBERWOOD-1', 'REF-RUBBERWOOD-1', 'REF-RUBBERWOOD-2'],
|
||||
})
|
||||
|
||||
const query = referenceFindMany.mock.calls[0]?.[0]
|
||||
expect(query.where.AND).toEqual(expect.arrayContaining([
|
||||
{ sourceSku: { in: ['REF-RUBBERWOOD-1', 'REF-RUBBERWOOD-2'] } },
|
||||
]))
|
||||
expect(JSON.stringify(query.where.AND)).toContain('木蜡油')
|
||||
})
|
||||
|
||||
it('accepts swapped length and width inside the bounded dimension window', async () => {
|
||||
referenceFindMany.mockResolvedValue([])
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
await repository.findReferenceCandidates({
|
||||
syncRunId: 'run-success',
|
||||
category: 'BOARD',
|
||||
partName: '桌面板',
|
||||
lengthMm: 1_200,
|
||||
widthMm: 600,
|
||||
targetMaterialNames: ['橡胶木'],
|
||||
})
|
||||
|
||||
const serialized = JSON.stringify(referenceFindMany.mock.calls[0]?.[0]?.where?.AND)
|
||||
expect(serialized).toContain('"lengthMm":{"gte":480,"lte":720}')
|
||||
expect(serialized).toContain('"widthMm":{"gte":960,"lte":1440}')
|
||||
})
|
||||
|
||||
it('falls back to real processing rows without dimensions after a dimension miss', async () => {
|
||||
referenceFindMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'processing-row-1',
|
||||
sourceRowId: 'PROCESS-1',
|
||||
sourceSku: 'REF-1',
|
||||
bomVersionKey: 'V1',
|
||||
category: '加工',
|
||||
categoryCode: 'PROCESSING',
|
||||
partNumber: 'PROCESS-CUT-SAND',
|
||||
partName: '桌面板加工',
|
||||
materialName: '黑胡桃木',
|
||||
materialCode: 'BLACK_WALNUT',
|
||||
materialSpec: '20mm',
|
||||
processName: '开料打磨',
|
||||
unit: 'm2',
|
||||
lengthMm: null,
|
||||
widthMm: null,
|
||||
quantity: 5,
|
||||
coefficient: 1,
|
||||
rawCoefficient: '1',
|
||||
areaSqm: null,
|
||||
edgeLengthM: null,
|
||||
unitPrice: 15,
|
||||
amount: 75,
|
||||
sourceCreatedAt: new Date(),
|
||||
sourceFingerprint: 'processing-fingerprint',
|
||||
syncedAt: new Date(),
|
||||
}])
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
const rows = await repository.findReferenceCandidates({
|
||||
syncRunId: 'run-success',
|
||||
category: 'PROCESSING',
|
||||
partName: '桌面板加工',
|
||||
materialName: '黑胡桃木',
|
||||
materialCode: 'BLACK_WALNUT',
|
||||
materialSpec: '20mm',
|
||||
processName: '开料打磨',
|
||||
unit: 'm2',
|
||||
lengthMm: 1_000,
|
||||
widthMm: 1_000,
|
||||
targetMaterialNames: ['黑胡桃木'],
|
||||
})
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
const relaxedQuery = referenceFindMany.mock.calls[1]?.[0]
|
||||
expect(JSON.stringify(relaxedQuery.where.AND)).not.toContain('lengthMm')
|
||||
expect(JSON.stringify(relaxedQuery.where.AND)).toContain('开料打磨')
|
||||
})
|
||||
|
||||
it('exposes only the newest successful snapshot even when a newer failed run exists', async () => {
|
||||
const successful = {
|
||||
id: 'run-success',
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
completedAt: new Date(),
|
||||
}
|
||||
const failed = {
|
||||
id: 'run-failed',
|
||||
status: BomCostingReferenceSyncStatus.FAILED,
|
||||
completedAt: new Date(),
|
||||
}
|
||||
syncFindFirst
|
||||
.mockResolvedValueOnce(successful)
|
||||
.mockResolvedValueOnce(failed)
|
||||
referenceFindFirst
|
||||
.mockResolvedValueOnce({ syncRunId: successful.id })
|
||||
.mockResolvedValueOnce(null)
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
const result = await repository.findLatestSyncStatuses()
|
||||
|
||||
expect(syncFindFirst).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
where: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
}))
|
||||
expect(referenceFindFirst).toHaveBeenCalledWith({
|
||||
where: { syncRunId: successful.id },
|
||||
select: { syncRunId: true },
|
||||
})
|
||||
expect(referenceFindFirst).toHaveBeenCalledWith({
|
||||
where: { syncRunId: { not: successful.id } },
|
||||
select: { syncRunId: true },
|
||||
})
|
||||
expect(result.latestSuccessful).toBe(successful)
|
||||
expect(result.latestFailed).toBe(failed)
|
||||
expect(result.mirrorState).toBe('READY')
|
||||
expect(result.mirrorRunIds).toEqual(['run-success'])
|
||||
})
|
||||
|
||||
it('marks the mirror mixed when a failed or old batch left rows behind', async () => {
|
||||
const successful = {
|
||||
id: 'run-success',
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
completedAt: new Date(),
|
||||
}
|
||||
syncFindFirst
|
||||
.mockResolvedValueOnce(successful)
|
||||
.mockResolvedValueOnce(null)
|
||||
referenceFindFirst
|
||||
.mockResolvedValueOnce({ syncRunId: successful.id })
|
||||
.mockResolvedValueOnce({ syncRunId: 'run-failed' })
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
const result = await repository.findLatestSyncStatuses()
|
||||
|
||||
expect(result.mirrorState).toBe('MIXED')
|
||||
expect(result.mirrorRunIds).toEqual(['run-success', 'run-failed'])
|
||||
})
|
||||
|
||||
it('uses the completed mirror time for freshness, not a historical source creation timestamp', async () => {
|
||||
const previousMaxAgeDays = process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS
|
||||
process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS = '2'
|
||||
try {
|
||||
syncFindFirst
|
||||
.mockResolvedValueOnce({
|
||||
id: 'run-success',
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
completedAt: new Date(),
|
||||
sourceAsOf: new Date(Date.now() - 10 * 86_400_000),
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
referenceFindFirst
|
||||
.mockResolvedValueOnce({ syncRunId: 'run-success' })
|
||||
.mockResolvedValueOnce(null)
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
await expect(repository.findLatestSyncStatuses()).resolves.toMatchObject({ mirrorState: 'READY' })
|
||||
} finally {
|
||||
if (previousMaxAgeDays === undefined) delete process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS
|
||||
else process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS = previousMaxAgeDays
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the mirror stale after the last successful completion ages out', async () => {
|
||||
const previousMaxAgeDays = process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS
|
||||
process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS = '2'
|
||||
try {
|
||||
syncFindFirst
|
||||
.mockResolvedValueOnce({
|
||||
id: 'run-success',
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
completedAt: new Date(Date.now() - 10 * 86_400_000),
|
||||
sourceAsOf: new Date(Date.now() - 20 * 86_400_000),
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
referenceFindFirst
|
||||
.mockResolvedValueOnce({ syncRunId: 'run-success' })
|
||||
.mockResolvedValueOnce(null)
|
||||
const repository = new BomCostingScenarioRepository()
|
||||
|
||||
await expect(repository.findLatestSyncStatuses()).resolves.toMatchObject({ mirrorState: 'STALE' })
|
||||
} finally {
|
||||
if (previousMaxAgeDays === undefined) delete process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS
|
||||
else process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS = previousMaxAgeDays
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,571 @@
|
||||
import { BomCostingReferenceSyncStatus, Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isNonBoardSubstrate } from '@/lib/bom-costing/material-family'
|
||||
|
||||
export const bomCostingScenarioInclude = Prisma.validator<Prisma.BomCostingScenarioInclude>()({
|
||||
lines: { orderBy: { createdAt: 'asc' } },
|
||||
syncRun: { select: { id: true, status: true, completedAt: true, sourceAsOf: true, batchKey: true } },
|
||||
targetMaterial: { select: { id: true, code: true, name: true, category: true, enabled: true } },
|
||||
})
|
||||
|
||||
export type BomCostingScenarioRecord = Prisma.BomCostingScenarioGetPayload<{
|
||||
include: typeof bomCostingScenarioInclude
|
||||
}>
|
||||
|
||||
export type BomCostingScenarioReferenceCandidate = {
|
||||
id: string
|
||||
sourceRowId: string
|
||||
sourceSku: string
|
||||
bomVersionKey: string
|
||||
category: string
|
||||
categoryCode: string
|
||||
partNumber: string
|
||||
partName: string
|
||||
materialName: string
|
||||
materialCode: string
|
||||
materialSpec: string
|
||||
processName: string
|
||||
unit: string
|
||||
quantity: Prisma.Decimal | null
|
||||
coefficient: Prisma.Decimal | null
|
||||
rawCoefficient: string
|
||||
lengthMm: Prisma.Decimal | null
|
||||
widthMm: Prisma.Decimal | null
|
||||
areaSqm: Prisma.Decimal | null
|
||||
edgeLengthM: Prisma.Decimal | null
|
||||
unitPrice: Prisma.Decimal | null
|
||||
amount: Prisma.Decimal | null
|
||||
sourceCreatedAt: Date | null
|
||||
sourceFingerprint: string
|
||||
syncedAt: Date
|
||||
}
|
||||
|
||||
export type BomCostingSubstrateSheetCandidate = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
categoryName: string
|
||||
systemName: string
|
||||
environmentGrade: string
|
||||
aliases: string[]
|
||||
thicknessMm: number
|
||||
sheetLengthMm: number
|
||||
sheetWidthMm: number
|
||||
fullSheetPrice: number
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
function nonEmptyTokens(values: Array<string | null | undefined>) {
|
||||
return Array.from(new Set(values.flatMap((value) => {
|
||||
const text = value?.normalize('NFKC').trim() ?? ''
|
||||
if (!text) return []
|
||||
const compact = text.toLowerCase().replace(/[\s_\-./\\::]+/g, '')
|
||||
return [
|
||||
...text.split(/\s+/),
|
||||
compact,
|
||||
].filter((token) => token.length >= 2)
|
||||
}))).slice(0, 16)
|
||||
}
|
||||
|
||||
function categoryCodes(category: string) {
|
||||
const normalized = category.trim().toUpperCase()
|
||||
const map: Record<string, string[]> = {
|
||||
BOARD: ['BOARD', '板材', '基材', '木料', '木材', '亚克力板', '玻璃', '三合板', 'MATERIAL'],
|
||||
STEEL: ['STEEL', '钢材', '钢架', '钢管', '铁件', '金属'],
|
||||
ACCESSORY: ['ACCESSORY', '配件', '五金', '辅料', '布艺软包', '胶水', '拉杆', '铁撑', '木腿'],
|
||||
PACKAGING: ['PACKAGING', '包材', '包装', '泡沫', '纸箱', '打包带', '护角', '扎带', '吸塑'],
|
||||
PROCESSING: ['PROCESSING', '加工', '封边', '喷塑', '木蜡油', '工艺'],
|
||||
LABOR: ['LABOR', '人工', '杂费'],
|
||||
}
|
||||
return map[normalized] ?? [category]
|
||||
}
|
||||
|
||||
function positiveDimension(value: number | null | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
function dimensionFilters(
|
||||
input: { lengthMm?: number | null; widthMm?: number | null },
|
||||
): Prisma.BomCostingReferenceRowWhereInput[] {
|
||||
const lengthMm = positiveDimension(input.lengthMm)
|
||||
const widthMm = positiveDimension(input.widthMm)
|
||||
const range = (value: number) => ({ gte: value * 0.8, lte: value * 1.2 })
|
||||
if (lengthMm !== null && widthMm !== null) {
|
||||
return [{
|
||||
OR: [
|
||||
{ AND: [{ lengthMm: range(lengthMm) }, { widthMm: range(widthMm) }] },
|
||||
{ AND: [{ lengthMm: range(widthMm) }, { widthMm: range(lengthMm) }] },
|
||||
],
|
||||
}]
|
||||
}
|
||||
const dimension = lengthMm ?? widthMm
|
||||
if (dimension === null) return []
|
||||
return [{ OR: [{ lengthMm: range(dimension) }, { widthMm: range(dimension) }] }]
|
||||
}
|
||||
|
||||
function optionalDate(value: unknown) {
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function readSubstrateSystemName(value: Prisma.JsonValue | null) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
|
||||
const systemName = (value as Record<string, unknown>).systemName
|
||||
return typeof systemName === 'string' ? systemName.trim() : ''
|
||||
}
|
||||
|
||||
type ScenarioCostResultInput = {
|
||||
status: string
|
||||
total: number | null
|
||||
knownCost: number
|
||||
confidence: number | null
|
||||
lossCost: number | null
|
||||
categoryTotals: Record<string, number | null>
|
||||
unresolvedCount: number
|
||||
warning: string
|
||||
lines: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
async function createScenarioRecord(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
caseId: string
|
||||
skuId: string
|
||||
kind: 'SAME_MATERIAL' | 'MATERIAL_SUBSTITUTION'
|
||||
targetMaterialId: string | null
|
||||
targetMaterialName: string
|
||||
revision: number
|
||||
syncRunId: string | null
|
||||
dataAsOf: Date | null
|
||||
competitorPrice: number | null
|
||||
rulesetVersion: string
|
||||
result: ScenarioCostResultInput
|
||||
},
|
||||
) {
|
||||
const priceDelta = input.result.total !== null && input.competitorPrice !== null
|
||||
? new Prisma.Decimal(input.result.total).minus(input.competitorPrice).toDecimalPlaces(2)
|
||||
: null
|
||||
const scenario = await tx.bomCostingScenario.create({
|
||||
data: {
|
||||
caseId: input.caseId,
|
||||
skuId: input.skuId,
|
||||
kind: input.kind,
|
||||
scenarioKey: input.kind === 'SAME_MATERIAL' ? '' : input.targetMaterialId ?? '',
|
||||
revision: input.revision,
|
||||
isCurrent: true,
|
||||
targetMaterialId: input.kind === 'MATERIAL_SUBSTITUTION' ? input.targetMaterialId : null,
|
||||
targetMaterialName: input.kind === 'MATERIAL_SUBSTITUTION' ? input.targetMaterialName : '',
|
||||
status: input.result.status as never,
|
||||
totalCost: input.result.total,
|
||||
knownCost: input.result.knownCost,
|
||||
materialCost: input.result.categoryTotals.MATERIAL ?? null,
|
||||
processingCost: input.result.categoryTotals.PROCESSING ?? null,
|
||||
laborCost: input.result.categoryTotals.LABOR ?? null,
|
||||
packagingCost: input.result.categoryTotals.PACKAGING ?? null,
|
||||
lossCost: input.result.lossCost,
|
||||
competitorPrice: input.competitorPrice,
|
||||
priceDelta,
|
||||
warning: input.result.warning,
|
||||
unresolvedCount: input.result.unresolvedCount,
|
||||
confidence: input.result.confidence,
|
||||
dataAsOf: input.dataAsOf,
|
||||
syncRunId: input.syncRunId,
|
||||
rulesetVersion: input.rulesetVersion,
|
||||
},
|
||||
include: bomCostingScenarioInclude,
|
||||
})
|
||||
if (input.result.lines.length > 0) {
|
||||
await tx.bomCostingScenarioLine.createMany({
|
||||
data: input.result.lines.map((line) => ({
|
||||
scenarioId: scenario.id,
|
||||
sourceLineId: typeof line.sourceLineId === 'string' ? line.sourceLineId : null,
|
||||
sourceRowId: String(line.sourceRowId ?? ''),
|
||||
category: String(line.category ?? ''),
|
||||
partName: String(line.partName ?? ''),
|
||||
originalMaterial: String(line.originalMaterial ?? ''),
|
||||
appliedMaterial: String(line.appliedMaterial ?? ''),
|
||||
materialSpec: String(line.materialSpec ?? ''),
|
||||
unit: String(line.unit ?? ''),
|
||||
quantity: typeof line.quantity === 'number' ? line.quantity : null,
|
||||
coefficient: typeof line.coefficient === 'number' ? line.coefficient : null,
|
||||
pricingQuantity: typeof line.pricingQuantity === 'number' ? line.pricingQuantity : null,
|
||||
unitPrice: typeof line.unitPrice === 'number' ? line.unitPrice : null,
|
||||
subtotal: typeof line.subtotal === 'number' ? line.subtotal : null,
|
||||
priceSource: String(line.priceSource ?? ''),
|
||||
sourceSku: String(line.sourceSku ?? ''),
|
||||
referenceSkus: Array.isArray(line.referenceSkus)
|
||||
? line.referenceSkus as Prisma.InputJsonValue
|
||||
: [],
|
||||
referenceRowIds: Array.isArray(line.referenceRowIds)
|
||||
? line.referenceRowIds as Prisma.InputJsonValue
|
||||
: [],
|
||||
sourceFingerprint: String(line.sourceFingerprint ?? ''),
|
||||
sourceCreatedAt: optionalDate(line.sourceCreatedAt),
|
||||
sourceSyncedAt: optionalDate(line.sourceSyncedAt),
|
||||
calculationRule: String(line.calculationRule ?? ''),
|
||||
confidence: typeof line.confidence === 'number' ? line.confidence : null,
|
||||
status: String(line.status ?? 'INSUFFICIENT_DATA') as never,
|
||||
warning: String(line.warning ?? ''),
|
||||
rawSnapshot: line.rawSnapshot as Prisma.InputJsonValue | undefined,
|
||||
})),
|
||||
})
|
||||
}
|
||||
return tx.bomCostingScenario.findUnique({
|
||||
where: { id: scenario.id },
|
||||
include: bomCostingScenarioInclude,
|
||||
})
|
||||
}
|
||||
|
||||
export class BomCostingScenarioRepository {
|
||||
async findSkuForScenario(input: { caseId: string; skuId: string }) {
|
||||
return prisma.bomCostingSku.findFirst({
|
||||
where: { id: input.skuId, caseId: input.caseId, case: { deletedAt: null } },
|
||||
include: { bomLines: { orderBy: { sortOrder: 'asc' } } },
|
||||
})
|
||||
}
|
||||
|
||||
async listEnabledMaterials() {
|
||||
return prisma.bomCostingMaterialOption.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
async listSubstrateSheetCandidates(): Promise<BomCostingSubstrateSheetCandidate[]> {
|
||||
const rows = await prisma.substrate.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
suggestedPrice: { gt: 0 },
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
environmentGrade: true,
|
||||
systemNameOption: true,
|
||||
thicknessSpec: true,
|
||||
},
|
||||
orderBy: [{ updatedAt: 'desc' }, { code: 'asc' }],
|
||||
})
|
||||
return rows.flatMap((row) => {
|
||||
const fullSheetPrice = row.suggestedPrice?.toNumber() ?? null
|
||||
const thicknessMm = row.thicknessSpec.thicknessMm
|
||||
const sheetLengthMm = row.thicknessSpec.sheetLengthMm
|
||||
const sheetWidthMm = row.thicknessSpec.sheetWidthMm
|
||||
if (
|
||||
fullSheetPrice === null ||
|
||||
fullSheetPrice <= 0 ||
|
||||
thicknessMm <= 0 ||
|
||||
sheetLengthMm <= 0 ||
|
||||
sheetWidthMm <= 0
|
||||
) {
|
||||
return []
|
||||
}
|
||||
const metadataSystemName = readSubstrateSystemName(row.metadata)
|
||||
const systemName = metadataSystemName || row.systemNameOption?.name || ''
|
||||
if (isNonBoardSubstrate(row.category.name, systemName, row.name)) return []
|
||||
return [{
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
categoryName: row.category.name,
|
||||
systemName,
|
||||
environmentGrade: row.environmentGrade.name,
|
||||
aliases: Array.from(new Set([
|
||||
row.code,
|
||||
row.name,
|
||||
row.category.name,
|
||||
row.systemNameOption?.name ?? '',
|
||||
metadataSystemName,
|
||||
].filter(Boolean))),
|
||||
thicknessMm,
|
||||
sheetLengthMm,
|
||||
sheetWidthMm,
|
||||
fullSheetPrice,
|
||||
updatedAt: row.updatedAt,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async listCaseSkuIds(caseId: string) {
|
||||
return prisma.bomCostingSku.findMany({
|
||||
where: { caseId, case: { deletedAt: null } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, externalSkuId: true },
|
||||
})
|
||||
}
|
||||
|
||||
async findLatestSuccessfulSync() {
|
||||
return prisma.bomCostingReferenceSyncRun.findFirst({
|
||||
where: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
}
|
||||
|
||||
async findLatestSyncStatuses(): Promise<{
|
||||
latestSuccessful: Awaited<ReturnType<typeof prisma.bomCostingReferenceSyncRun.findFirst>>
|
||||
latestFailed: Awaited<ReturnType<typeof prisma.bomCostingReferenceSyncRun.findFirst>>
|
||||
mirrorState: 'EMPTY' | 'READY' | 'MIXED' | 'STALE'
|
||||
mirrorRunIds: string[]
|
||||
}> {
|
||||
const [latestSuccessful, latestFailed] = await Promise.all([
|
||||
prisma.bomCostingReferenceSyncRun.findFirst({
|
||||
where: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
}),
|
||||
prisma.bomCostingReferenceSyncRun.findFirst({
|
||||
where: { status: BomCostingReferenceSyncStatus.FAILED },
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
}),
|
||||
])
|
||||
const [visibleRow, foreignRow] = latestSuccessful
|
||||
? await Promise.all([
|
||||
prisma.bomCostingReferenceRow.findFirst({
|
||||
where: { syncRunId: latestSuccessful.id },
|
||||
select: { syncRunId: true },
|
||||
}),
|
||||
prisma.bomCostingReferenceRow.findFirst({
|
||||
where: { syncRunId: { not: latestSuccessful.id } },
|
||||
select: { syncRunId: true },
|
||||
}),
|
||||
])
|
||||
: [
|
||||
null,
|
||||
await prisma.bomCostingReferenceRow.findFirst({
|
||||
select: { syncRunId: true },
|
||||
}),
|
||||
]
|
||||
const ageDays = Number(process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS ?? '2')
|
||||
// sourceAsOf is the maximum historical timestamp visible in the upstream
|
||||
// view. Until the source provides a real update marker, freshness is based
|
||||
// exclusively on the successful local mirror completion time.
|
||||
const mirroredAt = optionalDate(latestSuccessful?.completedAt)
|
||||
const isFresh = mirroredAt !== null && Number.isFinite(ageDays) &&
|
||||
Date.now() - mirroredAt.getTime() <= Math.max(0, ageDays) * 86_400_000
|
||||
const mirrorRunIds = Array.from(new Set(
|
||||
[visibleRow?.syncRunId, foreignRow?.syncRunId].filter((value): value is string => Boolean(value)),
|
||||
))
|
||||
const mirrorState: 'EMPTY' | 'READY' | 'MIXED' | 'STALE' = mirrorRunIds.length === 0
|
||||
? 'EMPTY'
|
||||
: !latestSuccessful || !visibleRow || foreignRow
|
||||
? 'MIXED'
|
||||
: isFresh ? 'READY' : 'STALE'
|
||||
return {
|
||||
latestSuccessful,
|
||||
latestFailed,
|
||||
mirrorState,
|
||||
mirrorRunIds,
|
||||
}
|
||||
}
|
||||
|
||||
async findReferenceCandidates(input: {
|
||||
syncRunId: string
|
||||
category: string
|
||||
partName?: string | null
|
||||
materialName?: string | null
|
||||
materialCode?: string | null
|
||||
materialSpec?: string | null
|
||||
processName?: string | null
|
||||
unit?: string | null
|
||||
lengthMm?: number | null
|
||||
widthMm?: number | null
|
||||
targetMaterialNames?: string[]
|
||||
referenceSkus?: string[]
|
||||
limit?: number
|
||||
}): Promise<BomCostingScenarioReferenceCandidate[]> {
|
||||
const categorySet = categoryCodes(input.category)
|
||||
const sourceContextTokens = nonEmptyTokens([
|
||||
input.materialName,
|
||||
input.materialCode,
|
||||
input.partName,
|
||||
input.materialSpec,
|
||||
input.processName,
|
||||
])
|
||||
const structuralTokens = nonEmptyTokens([
|
||||
input.partName,
|
||||
input.materialSpec,
|
||||
input.processName,
|
||||
])
|
||||
const textFilters = (tokens: string[]): Prisma.BomCostingReferenceRowWhereInput[] => tokens.flatMap((token) => [
|
||||
{ materialName: { contains: token, mode: 'insensitive' as const } },
|
||||
{ partName: { contains: token, mode: 'insensitive' as const } },
|
||||
{ partNumber: { contains: token, mode: 'insensitive' as const } },
|
||||
{ materialCode: { contains: token, mode: 'insensitive' as const } },
|
||||
{ materialSpec: { contains: token, mode: 'insensitive' as const } },
|
||||
{ processName: { contains: token, mode: 'insensitive' as const } },
|
||||
])
|
||||
const sourceContextTextOr = textFilters(sourceContextTokens)
|
||||
const structuralTextOr = textFilters(structuralTokens)
|
||||
const targetTokens = nonEmptyTokens(input.targetMaterialNames ?? [])
|
||||
const targetExactOr: Prisma.BomCostingReferenceRowWhereInput[] = targetTokens.flatMap((token) => [
|
||||
{ materialName: { equals: token } },
|
||||
{ materialCode: { equals: token.toUpperCase() } },
|
||||
{ partNumber: { equals: token.toUpperCase() } },
|
||||
])
|
||||
const targetTextOr: Prisma.BomCostingReferenceRowWhereInput[] = targetTokens.flatMap((token) => [
|
||||
{ materialName: { contains: token, mode: 'insensitive' as const } },
|
||||
{ materialCode: { contains: token, mode: 'insensitive' as const } },
|
||||
{ partNumber: { contains: token, mode: 'insensitive' as const } },
|
||||
])
|
||||
const categoryOr: Prisma.BomCostingReferenceRowWhereInput[] = [
|
||||
{ categoryCode: { in: categorySet } },
|
||||
{ category: { in: categorySet } },
|
||||
]
|
||||
const referenceSkus = Array.from(new Set(
|
||||
(input.referenceSkus ?? []).map((value) => value.trim()).filter(Boolean),
|
||||
)).slice(0, 50)
|
||||
const dimensionAnd = dimensionFilters(input)
|
||||
const baseAndWithoutDimensions: Prisma.BomCostingReferenceRowWhereInput[] = [
|
||||
{ OR: categoryOr },
|
||||
...(referenceSkus.length > 0
|
||||
? [{ sourceSku: { in: referenceSkus } }]
|
||||
: []),
|
||||
]
|
||||
const contextTextOr = targetTextOr.length > 0 ? structuralTextOr : sourceContextTextOr
|
||||
if (targetTextOr.length === 0 && contextTextOr.length === 0 && referenceSkus.length === 0) {
|
||||
return []
|
||||
}
|
||||
const take = Math.min(Math.max(input.limit ?? 80, 1), 200)
|
||||
const findRows = (
|
||||
materialOr: Prisma.BomCostingReferenceRowWhereInput[],
|
||||
extraAnd: Prisma.BomCostingReferenceRowWhereInput[],
|
||||
includeDimensions = true,
|
||||
) => (
|
||||
prisma.bomCostingReferenceRow.findMany({
|
||||
where: {
|
||||
syncRunId: input.syncRunId,
|
||||
// The service resolves this ID from the newest successful run. Keep
|
||||
// the relation guard here as a second boundary so a failed/running
|
||||
// batch can never become a price source through a direct repository call.
|
||||
syncRun: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
isSummaryRow: false,
|
||||
unitPrice: { gt: 0 },
|
||||
AND: [
|
||||
...baseAndWithoutDimensions,
|
||||
...(includeDimensions ? dimensionAnd : []),
|
||||
...(materialOr.length > 0 ? [{ OR: materialOr }] : []),
|
||||
...extraAnd,
|
||||
],
|
||||
},
|
||||
orderBy: [
|
||||
{ sourceCreatedAt: 'desc' as const },
|
||||
{ syncedAt: 'desc' as const },
|
||||
],
|
||||
take,
|
||||
})
|
||||
)
|
||||
|
||||
const searchRows = async (
|
||||
materialOr: Prisma.BomCostingReferenceRowWhereInput[],
|
||||
extraAnd: Prisma.BomCostingReferenceRowWhereInput[],
|
||||
) => {
|
||||
const dimensionMatched = await findRows(materialOr, extraAnd, true)
|
||||
if (dimensionMatched.length > 0 || dimensionAnd.length === 0) return dimensionMatched
|
||||
|
||||
// Processing, labour and packaging rows often have no physical
|
||||
// dimensions even when the competitor row does. Keep those real rows
|
||||
// as a documented lower-confidence fallback instead of treating the
|
||||
// missing dimension as missing price data.
|
||||
return findRows(materialOr, extraAnd, false)
|
||||
}
|
||||
|
||||
// Search the selected material together with the competitor's structural
|
||||
// facts first. Otherwise the global recency limit can fill up with
|
||||
// unrelated rows of the same material before an exact part is considered.
|
||||
const contextAnd = contextTextOr.length > 0 ? [{ OR: contextTextOr }] : []
|
||||
let rows = await searchRows(targetExactOr, contextAnd)
|
||||
if (rows.length === 0 && targetTextOr.length > 0) {
|
||||
rows = await searchRows(targetTextOr, contextAnd)
|
||||
}
|
||||
if (rows.length === 0 && targetTextOr.length > 0 && contextTextOr.length > 0) {
|
||||
// A material-only fallback is still real company data, but the scoring
|
||||
// layer will keep it provisional because no structural term matched.
|
||||
rows = await searchRows(targetExactOr, [])
|
||||
if (rows.length === 0) rows = await searchRows(targetTextOr, [])
|
||||
}
|
||||
return rows as unknown as BomCostingScenarioReferenceCandidate[]
|
||||
}
|
||||
|
||||
async listCurrentScenarios(input: { caseId: string; skuId?: string }) {
|
||||
return prisma.bomCostingScenario.findMany({
|
||||
where: { caseId: input.caseId, skuId: input.skuId, isCurrent: true },
|
||||
include: bomCostingScenarioInclude,
|
||||
orderBy: [{ skuId: 'asc' }, { kind: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
async nextRevision(tx: Prisma.TransactionClient, skuId: string) {
|
||||
const latest = await tx.bomCostingScenario.findFirst({
|
||||
where: { skuId },
|
||||
orderBy: { revision: 'desc' },
|
||||
select: { revision: true },
|
||||
})
|
||||
return (latest?.revision ?? 0) + 1
|
||||
}
|
||||
|
||||
async saveScenarioPair(input: {
|
||||
caseId: string
|
||||
skuId: string
|
||||
targetMaterialId: string
|
||||
targetMaterialName: string
|
||||
syncRunId: string | null
|
||||
dataAsOf: Date | null
|
||||
competitorPrice: number | null
|
||||
rulesetVersion: string
|
||||
sameMaterial: ScenarioCostResultInput
|
||||
substitution: ScenarioCostResultInput
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const revision = await this.nextRevision(tx, input.skuId)
|
||||
await tx.bomCostingScenario.updateMany({
|
||||
where: { skuId: input.skuId, isCurrent: true },
|
||||
data: { isCurrent: false },
|
||||
})
|
||||
return {
|
||||
sameMaterial: await createScenarioRecord(tx, {
|
||||
...input,
|
||||
kind: 'SAME_MATERIAL',
|
||||
targetMaterialId: null,
|
||||
targetMaterialName: '',
|
||||
revision,
|
||||
result: input.sameMaterial,
|
||||
}),
|
||||
substitution: await createScenarioRecord(tx, {
|
||||
...input,
|
||||
kind: 'MATERIAL_SUBSTITUTION',
|
||||
revision,
|
||||
result: input.substitution,
|
||||
}),
|
||||
revision,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async saveSameMaterialScenario(input: {
|
||||
caseId: string
|
||||
skuId: string
|
||||
syncRunId: string | null
|
||||
dataAsOf: Date | null
|
||||
competitorPrice: number | null
|
||||
rulesetVersion: string
|
||||
sameMaterial: ScenarioCostResultInput
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const revision = await this.nextRevision(tx, input.skuId)
|
||||
await tx.bomCostingScenario.updateMany({
|
||||
where: { skuId: input.skuId, isCurrent: true },
|
||||
data: { isCurrent: false },
|
||||
})
|
||||
return createScenarioRecord(tx, {
|
||||
...input,
|
||||
kind: 'SAME_MATERIAL',
|
||||
targetMaterialId: null,
|
||||
targetMaterialName: '',
|
||||
revision,
|
||||
result: input.sameMaterial,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const bomCostingScenarioRepository = new BomCostingScenarioRepository()
|
||||
@ -366,6 +366,12 @@ export class BomCostingRepository {
|
||||
name: true,
|
||||
estimatedCost: true,
|
||||
costCalculatedAt: true,
|
||||
scenarios: {
|
||||
where: { kind: 'SAME_MATERIAL', isCurrent: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 1,
|
||||
select: { totalCost: true },
|
||||
},
|
||||
bomLines: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
@ -1561,6 +1567,7 @@ export class BomCostingRepository {
|
||||
durationMs: number
|
||||
analysisSchemaVersion: string
|
||||
completedMessage?: string
|
||||
deferReady?: boolean
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.bomCostingSku.deleteMany({
|
||||
@ -1627,22 +1634,30 @@ export class BomCostingRepository {
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
const deferred = input.deferReady === true
|
||||
const caseStatus = deferred
|
||||
? BomCostingCaseStatus.ANALYZING
|
||||
: BomCostingCaseStatus.READY
|
||||
const progress = deferred ? 97 : 100
|
||||
const message = input.completedMessage ?? (deferred
|
||||
? `SKU 结构已就绪,共 ${input.skus.length} 个 SKU;正在生成公司成本`
|
||||
: `核算完成,共 ${input.skus.length} 个 SKU`)
|
||||
await tx.bomCostingCase.update({
|
||||
where: { id: input.caseId },
|
||||
data: {
|
||||
title: input.title,
|
||||
analysisSchemaVersion: input.analysisSchemaVersion,
|
||||
status: BomCostingCaseStatus.READY,
|
||||
progress: 100,
|
||||
statusMessage: input.completedMessage ?? `核算完成,共 ${input.skus.length} 个 SKU`,
|
||||
status: caseStatus,
|
||||
progress,
|
||||
statusMessage: message,
|
||||
rawAnalysisSnapshot: input.analysis,
|
||||
processingFinishedAt: new Date(),
|
||||
processingFinishedAt: deferred ? null : new Date(),
|
||||
taskEvents: {
|
||||
create: {
|
||||
eventType: 'RESULT_SAVED',
|
||||
status: BomCostingCaseStatus.READY,
|
||||
progress: 100,
|
||||
message: input.completedMessage ?? `核算完成,共 ${input.skus.length} 个 SKU`,
|
||||
status: caseStatus,
|
||||
progress,
|
||||
message,
|
||||
payload: input.eventPayload,
|
||||
},
|
||||
},
|
||||
@ -1654,6 +1669,34 @@ export class BomCostingRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async finalizeCaseDynamicCosting(input: {
|
||||
caseId: string
|
||||
message: string
|
||||
payload: Prisma.InputJsonValue
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.bomCostingCase.update({
|
||||
where: { id: input.caseId },
|
||||
data: {
|
||||
status: BomCostingCaseStatus.READY,
|
||||
progress: 100,
|
||||
statusMessage: input.message,
|
||||
processingFinishedAt: new Date(),
|
||||
},
|
||||
})
|
||||
return tx.bomCostingTaskEvent.create({
|
||||
data: {
|
||||
caseId: input.caseId,
|
||||
eventType: 'DYNAMIC_COST_COMPLETED',
|
||||
status: BomCostingCaseStatus.READY,
|
||||
progress: 100,
|
||||
message: input.message,
|
||||
payload: input.payload,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async failCase(input: {
|
||||
caseId: string
|
||||
analysisRunId: string | null
|
||||
|
||||
@ -64,15 +64,15 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务
|
||||
1. 只输出一个 JSON 对象,不要 Markdown、解释、代码围栏或额外文字。
|
||||
2. 必须为输入中的每一个 SKU 输出且只输出一条绑定记录,externalSkuId 必须逐字保持一致。
|
||||
3. 不得把商品售价当作成本,不得根据售价倒推材料价格。
|
||||
4. 产品平台资料库价格是正式匹配的第一优先级;但为了快速形成预估成本,每一条 BOM 都必须填写保守的 estimatedUnitPrice 作为资料库未唯一匹配时的备用估价。不得使用商品售价倒推。
|
||||
5. estimatedUnitPrice 单位必须与 unit 一致。BOARD 有单块尺寸时优先使用平方米计价,STEEL 有长度时优先使用米计价;尺寸不足时可以使用“件”或“套”的保守整件估价,不能把缺失尺寸伪装成精确测量。证据越弱,confidence 越低。
|
||||
4. 正式价格只能来自公司基础资料库或历史 BOM 本地镜像。estimatedUnitPrice 若输出,仅作为模型分析对照,不会进入正式成本结果;不得使用商品售价倒推。
|
||||
5. 优先提取可核验的部件尺寸、数量、单位、材质和工艺。不能把缺失尺寸伪装成精确测量,证据越弱,confidence 越低。
|
||||
6. 尺寸统一使用毫米,重量统一使用千克;每条 SKU 必须返回 dimensions 对象,没有尺寸证据时返回空对象 {},对象内没有证据的可选字段直接省略,不得编造精确数值。
|
||||
7. skuTruth 是唯一允许核算的 SKU 列表:不得组合、拆分、补造或省略 SKU。每个输入行都是一条独立平台规格,哪怕图片相同也必须独立输出。
|
||||
8. structureGroupKey 只能复用于材料、尺寸、是否含镜、部件、功能和包装均完全相同的 SKU。颜色或不影响物料的文案可以复用;木材等级/材种、长宽高、是否带镜、是否含柜体或安装部件等任一差异都必须使用不同结构组,并在对应 BOM 中反映差异。
|
||||
9. BOM 分类只能是 BOARD、STEEL、ACCESSORY、PACKAGING、PROCESSING、LABOR。
|
||||
10. evidence 必须说明依据;引用图片时只能照抄逐图事实中的 url(这是系统图片引用,不得编造、改写或填写其他图片地址)。
|
||||
11. confidence 必须在 0 到 1 之间。
|
||||
12. BOARD 的 lengthMm/widthMm 表示单块板件长宽,heightMm 只能表示板厚;没有板厚证据时必须省略 heightMm,并在 evidence.note 写明“板厚待确认”,不能把整机高度或部件外形高度填成板厚。
|
||||
12. BOARD 的 lengthMm/widthMm 表示单块板件长宽,heightMm 只能表示板厚;来源明确标注板厚时,同时把厚度写入 materialSpec。没有板厚证据时必须省略 heightMm,并在 evidence.note 写明“板厚待确认”,不能把整机高度或部件外形高度填成板厚。系统会在后续按基础资料库整板尺寸做裁切排版,模型不要自行填写板价。
|
||||
13. PACKAGING 必须按纸箱、泡沫、珍珠棉、护角、打包带等实际包材逐行拆分;packagingKind 必须与部件一致,尺寸表示单件包材规格,quantity 表示单套产品实际用量。没有直接包装证据时,根据整机尺寸、重量、易损部位和运输保护需要给出保守的最小包装方案,basis 使用 STRUCTURAL_INFERENCE,confidence 不得高于 0.55,并明确待确认项。
|
||||
14. materialName 使用可用于资料库匹配的材种或材料名称,例如“橡胶木”“刨花板”“碳钢”“瓦楞纸箱”,不要把颜色、营销等级或整段商品描述当作材种。SKU 元数据明确为 FAS 级橡胶木、实木、无镜等时,不能默认替换为另一种材质或保留不应存在的镜子部件。
|
||||
15. 每条 evidence 必须设置 basis:图片直接可见为 IMAGE;图片标注、详情文字或商品文案为 SOURCE_TEXT;SKU 名称、规格或属性为 SKU_METADATA;由装配或机构功能必需性推断的隐藏部件为 STRUCTURAL_INFERENCE。
|
||||
@ -93,9 +93,9 @@ const COMPACT_COSTING_SYSTEM_PROMPT = `你是家具产品成本工程师。当
|
||||
|
||||
请生成 4 到 12 条真正可计价的 BOM 项:
|
||||
1. 必须至少有一条材料项(BOARD、STEEL 或 ACCESSORY)、一条 PROCESSING、一条 LABOR 和一条 PACKAGING。
|
||||
2. 每条必须包含 externalLineId、category、partName、quantity、unit、estimatedUnitPrice、evidence、confidence。estimatedUnitPrice 是保守的出厂成本估算,用于资料库没有匹配项时的备用价格;不得从商品售价倒推,本次也没有提供商品售价。
|
||||
3. 资料库会在系统内优先覆盖你的备用价格。materialName 只写可匹配的材料,例如“橡胶木”“不锈钢”“银镜”“万向轮”“瓦楞纸箱”,不要把营销文案当材料。
|
||||
4. 板材有明确“单块板件”长宽时才使用“㎡”并填写 lengthMm、widthMm;没有可见裁切尺寸时,使用“件”或“套”的保守部件价格,绝不能把整机尺寸当成板件尺寸,也不能按整板价格核算。
|
||||
2. 每条必须包含 externalLineId、category、partName、quantity、unit、evidence、confidence。estimatedUnitPrice 可以省略;即使输出也仅作分析对照,不会作为公司正式成本价格来源。
|
||||
3. 公司基础资料库和历史 BOM 本地镜像是唯一正式价格来源。materialName 只写可匹配的材料,例如“橡胶木”“不锈钢”“银镜”“万向轮”“瓦楞纸箱”,不要把营销文案当材料。
|
||||
4. 板材有明确“单块板件”长宽时才使用“㎡”并填写 lengthMm、widthMm;来源明确标注板厚时填写 heightMm 和 materialSpec。没有可见裁切尺寸时,使用“件”或“套”的保守部件描述,绝不能把整机尺寸当成板件尺寸。系统会在后续按整板尺寸与裁切利用率计算成本,模型不要自行填写板价。
|
||||
5. 只写与该 SKU 已确认结构直接相关的部件。隐藏五金、人工、包材可以作最小必要推断,但 evidence.basis 必须是 STRUCTURAL_INFERENCE,且 evidence.confidence 和该行 confidence 均不得高于 0.55。
|
||||
6. evidence.imageUrl 只能使用输入 facts 中的 imageRef,不能编造地址。所有尺寸用毫米;没有证据的可选尺寸字段直接省略。
|
||||
7. SKU 之间不允许组合、拆分、补造或遗漏。若材料、尺寸、是否含镜、部件或包装有差异,必须在本 SKU 的 BOM 中体现。
|
||||
@ -143,7 +143,7 @@ const aiBomLineSchema = z.object({
|
||||
quantity: aiPositiveNumberSchema,
|
||||
unit: z.string().trim().max(40),
|
||||
weightKg: aiOptionalPositiveNumberSchema,
|
||||
estimatedUnitPrice: aiPositiveNumberSchema,
|
||||
estimatedUnitPrice: aiOptionalPositiveNumberSchema,
|
||||
suggestedLossFactor: z.preprocess(
|
||||
parseAiOptionalSuggestedLossFactor,
|
||||
z.number().finite().min(1).max(3).optional(),
|
||||
@ -618,7 +618,7 @@ function buildUserText(
|
||||
'externalLineId', 'category', 'partName', 'materialCode?',
|
||||
'materialName?', 'materialSpec?', 'processName?', 'packagingKind?',
|
||||
'lengthMm?', 'widthMm?', 'heightMm?', 'quantity', 'unit', 'weightKg?',
|
||||
'estimatedUnitPrice', 'suggestedLossFactor?', 'evidence', 'confidence',
|
||||
'estimatedUnitPrice?', 'suggestedLossFactor?', 'evidence', 'confidence',
|
||||
],
|
||||
evidence: ['sourceUrl?', 'imageUrl?', 'basis?', 'note?', 'confidence'],
|
||||
sku: ['externalSkuId', 'structureGroupKey', 'attributes?', 'dimensions', 'weightKg?', 'packaging?'],
|
||||
@ -1117,12 +1117,12 @@ function buildCompactCostingUserText(input: {
|
||||
contractRetry: boolean
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
task: '根据已保存的逐图事实,为一个真实平台 SKU 输出少量可计价的 BOM 项。资料库会优先匹配材料价格,estimatedUnitPrice 仅是资料库未命中时的保守备用估算。',
|
||||
task: '根据已保存的逐图事实,为一个真实平台 SKU 输出少量可核验的 BOM 项。正式价格只从公司本地 BOM 镜像匹配,estimatedUnitPrice 若输出仅作分析对照。',
|
||||
outputContractVersion: 'bom-costing/compact-costing/v1',
|
||||
sourceUrl: input.sourceUrl,
|
||||
reviewInstruction: input.analysisInstruction?.trim() || undefined,
|
||||
contractRetryRule: input.contractRetry
|
||||
? '上一次结果不符合结构化合同。请严格返回且只返回 skuTruth 中的一个 SKU、一个已被使用的结构组,以及 4 到 12 条带 estimatedUnitPrice 的 BOM 项。'
|
||||
? '上一次结果不符合结构化合同。请严格返回且只返回 skuTruth 中的一个 SKU、一个已被使用的结构组,以及 4 到 12 条带尺寸、数量、单位、材质和证据的 BOM 项。'
|
||||
: undefined,
|
||||
outputFieldReference: {
|
||||
product: ['brand?', 'category?', 'dimensions?'],
|
||||
@ -1131,7 +1131,7 @@ function buildCompactCostingUserText(input: {
|
||||
'externalLineId', 'category', 'partName', 'materialCode?',
|
||||
'materialName?', 'materialSpec?', 'processName?', 'packagingKind?',
|
||||
'lengthMm?', 'widthMm?', 'heightMm?', 'quantity', 'unit', 'weightKg?',
|
||||
'estimatedUnitPrice', 'suggestedLossFactor?', 'evidence', 'confidence',
|
||||
'estimatedUnitPrice?', 'suggestedLossFactor?', 'evidence', 'confidence',
|
||||
],
|
||||
evidence: ['sourceUrl?', 'imageUrl?', 'basis?', 'note?', 'confidence'],
|
||||
sku: ['externalSkuId', 'structureGroupKey', 'attributes?', 'dimensions', 'weightKg?', 'packaging?'],
|
||||
@ -1146,7 +1146,7 @@ function buildCompactCostingUserText(input: {
|
||||
facts: buildCompactCostingFactInput(input.factSnapshot, input.sku),
|
||||
peerSkuContext: {
|
||||
purpose:
|
||||
'同链接其他真实 SKU 仅用于交叉核对共同款式与差异;目标 SKU 的 BOM、数量、尺寸和备用价格必须独立形成,不能照抄其他 SKU。',
|
||||
'同链接其他真实 SKU 仅用于交叉核对共同款式与差异;目标 SKU 的 BOM、数量、尺寸和材质必须独立形成,不能照抄其他 SKU;正式价格只从公司本地 BOM 镜像匹配。',
|
||||
peerSkus: buildCompactCostingPeerSkuContext(input),
|
||||
},
|
||||
})
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
} from '@/lib/bom-costing/fact-contracts'
|
||||
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
|
||||
import { marketDetailClient, type MarketDetailSnapshot } from './market-detail-client'
|
||||
import { bomCostingScenarioService } from './bom-costing-scenario.service'
|
||||
|
||||
const mockRunAnalysis = jest.fn()
|
||||
const mockBuildStoredSnapshot = jest.fn()
|
||||
@ -343,6 +344,18 @@ describe('BomCostingProcessingService', () => {
|
||||
LABOR: [],
|
||||
})
|
||||
jest.spyOn(bomCostingRepository, 'completeCase').mockResolvedValue({} as never)
|
||||
jest.spyOn(bomCostingRepository, 'finalizeCaseDynamicCosting').mockResolvedValue({} as never)
|
||||
jest.spyOn(bomCostingScenarioService, 'calculateSameMaterialForCase').mockResolvedValue({
|
||||
totalSkuCount: 1,
|
||||
succeededSkuCount: 1,
|
||||
costedSkuCount: 1,
|
||||
pendingSkuCount: 0,
|
||||
failedSkuCount: 0,
|
||||
succeededSkuIds: ['sku-1'],
|
||||
costedSkuIds: ['sku-1'],
|
||||
pendingSkuIds: [],
|
||||
failures: [],
|
||||
})
|
||||
jest.spyOn(bomCostingRepository, 'failCase').mockResolvedValue(undefined)
|
||||
jest.spyOn(bomCostingRepository, 'requeueTransientMultimodalFailure')
|
||||
.mockResolvedValue(false)
|
||||
@ -585,7 +598,7 @@ describe('BomCostingProcessingService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('任一 SKU 未形成有效预估成本时拒绝写成已完成', async () => {
|
||||
it('没有公司价格来源时仍保存结构为待补成本,不调用模型估价', async () => {
|
||||
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
|
||||
.mockResolvedValue(factAnalysisRecord(
|
||||
BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
|
||||
@ -618,12 +631,22 @@ describe('BomCostingProcessingService', () => {
|
||||
|
||||
await new BomCostingProcessingService().processCase('case-1')
|
||||
|
||||
expect(bomCostingRepository.completeCase).not.toHaveBeenCalled()
|
||||
expect(bomCostingRepository.failCase).toHaveBeenCalledWith({
|
||||
caseId: 'case-1',
|
||||
analysisRunId: 'analysis-run-1',
|
||||
message: '成本核算结果不完整:1 个 SKU 未形成有效预估成本',
|
||||
})
|
||||
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
|
||||
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skus: [expect.objectContaining({
|
||||
estimatedCost: null,
|
||||
attributes: expect.objectContaining({
|
||||
costingEstimateStatus: 'PENDING',
|
||||
}),
|
||||
bomLines: [expect.objectContaining({
|
||||
matchStatus: 'UNMATCHED',
|
||||
resolvedUnitPrice: null,
|
||||
subtotal: null,
|
||||
})],
|
||||
})],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('事实结构阶段允许成本待补充,并把已完成的 SKU 结构保存为可继续核算', async () => {
|
||||
@ -663,10 +686,18 @@ describe('BomCostingProcessingService', () => {
|
||||
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
|
||||
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
completedMessage: 'SKU 结构已就绪,共 1 个 SKU;成本项待补充',
|
||||
completedMessage: 'SKU 结构已就绪,共 1 个 SKU;正在生成公司成本',
|
||||
deferReady: true,
|
||||
skus: [expect.objectContaining({ estimatedCost: null })],
|
||||
}),
|
||||
)
|
||||
expect(bomCostingScenarioService.calculateSameMaterialForCase).toHaveBeenCalledWith('case-1')
|
||||
expect(bomCostingRepository.finalizeCaseDynamicCosting).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
caseId: 'case-1',
|
||||
message: '核算完成,共 1 个 SKU,已生成同材质公司成本',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('紧凑成本阶段仅有个别 SKU 待补成本时仍完成整单并保留可继续核算状态', async () => {
|
||||
@ -707,13 +738,14 @@ describe('BomCostingProcessingService', () => {
|
||||
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
|
||||
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
completedMessage: '已生成 0/1 个 SKU 的预估成本;1 个 SKU 待补成本',
|
||||
completedMessage: 'SKU 结构已就绪,共 1 个 SKU;正在用公司资料重新生成成本',
|
||||
deferReady: true,
|
||||
skus: [expect.objectContaining({ estimatedCost: null })],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('预估成本高于竞品售价时拒绝把结果写成已完成', async () => {
|
||||
it('公司成本高于竞品售价时仍保存金额并标记暂估', async () => {
|
||||
jest.spyOn(bomCostingRepository, 'prepareSourceFactAnalysis')
|
||||
.mockResolvedValue(factAnalysisRecord(
|
||||
BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
|
||||
@ -722,6 +754,52 @@ describe('BomCostingProcessingService', () => {
|
||||
.mockResolvedValue(factAnalysisRecord(
|
||||
BOM_COSTING_FACT_ANALYSIS_STATUS.READY,
|
||||
) as never)
|
||||
jest.mocked(bomCostingRepository.listPriceCandidates).mockResolvedValue({
|
||||
BOARD: [{
|
||||
libraryType: 'SUBSTRATE',
|
||||
recordId: 'rubberwood-board',
|
||||
code: 'RW-BOARD',
|
||||
name: '橡胶木',
|
||||
spec: '18mm',
|
||||
unit: '㎡',
|
||||
unitPrice: 80,
|
||||
materialFamily: '橡胶木',
|
||||
pricingMode: 'AREA_SQM',
|
||||
}],
|
||||
STEEL: [],
|
||||
ACCESSORY: [],
|
||||
PACKAGING: [{
|
||||
libraryType: 'PACKAGING',
|
||||
recordId: 'carton',
|
||||
code: 'PACK-CARTON',
|
||||
name: '五层瓦楞纸箱',
|
||||
spec: '',
|
||||
unit: '件',
|
||||
unitPrice: 12,
|
||||
packagingKind: 'CARTON',
|
||||
pricingMode: 'UNIT',
|
||||
}],
|
||||
PROCESSING: [{
|
||||
libraryType: 'PROCESSING',
|
||||
recordId: 'processing',
|
||||
code: 'PROC-SET',
|
||||
name: '综合加工',
|
||||
spec: '开料与表面处理',
|
||||
unit: '套',
|
||||
unitPrice: 20,
|
||||
pricingMode: 'UNIT',
|
||||
}],
|
||||
LABOR: [{
|
||||
libraryType: 'LABOR',
|
||||
recordId: 'assembly-labor',
|
||||
code: 'LABOR-SET',
|
||||
name: '装配人工',
|
||||
spec: '',
|
||||
unit: '套',
|
||||
unitPrice: 15,
|
||||
pricingMode: 'UNIT',
|
||||
}],
|
||||
})
|
||||
mockRunAnalysis.mockResolvedValue({
|
||||
result: {
|
||||
...analysisResult,
|
||||
@ -749,12 +827,21 @@ describe('BomCostingProcessingService', () => {
|
||||
|
||||
await new BomCostingProcessingService().processCase('case-1')
|
||||
|
||||
expect(bomCostingRepository.completeCase).not.toHaveBeenCalled()
|
||||
expect(bomCostingRepository.failCase).toHaveBeenCalledWith({
|
||||
caseId: 'case-1',
|
||||
analysisRunId: 'analysis-run-1',
|
||||
message: '成本可信度校验未通过:1 个 SKU 的预估成本高于竞品平台售价,系统未将错误成本标记为完成',
|
||||
})
|
||||
expect(bomCostingRepository.failCase).not.toHaveBeenCalled()
|
||||
expect(bomCostingRepository.completeCase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skus: [expect.objectContaining({
|
||||
estimatedCost: expect.any(Number),
|
||||
attributes: expect.objectContaining({
|
||||
costingEstimateStatus: 'PROVISIONAL',
|
||||
costingWarning: expect.stringContaining('金额已保留'),
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
)
|
||||
const completedSku = jest.mocked(bomCostingRepository.completeCase)
|
||||
.mock.calls[0]?.[0].skus[0]
|
||||
expect(completedSku?.estimatedCost).toBeGreaterThan(60)
|
||||
})
|
||||
|
||||
it('旧版 500 条快照先补全 SKU 元数据并复用原图片,不重新归档', async () => {
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
} from './bom-costing-source-archive.service'
|
||||
import { prepareBomCostingAnalysis } from './bom-costing-result-preparer'
|
||||
import { assertBomCostingResultQuality } from './bom-costing-result-quality'
|
||||
import { bomCostingScenarioService } from './bom-costing-scenario.service'
|
||||
import { getCompetitorAnalysisProvider } from './bom-costing-provider'
|
||||
import {
|
||||
readBomCostingSynthesisCheckpoint,
|
||||
@ -1056,8 +1057,10 @@ export class BomCostingProcessingService {
|
||||
const isFactBasedStructure =
|
||||
output.trace.pipeline === 'FACT_BASED_STRUCTURE'
|
||||
const isCompactCosting = output.trace.pipeline === 'COMPACT_COSTING'
|
||||
const allowIncompleteCosting =
|
||||
isFactBasedStructure || isCompactCosting
|
||||
// Structure extraction and cost availability are separate stages now.
|
||||
// A missing legacy/library estimate must not turn a valid competitor BOM
|
||||
// into FAILED; the dynamic company-cost scenarios own final pricing.
|
||||
const allowIncompleteCosting = true
|
||||
await bomCostingRepository.updateCaseStage(caseId, {
|
||||
eventType: 'MODEL_ANALYSIS_COMPLETED',
|
||||
status: BomCostingCaseStatus.ANALYZING,
|
||||
@ -1138,13 +1141,47 @@ export class BomCostingProcessingService {
|
||||
completionTokens: output.trace.completionTokens,
|
||||
durationMs: output.trace.durationMs,
|
||||
analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,
|
||||
deferReady: true,
|
||||
completedMessage: isFactBasedStructure
|
||||
? `SKU 结构已就绪,共 ${preparedSkus.length} 个 SKU;成本项待补充`
|
||||
? `SKU 结构已就绪,共 ${preparedSkus.length} 个 SKU;正在生成公司成本`
|
||||
: isCompactCosting
|
||||
? stats.pendingCostSkuCount > 0
|
||||
? `已生成 ${stats.costedSkuCount}/${preparedSkus.length} 个 SKU 的预估成本;${stats.pendingCostSkuCount} 个 SKU 待补成本`
|
||||
: `已生成全部 ${preparedSkus.length} 个 SKU 的预估成本`
|
||||
: undefined,
|
||||
? `SKU 结构已就绪,共 ${preparedSkus.length} 个 SKU;正在用公司资料重新生成成本`
|
||||
: `SKU 结构与旧规则估算已就绪;正在生成公司动态成本`
|
||||
: `SKU 结构已就绪,共 ${preparedSkus.length} 个 SKU;正在生成公司成本`,
|
||||
})
|
||||
let dynamicCosting: Awaited<ReturnType<
|
||||
typeof bomCostingScenarioService.calculateSameMaterialForCase
|
||||
>>
|
||||
try {
|
||||
dynamicCosting = await bomCostingScenarioService.calculateSameMaterialForCase(caseId)
|
||||
} catch (error) {
|
||||
const message = readErrorMessage(error)
|
||||
dynamicCosting = {
|
||||
totalSkuCount: preparedSkus.length,
|
||||
succeededSkuCount: 0,
|
||||
costedSkuCount: 0,
|
||||
pendingSkuCount: 0,
|
||||
failedSkuCount: preparedSkus.length,
|
||||
succeededSkuIds: [],
|
||||
costedSkuIds: [],
|
||||
pendingSkuIds: [],
|
||||
failures: preparedSkus.map((sku) => ({
|
||||
skuId: '',
|
||||
externalSkuId: sku.externalSkuId,
|
||||
message,
|
||||
})),
|
||||
}
|
||||
}
|
||||
const finalMessage = dynamicCosting.failedSkuCount > 0
|
||||
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.failedSkuCount} 个成本生成失败可单独重试`
|
||||
: dynamicCosting.pendingSkuCount > 0
|
||||
? `结构分析已完成;${dynamicCosting.costedSkuCount}/${dynamicCosting.totalSkuCount} 个 SKU 已生成完整成本,${dynamicCosting.pendingSkuCount} 个仍缺公司价格资料`
|
||||
: `核算完成,共 ${dynamicCosting.totalSkuCount} 个 SKU,已生成同材质公司成本`
|
||||
await bomCostingRepository.finalizeCaseDynamicCosting({
|
||||
caseId,
|
||||
message: finalMessage,
|
||||
payload: dynamicCosting as unknown as Prisma.InputJsonValue,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = readErrorMessage(error)
|
||||
|
||||
@ -81,6 +81,53 @@ const emptyCandidates = (): Record<
|
||||
LABOR: [],
|
||||
})
|
||||
|
||||
const fullyPricedCandidates = () => {
|
||||
const candidates = emptyCandidates()
|
||||
candidates.BOARD = [{
|
||||
libraryType: 'SUBSTRATE',
|
||||
recordId: 'rubberwood-a',
|
||||
code: 'RW-A',
|
||||
name: '橡胶木',
|
||||
spec: '18mm',
|
||||
unit: '㎡',
|
||||
unitPrice: 40,
|
||||
materialFamily: '橡胶木',
|
||||
pricingMode: 'AREA_SQM',
|
||||
}]
|
||||
candidates.PROCESSING = [{
|
||||
libraryType: 'PROCESSING',
|
||||
recordId: 'processing-a',
|
||||
code: 'PROC-A',
|
||||
name: '综合加工',
|
||||
spec: '',
|
||||
unit: '套',
|
||||
unitPrice: 20,
|
||||
pricingMode: 'UNIT',
|
||||
}]
|
||||
candidates.LABOR = [{
|
||||
libraryType: 'LABOR',
|
||||
recordId: 'labor-a',
|
||||
code: 'LABOR-A',
|
||||
name: '装配人工',
|
||||
spec: '',
|
||||
unit: '套',
|
||||
unitPrice: 15,
|
||||
pricingMode: 'UNIT',
|
||||
}]
|
||||
candidates.PACKAGING = [{
|
||||
libraryType: 'PACKAGING',
|
||||
recordId: 'packaging-a',
|
||||
code: 'PACK-A',
|
||||
name: '瓦楞纸箱',
|
||||
spec: '',
|
||||
unit: '件',
|
||||
unitPrice: 12,
|
||||
packagingKind: 'CARTON',
|
||||
pricingMode: 'UNIT',
|
||||
}]
|
||||
return candidates
|
||||
}
|
||||
|
||||
describe('BOM 核算结果准备', () => {
|
||||
it('资料库多候选时保留待确认标记,但仍形成完整预估成本', () => {
|
||||
const candidates = emptyCandidates()
|
||||
@ -109,10 +156,14 @@ describe('BOM 核算结果准备', () => {
|
||||
},
|
||||
]
|
||||
|
||||
candidates.PROCESSING = fullyPricedCandidates().PROCESSING
|
||||
candidates.LABOR = fullyPricedCandidates().LABOR
|
||||
candidates.PACKAGING = fullyPricedCandidates().PACKAGING
|
||||
const [sku] = prepareBomCostingAnalysis(analysis, candidates)
|
||||
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
||||
|
||||
expect(sku?.estimatedCost).toBeGreaterThan(0)
|
||||
expect(sku?.estimatedCost).toBeCloseTo(70.68)
|
||||
expect(board).toMatchObject({
|
||||
matchStatus: BomCostingMatchStatus.NEEDS_CONFIRMATION,
|
||||
resolvedUnitPrice: 40,
|
||||
@ -120,27 +171,28 @@ describe('BOM 核算结果准备', () => {
|
||||
expect(board?.subtotal).toBeCloseTo(23.68)
|
||||
})
|
||||
|
||||
it('资料库未命中时使用模型备用估价,人工确认不是展示总价的前置条件', () => {
|
||||
it('资料库未命中时不使用模型备用估价,并保留未解决明细', () => {
|
||||
const [sku] = prepareBomCostingAnalysis(analysis, emptyCandidates())
|
||||
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
||||
const packaging = sku?.bomLines.find(
|
||||
(line) => line.category === 'PACKAGING',
|
||||
)
|
||||
|
||||
expect(sku?.estimatedCost).toBeGreaterThan(0)
|
||||
expect(sku?.estimatedCost).toBeNull()
|
||||
expect(board).toMatchObject({
|
||||
matchStatus: BomCostingMatchStatus.NEEDS_CONFIRMATION,
|
||||
matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE',
|
||||
resolvedUnitPrice: 80,
|
||||
matchStatus: BomCostingMatchStatus.UNMATCHED,
|
||||
matchedLibraryType: '',
|
||||
resolvedUnitPrice: null,
|
||||
subtotal: null,
|
||||
})
|
||||
expect(packaging).toMatchObject({
|
||||
matchStatus: BomCostingMatchStatus.NEEDS_CONFIRMATION,
|
||||
matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE',
|
||||
subtotal: 12,
|
||||
matchStatus: BomCostingMatchStatus.UNMATCHED,
|
||||
matchedLibraryType: '',
|
||||
subtotal: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('板材缺少自身裁切尺寸时不拿整板资料库价格,但允许明确的单件备用估价', () => {
|
||||
it('板材缺少自身裁切尺寸时不拿整板资料库价格,也不使用备用估价', () => {
|
||||
const candidates = emptyCandidates()
|
||||
candidates.BOARD = [{
|
||||
libraryType: 'SUBSTRATE',
|
||||
@ -173,13 +225,13 @@ describe('BOM 核算结果准备', () => {
|
||||
const [sku] = prepareBomCostingAnalysis(missingBoardDimensions, candidates)
|
||||
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
||||
|
||||
expect(sku?.estimatedCost).toBeGreaterThan(0)
|
||||
expect(sku?.estimatedCost).toBeNull()
|
||||
expect(board).toMatchObject({
|
||||
matchStatus: BomCostingMatchStatus.NEEDS_CONFIRMATION,
|
||||
matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE',
|
||||
resolvedUnitPrice: 80,
|
||||
matchStatus: BomCostingMatchStatus.UNMATCHED,
|
||||
matchedLibraryType: '',
|
||||
resolvedUnitPrice: null,
|
||||
calculatedQuantity: 1,
|
||||
subtotal: 80,
|
||||
subtotal: null,
|
||||
})
|
||||
})
|
||||
|
||||
@ -223,24 +275,24 @@ describe('BOM 核算结果准备', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('预估成本高于该 SKU 的竞品售价时保留明细但不把它当作最终成本', () => {
|
||||
it('预估成本高于该 SKU 的竞品售价时保留金额并标记暂估', () => {
|
||||
const overMarketPrice: CompetitorAnalysisResultV1 = {
|
||||
...analysis,
|
||||
skus: analysis.skus.map((sku) => ({
|
||||
...sku,
|
||||
attributes: {
|
||||
...sku.attributes,
|
||||
marketPriceRaw: '90.00',
|
||||
marketPriceRaw: '60.00',
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
const [sku] = prepareBomCostingAnalysis(overMarketPrice, emptyCandidates())
|
||||
const [sku] = prepareBomCostingAnalysis(overMarketPrice, fullyPricedCandidates())
|
||||
|
||||
expect(sku?.estimatedCost).toBeNull()
|
||||
expect(sku?.estimatedCost).toBeCloseTo(70.68)
|
||||
expect(sku?.attributes).toMatchObject({
|
||||
costingEstimateStatus: 'REVIEW_REQUIRED',
|
||||
costingWarning: expect.stringContaining('高于该 SKU 的竞品售价'),
|
||||
costingEstimateStatus: 'PROVISIONAL',
|
||||
costingWarning: expect.stringContaining('成本高于竞品售价'),
|
||||
})
|
||||
expect(sku?.bomLines.some((line) => line.subtotal !== null)).toBe(true)
|
||||
})
|
||||
@ -257,7 +309,7 @@ describe('BOM 核算结果准备', () => {
|
||||
})),
|
||||
}
|
||||
|
||||
const [sku] = prepareBomCostingAnalysis(lossAdjustedAnalysis, emptyCandidates())
|
||||
const [sku] = prepareBomCostingAnalysis(lossAdjustedAnalysis, fullyPricedCandidates())
|
||||
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
||||
const processing = sku?.bomLines.find(
|
||||
(line) => line.category === 'PROCESSING',
|
||||
@ -267,7 +319,7 @@ describe('BOM 核算结果准备', () => {
|
||||
(line) => line.category === 'PACKAGING',
|
||||
)
|
||||
|
||||
expect(board).toMatchObject({ lossFactor: 1.2, subtotal: 56.83 })
|
||||
expect(board).toMatchObject({ lossFactor: 1.2, subtotal: 28.42 })
|
||||
expect(processing).toMatchObject({ lossFactor: 1, subtotal: 20 })
|
||||
expect(labor).toMatchObject({ lossFactor: 1, subtotal: 15 })
|
||||
expect(packaging).toMatchObject({ lossFactor: 1, subtotal: 12 })
|
||||
|
||||
@ -57,44 +57,6 @@ function createSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
function createExternalEstimateCandidate(
|
||||
line: CompetitorBomLineV1,
|
||||
): BomCostingLibraryCandidate | null {
|
||||
if (!line.estimatedUnitPrice) return null
|
||||
const normalizedUnit = line.unit.trim().toLowerCase()
|
||||
const pricingMode =
|
||||
line.category === 'BOARD' && ['㎡', 'm²', 'm2', '平方米'].includes(normalizedUnit)
|
||||
? 'AREA_SQM'
|
||||
: line.category === 'STEEL' && ['m', '米', '延米'].includes(normalizedUnit)
|
||||
? 'LENGTH_M'
|
||||
: 'UNIT'
|
||||
return {
|
||||
libraryType:
|
||||
line.category === 'PROCESSING' || line.category === 'LABOR'
|
||||
? 'EXTERNAL_ANALYSIS'
|
||||
: 'EXTERNAL_ANALYSIS_ESTIMATE',
|
||||
recordId: line.externalLineId,
|
||||
code: line.externalLineId,
|
||||
name: line.partName,
|
||||
spec: line.processName ?? '',
|
||||
unit: line.unit,
|
||||
unitPrice: line.estimatedUnitPrice,
|
||||
pricingMode,
|
||||
}
|
||||
}
|
||||
|
||||
function canUseUnitBasedBoardEstimate(
|
||||
line: CompetitorBomLineV1,
|
||||
candidate: BomCostingLibraryCandidate | null,
|
||||
) {
|
||||
// A model can conservatively estimate one identified wooden component as a
|
||||
// complete part (for example, a mirror frame or a solid-wood shelf) when a
|
||||
// cut list is not visible. That is different from treating the whole SKU as
|
||||
// one panel or consuming a full stock board. Only an explicit per-piece
|
||||
// fallback is allowed here; library area pricing remains blocked below.
|
||||
return line.category === 'BOARD' && candidate?.pricingMode === 'UNIT'
|
||||
}
|
||||
|
||||
function resolvePricingDimensions(line: CompetitorBomLineV1) {
|
||||
// A SKU's overall dimensions are never a board-cut size. Reusing them here
|
||||
// silently prices a whole product as one panel (and can also charge an
|
||||
@ -129,14 +91,15 @@ function readMarketPrice(value: unknown) {
|
||||
function buildPreparedSkuAttributes(input: {
|
||||
attributes: Record<string, unknown>
|
||||
hasFinalCost: boolean
|
||||
hasProvisionalCost: boolean
|
||||
marketPriceExceeded: boolean
|
||||
}) {
|
||||
if (input.marketPriceExceeded) {
|
||||
return {
|
||||
...input.attributes,
|
||||
costingEstimateStatus: 'REVIEW_REQUIRED',
|
||||
costingEstimateStatus: 'PROVISIONAL',
|
||||
costingWarning:
|
||||
'预估成本高于该 SKU 的竞品售价,系统未将其作为最终成本;请补充部件尺寸或核验资料库价格。',
|
||||
'成本高于竞品售价,需复核主要驱动项;金额已保留,不影响审计和后续校准。',
|
||||
} as Prisma.InputJsonValue
|
||||
}
|
||||
if (input.hasFinalCost) {
|
||||
@ -145,6 +108,13 @@ function buildPreparedSkuAttributes(input: {
|
||||
costingEstimateStatus: 'ESTIMATED',
|
||||
} as Prisma.InputJsonValue
|
||||
}
|
||||
if (input.hasProvisionalCost) {
|
||||
return {
|
||||
...input.attributes,
|
||||
costingEstimateStatus: 'PROVISIONAL',
|
||||
costingWarning: '部分价格来自相近的公司历史 BOM,当前结果为暂估,需复核低置信度明细。',
|
||||
} as Prisma.InputJsonValue
|
||||
}
|
||||
return {
|
||||
...input.attributes,
|
||||
costingEstimateStatus: 'PENDING',
|
||||
@ -162,11 +132,6 @@ function prepareLine(
|
||||
const hasBoardCutDimensions =
|
||||
line.category !== 'BOARD' ||
|
||||
(Boolean(line.lengthMm) && Boolean(line.widthMm))
|
||||
const externalCandidate = createExternalEstimateCandidate(line)
|
||||
const externalEstimate =
|
||||
hasBoardCutDimensions || canUseUnitBasedBoardEstimate(line, externalCandidate)
|
||||
? externalCandidate
|
||||
: null
|
||||
const libraryEstimate = resolveBomCostingLibraryEstimate(
|
||||
hasBoardCutDimensions ? candidates[category] : [],
|
||||
{
|
||||
@ -194,20 +159,13 @@ function prepareLine(
|
||||
lossFactor,
|
||||
})
|
||||
const libraryCalculation = calculateWithCandidate(libraryEstimate.candidate)
|
||||
const canUseLibraryEstimate = libraryCalculation.subtotal !== null
|
||||
const matchedCandidate = canUseLibraryEstimate
|
||||
const matchedCandidate = libraryCalculation.subtotal !== null
|
||||
? libraryEstimate.candidate
|
||||
: externalEstimate
|
||||
const calculation = canUseLibraryEstimate
|
||||
: null
|
||||
const calculation = matchedCandidate
|
||||
? libraryCalculation
|
||||
: calculateWithCandidate(externalEstimate)
|
||||
const matchStatus = canUseLibraryEstimate && libraryEstimate.candidate
|
||||
? libraryEstimate.status
|
||||
: externalEstimate
|
||||
? line.category === 'PROCESSING' || line.category === 'LABOR'
|
||||
? 'MATCHED'
|
||||
: 'NEEDS_CONFIRMATION'
|
||||
: 'UNMATCHED'
|
||||
: calculateWithCandidate(null)
|
||||
const matchStatus = matchedCandidate ? libraryEstimate.status : 'UNMATCHED'
|
||||
|
||||
return {
|
||||
externalLineId: line.externalLineId,
|
||||
@ -267,6 +225,9 @@ export function prepareBomCostingAnalysis(
|
||||
const allLinesPriced =
|
||||
summary.unresolvedLineCount === 0 &&
|
||||
summary.missingCostGroups.length === 0
|
||||
const hasProvisionalCost = allLinesPriced && bomLines.some(
|
||||
(line) => line.matchStatus !== BomCostingMatchStatus.MATCHED,
|
||||
)
|
||||
const marketPrice = readMarketPrice(
|
||||
sku.attributes.marketPriceRaw ?? sku.attributes.marketOriginalPriceRaw,
|
||||
)
|
||||
@ -274,7 +235,7 @@ export function prepareBomCostingAnalysis(
|
||||
allLinesPriced &&
|
||||
marketPrice !== null &&
|
||||
summary.lossIncludedTotal > marketPrice + 0.01
|
||||
const hasFinalCost = allLinesPriced && !marketPriceExceeded
|
||||
const hasFinalCost = allLinesPriced && !hasProvisionalCost && !marketPriceExceeded
|
||||
|
||||
return {
|
||||
externalSkuId: sku.externalSkuId,
|
||||
@ -284,6 +245,7 @@ export function prepareBomCostingAnalysis(
|
||||
attributes: buildPreparedSkuAttributes({
|
||||
attributes: sku.attributes,
|
||||
hasFinalCost,
|
||||
hasProvisionalCost,
|
||||
marketPriceExceeded,
|
||||
}),
|
||||
lengthMm: sku.dimensions.lengthMm ?? null,
|
||||
@ -294,8 +256,8 @@ export function prepareBomCostingAnalysis(
|
||||
packageWidthMm: sku.packaging?.widthMm ?? null,
|
||||
packageHeightMm: sku.packaging?.heightMm ?? null,
|
||||
packageWeightKg: sku.packaging?.weightKg ?? null,
|
||||
estimatedCost: hasFinalCost ? summary.lossIncludedTotal : null,
|
||||
costCalculatedAt: hasFinalCost ? new Date() : null,
|
||||
estimatedCost: allLinesPriced ? summary.lossIncludedTotal : null,
|
||||
costCalculatedAt: allLinesPriced ? new Date() : null,
|
||||
sortOrder: skuIndex,
|
||||
bomLines,
|
||||
}
|
||||
|
||||
@ -179,7 +179,7 @@ describe('BOM 核算结果质量保护', () => {
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('能识别带货币符号和“起”字的售价,阻止高于售价的成本', () => {
|
||||
it('能识别带货币符号和“起”字的售价,但不阻止高于售价的成本', () => {
|
||||
const analysis = buildAnalysis([{ externalSkuId: 'sku-1', name: '测试 SKU' }])
|
||||
|
||||
expect(() => assertBomCostingResultQuality({
|
||||
@ -188,6 +188,6 @@ describe('BOM 核算结果质量保护', () => {
|
||||
preparedSkus: [buildPreparedSku('sku-1', 618, {
|
||||
marketPriceRaw: '¥617.10起',
|
||||
})],
|
||||
})).toThrow('预估成本高于竞品平台售价')
|
||||
})).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@ -192,31 +192,7 @@ function assertDistinctSourceSkuStructures(
|
||||
)
|
||||
}
|
||||
|
||||
function readMarketPrice(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
if (typeof value !== 'string') return null
|
||||
const match = value.replace(/[,,\s]/g, '').match(/\d+(?:\.\d+)?/)
|
||||
if (!match) return null
|
||||
const parsed = Number(match[0])
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
function assertPreparedCostRange(skus: BomCostingPreparedSku[]) {
|
||||
const marketRejectedSkus = skus.filter((sku) => {
|
||||
if (!sku.attributes || typeof sku.attributes !== 'object' || Array.isArray(sku.attributes)) {
|
||||
return false
|
||||
}
|
||||
return (sku.attributes as Record<string, unknown>).costingEstimateStatus ===
|
||||
'REVIEW_REQUIRED'
|
||||
})
|
||||
if (marketRejectedSkus.length > 0) {
|
||||
throw new Error(
|
||||
`成本可信度校验未通过:${marketRejectedSkus.length} 个 SKU 的预估成本高于竞品平台售价,系统未将错误成本标记为完成`,
|
||||
)
|
||||
}
|
||||
|
||||
const incompleteSkus = skus.filter(
|
||||
(sku) => sku.estimatedCost === null || sku.estimatedCost <= 0,
|
||||
)
|
||||
@ -239,21 +215,6 @@ function assertPreparedCostRange(skus: BomCostingPreparedSku[]) {
|
||||
)
|
||||
}
|
||||
|
||||
const implausibleSkus = skus.filter((sku) => {
|
||||
if (sku.estimatedCost === null || !sku.attributes || typeof sku.attributes !== 'object' || Array.isArray(sku.attributes)) {
|
||||
return false
|
||||
}
|
||||
const attributes = sku.attributes as Record<string, unknown>
|
||||
const price = readMarketPrice(
|
||||
attributes.marketPriceRaw ?? attributes.marketOriginalPriceRaw,
|
||||
)
|
||||
return price !== null && sku.estimatedCost > price + 0.01
|
||||
})
|
||||
if (implausibleSkus.length > 0) {
|
||||
throw new Error(
|
||||
`成本可信度校验未通过:${implausibleSkus.length} 个 SKU 的预估成本高于竞品平台售价,系统未将错误成本标记为完成`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBomCostingResultQuality(input: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -537,10 +537,10 @@ describe('BomCostingService', () => {
|
||||
id: 'sku-review-required',
|
||||
externalSkuId: 'sku-1',
|
||||
name: '测试规格',
|
||||
// These lines are deliberately priced, but the processor withheld
|
||||
// the final cost after a business-level guard (for example, it was
|
||||
// above the market price). A list page must not reconstruct a total
|
||||
// that the persisted result intentionally rejected.
|
||||
// These rows are priced, but the persisted result is intentionally
|
||||
// incomplete. A list page must not reconstruct a formal total from
|
||||
// line subtotals. Competitor-price alerts are tested separately and
|
||||
// never hide a complete persisted amount.
|
||||
estimatedCost: null,
|
||||
costCalculatedAt: null,
|
||||
bomLines: [
|
||||
|
||||
@ -291,10 +291,9 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
|
||||
packageWidthMm: decimalToNumber(sku.packageWidthMm),
|
||||
packageHeightMm: decimalToNumber(sku.packageHeightMm),
|
||||
packageWeightKg: decimalToNumber(sku.packageWeightKg),
|
||||
// The persisted cost is the final business decision. Do not rebuild a
|
||||
// display total from priced lines when the processor deliberately
|
||||
// withheld it (for example, because the estimate exceeded the
|
||||
// competitor price and must be reviewed first).
|
||||
// Only expose a persisted total when every required cost group is
|
||||
// complete. A competitor-price alert never clears or hides that total.
|
||||
// Do not reconstruct an incomplete total from individually priced rows.
|
||||
estimatedCost: hasCompleteCost
|
||||
? roundBomCostMoney(savedEstimatedCost)
|
||||
: null,
|
||||
@ -406,8 +405,14 @@ export class BomCostingService {
|
||||
? null
|
||||
: record.skus.reduce<{ min: number; max: number } | null>(
|
||||
(range, sku) => {
|
||||
const value = decimalToNumber(sku.estimatedCost)
|
||||
if (value === null || value <= 0 || !sku.costCalculatedAt) {
|
||||
const dynamicValue = decimalToNumber(sku.scenarios?.[0]?.totalCost)
|
||||
const legacyValue = decimalToNumber(sku.estimatedCost)
|
||||
const value = dynamicValue ?? legacyValue
|
||||
if (
|
||||
value === null ||
|
||||
value <= 0 ||
|
||||
(dynamicValue === null && !sku.costCalculatedAt)
|
||||
) {
|
||||
return range
|
||||
}
|
||||
return range
|
||||
|
||||
@ -0,0 +1,179 @@
|
||||
import type { UpstreamBomReferenceRawRow } from './upstream-bom-reference-source'
|
||||
import {
|
||||
cleanBomReferenceCode,
|
||||
cleanBomReferenceText,
|
||||
CURRENT_BOM_VERSION_KEY,
|
||||
normalizeBomReferenceRow,
|
||||
parseBomReferenceNumber,
|
||||
} from './upstream-bom-reference-normalizer'
|
||||
|
||||
const baseRow: UpstreamBomReferenceRawRow = {
|
||||
sourceRowId: '0007',
|
||||
sourceSku: ' sku-a ',
|
||||
bomVersionKey: ' v1 ',
|
||||
category: '板材',
|
||||
categoryCode: ' board ',
|
||||
partNumber: ' p-001 ',
|
||||
partName: '侧板',
|
||||
side: '左',
|
||||
materialName: '橡胶木',
|
||||
materialCode: ' wood-01 ',
|
||||
materialSpec: '20 mm',
|
||||
processName: '开料',
|
||||
unit: '件',
|
||||
rawLength: '1,200',
|
||||
rawWidth: '600',
|
||||
rawLongEdge: '2.4千',
|
||||
rawShortEdge: '1,200',
|
||||
rawQuantity: '2',
|
||||
rawCoefficient: '5%',
|
||||
rawArea: '1.25',
|
||||
rawEdgeLength: '3.5',
|
||||
rawUnitPrice: '1,234.5 元/件',
|
||||
rawAmount: '2,469',
|
||||
rawNote: ' 正常\u0000 用料 ',
|
||||
rawCreatedAt: '2026-08-07T00:00:00.000Z',
|
||||
}
|
||||
|
||||
describe('upstream BOM reference normalizer', () => {
|
||||
it('cleans compatibility characters, control characters and codes', () => {
|
||||
expect(cleanBomReferenceText(' 板\t材\u0000 A ')).toBe('板 材 A')
|
||||
expect(cleanBomReferenceCode(' sku-a ')).toBe('SKU-A')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1,234.5 元/㎡', 1234.5],
|
||||
['1.2万', 12_000],
|
||||
['2.4千', 2_400],
|
||||
['12 元/m²', 12],
|
||||
['1.2M', 1_200_000],
|
||||
['5%', 0.05],
|
||||
['(1,234.5)', -1234.5],
|
||||
['待定', null],
|
||||
['--', null],
|
||||
])('parses report numeric value %p', (input, expected) => {
|
||||
expect(parseBomReferenceNumber(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('normalizes decimals to six places and produces a stable fingerprint', () => {
|
||||
const first = normalizeBomReferenceRow(baseRow)
|
||||
const equivalent = normalizeBomReferenceRow({
|
||||
...baseRow,
|
||||
sourceRowId: 7,
|
||||
sourceSku: 'SKU-A',
|
||||
bomVersionKey: 'V1',
|
||||
categoryCode: 'BOARD',
|
||||
materialCode: 'WOOD-01',
|
||||
rawNote: '正常 用料',
|
||||
})
|
||||
|
||||
expect(first.row).toMatchObject({
|
||||
sourceRowId: '7',
|
||||
sourceSku: 'SKU-A',
|
||||
bomVersionKey: 'V1',
|
||||
categoryCode: 'BOARD',
|
||||
partNumber: 'P-001',
|
||||
materialCode: 'WOOD-01',
|
||||
lengthMm: '1200.000000',
|
||||
longEdgeMm: '2400.000000',
|
||||
quantity: '2.000000',
|
||||
coefficient: '1.050000',
|
||||
unitPrice: '1234.500000',
|
||||
amount: '2469.000000',
|
||||
isSummaryRow: false,
|
||||
})
|
||||
expect(first.row.sourceCreatedAt?.toISOString()).toBe('2026-08-07T00:00:00.000Z')
|
||||
expect(first.row.sourceFingerprint).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(equivalent.row.sourceFingerprint).toBe(first.row.sourceFingerprint)
|
||||
expect(first.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps rows with different createdAt values in the same current snapshot version', () => {
|
||||
const first = normalizeBomReferenceRow({
|
||||
...baseRow,
|
||||
bomVersionKey: null,
|
||||
rawCreatedAt: '2026-08-07T02:03:04.000Z',
|
||||
})
|
||||
const second = normalizeBomReferenceRow({
|
||||
...baseRow,
|
||||
sourceRowId: '8',
|
||||
bomVersionKey: null,
|
||||
rawCreatedAt: '2026-08-08T03:04:05.000Z',
|
||||
})
|
||||
|
||||
expect(first.row.bomVersionKey).toBe(CURRENT_BOM_VERSION_KEY)
|
||||
expect(second.row.bomVersionKey).toBe(CURRENT_BOM_VERSION_KEY)
|
||||
expect(first.row.sourceCreatedAt?.toISOString()).toBe('2026-08-07T02:03:04.000Z')
|
||||
expect(second.row.sourceCreatedAt?.toISOString()).toBe('2026-08-08T03:04:05.000Z')
|
||||
expect(first.warnings).not.toContain('bomVersionKey is empty')
|
||||
expect(second.warnings).not.toContain('bomVersionKey is empty')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['亚克力板', 'BOARD'],
|
||||
['玻璃', 'BOARD'],
|
||||
['三合板', 'BOARD'],
|
||||
['布艺软包', 'ACCESSORY'],
|
||||
['胶水', 'ACCESSORY'],
|
||||
['泡沫', 'PACKAGING'],
|
||||
['纸箱', 'PACKAGING'],
|
||||
['打包带', 'PACKAGING'],
|
||||
['护角', 'PACKAGING'],
|
||||
['扎带', 'PACKAGING'],
|
||||
['吸塑', 'PACKAGING'],
|
||||
['喷塑', 'PROCESSING'],
|
||||
['木蜡油', 'PROCESSING'],
|
||||
['拉杆', 'ACCESSORY'],
|
||||
['铁撑', 'ACCESSORY'],
|
||||
['木腿', 'ACCESSORY'],
|
||||
])('maps verified upstream category %s to %s', (category, expected) => {
|
||||
const result = normalizeBomReferenceRow({
|
||||
...baseRow,
|
||||
category,
|
||||
categoryCode: '',
|
||||
})
|
||||
|
||||
expect(result.row.categoryCode).toBe(expected)
|
||||
})
|
||||
|
||||
it('marks summary rows and reports malformed optional values without inventing zeroes', () => {
|
||||
const result = normalizeBomReferenceRow({
|
||||
...baseRow,
|
||||
category: '合计',
|
||||
sourceSku: '',
|
||||
bomVersionKey: '',
|
||||
materialName: '',
|
||||
materialCode: '',
|
||||
partName: '',
|
||||
rawQuantity: '未知',
|
||||
rawUnitPrice: '价格待定',
|
||||
rawCreatedAt: 'not-a-date',
|
||||
})
|
||||
|
||||
expect(result.row.isSummaryRow).toBe(true)
|
||||
expect(result.row.quantity).toBeNull()
|
||||
expect(result.row.unitPrice).toBeNull()
|
||||
expect(result.row.sourceCreatedAt).toBeNull()
|
||||
expect(result.warnings).toEqual([
|
||||
'sourceSku is empty',
|
||||
'material identity is empty',
|
||||
'unitPrice is not numeric',
|
||||
'quantity is not numeric',
|
||||
'sourceCreatedAt is not parseable',
|
||||
])
|
||||
})
|
||||
|
||||
it('marks the upstream no-invoice-price aggregate as a summary row', () => {
|
||||
const result = normalizeBomReferenceRow({
|
||||
...baseRow,
|
||||
category: '不包含开票价格',
|
||||
partName: '',
|
||||
materialName: '',
|
||||
materialCode: '',
|
||||
rawUnitPrice: '1,300',
|
||||
rawAmount: '1,300',
|
||||
})
|
||||
|
||||
expect(result.row.isSummaryRow).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,281 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { UpstreamBomReferenceRawRow } from './upstream-bom-reference-source'
|
||||
|
||||
export const CURRENT_BOM_VERSION_KEY = 'CURRENT'
|
||||
|
||||
export type NormalizedBomReferenceRow = {
|
||||
sourceRowId: string
|
||||
sourceSku: string
|
||||
bomVersionKey: string
|
||||
category: string
|
||||
categoryCode: string
|
||||
partNumber: string
|
||||
partName: string
|
||||
side: string
|
||||
materialName: string
|
||||
materialCode: string
|
||||
materialSpec: string
|
||||
processName: string
|
||||
unit: string
|
||||
rawLength: string
|
||||
rawWidth: string
|
||||
rawLongEdge: string
|
||||
rawShortEdge: string
|
||||
rawQuantity: string
|
||||
rawCoefficient: string
|
||||
rawArea: string
|
||||
rawEdgeLength: string
|
||||
rawUnitPrice: string
|
||||
rawAmount: string
|
||||
rawNote: string
|
||||
rawCreatedAt: string
|
||||
lengthMm: string | null
|
||||
widthMm: string | null
|
||||
longEdgeMm: string | null
|
||||
shortEdgeMm: string | null
|
||||
quantity: string | null
|
||||
coefficient: string | null
|
||||
areaSqm: string | null
|
||||
edgeLengthM: string | null
|
||||
unitPrice: string | null
|
||||
amount: string | null
|
||||
sourceCreatedAt: Date | null
|
||||
sourceFingerprint: string
|
||||
isSummaryRow: boolean
|
||||
}
|
||||
|
||||
export type NormalizedBomReferenceResult = {
|
||||
row: NormalizedBomReferenceRow
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
||||
.replace(/[\s\u00A0\u202F]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'bigint') return String(value)
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? '' : value.toISOString()
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function cleanBomReferenceCode(value: unknown) {
|
||||
return text(value).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse report-view numeric text without treating placeholders as zero.
|
||||
* The source view contains values such as `1,234.5 元/㎡`, `1.2万` and `5%`.
|
||||
*/
|
||||
export function parseBomReferenceNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
if (typeof value === 'bigint') {
|
||||
const result = Number(value)
|
||||
return Number.isFinite(result) ? result : null
|
||||
}
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const original = value.trim()
|
||||
if (!original) return null
|
||||
const normalized = original
|
||||
.normalize('NFKC')
|
||||
.replace(/[,、]/g, ',')
|
||||
.replace(/[.。]/g, '.')
|
||||
.replace(/[−-]/g, '-')
|
||||
.replace(/[+]/g, '+')
|
||||
if (/^(?:null|nil|none|n\/a|na|无|暂无|未知|待定|空|--+|—+|\.\.\.)$/i.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const negative = /^\s*\(.*\)\s*$/.test(normalized)
|
||||
const source = negative ? normalized.replace(/^\s*\(|\)\s*$/g, '') : normalized
|
||||
const tokenMatch = source.match(
|
||||
/[+-]?(?:(?:\d{1,3}(?:[,\s]\d{3})+)|\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?|[+-]?\.\d+(?:[eE][+-]?\d+)?/,
|
||||
)
|
||||
if (!tokenMatch) return null
|
||||
const parsed = Number(tokenMatch[0].replace(/[,\s]/g, ''))
|
||||
if (!Number.isFinite(parsed)) return null
|
||||
|
||||
const suffix = source.slice((tokenMatch.index ?? 0) + tokenMatch[0].length).trim()
|
||||
let multiplier = 1
|
||||
if (/^亿/.test(suffix)) multiplier = 100_000_000
|
||||
else if (/^万/.test(suffix)) multiplier = 10_000
|
||||
else if (/^(?:千|k(?:\b|$))/i.test(suffix)) multiplier = 1_000
|
||||
else if (/^百/.test(suffix)) multiplier = 100
|
||||
// Lower-case `m` is a common metre unit (for example `元/m²`); only an
|
||||
// explicit upper-case magnitude marker is allowed to mean million.
|
||||
else if (/^M(?:\b|$)/.test(suffix)) multiplier = 1_000_000
|
||||
else if (/^B(?:\b|$)/.test(suffix)) multiplier = 1_000_000_000
|
||||
const percent = /^(?:%|%)/.test(suffix)
|
||||
const result = parsed * multiplier * (percent ? 0.01 : 1) * (negative ? -1 : 1)
|
||||
return Number.isFinite(result) ? result : null
|
||||
}
|
||||
|
||||
function fixedDecimal(value: number) {
|
||||
// The mirror migration uses six fractional places. Rounding here keeps
|
||||
// the fingerprint stable with the value that PostgreSQL will store.
|
||||
const rounded = value.toFixed(6)
|
||||
return rounded === '-0.000000' ? '0.000000' : rounded
|
||||
}
|
||||
|
||||
function decimal(value: unknown) {
|
||||
const parsed = parseBomReferenceNumber(value)
|
||||
return parsed === null || !Number.isFinite(parsed) ? null : fixedDecimal(parsed)
|
||||
}
|
||||
|
||||
function coefficientDecimal(value: unknown) {
|
||||
const parsed = parseBomReferenceNumber(value)
|
||||
if (parsed === null || !Number.isFinite(parsed)) return null
|
||||
const multiplier = /%|%/.test(text(value)) ? 1 + parsed : parsed
|
||||
return Number.isFinite(multiplier) ? fixedDecimal(multiplier) : null
|
||||
}
|
||||
|
||||
function dateValue(value: unknown) {
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
||||
const normalized = text(value)
|
||||
if (!normalized) return null
|
||||
const parsed = new Date(normalized)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
function summaryRow(input: {
|
||||
category: string
|
||||
partName: string
|
||||
materialName: string
|
||||
note: string
|
||||
}) {
|
||||
const category = input.category.trim()
|
||||
const labels = [input.partName, input.materialName, input.note].filter(Boolean).join(' ')
|
||||
return /^(?:总材料费|总成本|总费用|不包含开票价格|合计|小计|汇总|subtotal|grandtotal|total|summary)$/i.test(category) ||
|
||||
/(?:总材料费|总成本|总费用|不包含开票价格|材料费合计|成本合计|小计|合计金额|grand\s*total|subtotal|summary)/i.test(labels)
|
||||
}
|
||||
|
||||
function normalizeCategoryCode(categoryCode: string, category: string) {
|
||||
const normalized = cleanBomReferenceCode(categoryCode)
|
||||
const source = `${normalized} ${category}`.normalize('NFKC').toLowerCase()
|
||||
if (/板材|基材|木料|木材|实木|亚克力板|玻璃|三合板/.test(source)) return 'BOARD'
|
||||
if (/钢材|钢架|钢管|铁件|金属|铝合金/.test(source)) return 'STEEL'
|
||||
if (/配件|五金|辅料|布艺软包|胶水|拉杆|铁撑|木腿/.test(source)) return 'ACCESSORY'
|
||||
if (/包材|包装|纸箱|泡沫|打包带|护角|扎带|吸塑/.test(source)) return 'PACKAGING'
|
||||
if (/加工|封边|喷涂|喷塑|木蜡油|工艺/.test(source)) return 'PROCESSING'
|
||||
if (/人工|杂费|工时|劳务/.test(source)) return 'LABOR'
|
||||
return normalized
|
||||
}
|
||||
|
||||
function fingerprint(row: Omit<NormalizedBomReferenceRow, 'sourceFingerprint'>) {
|
||||
const canonical = JSON.stringify({
|
||||
sourceRowId: row.sourceRowId,
|
||||
sourceSku: row.sourceSku,
|
||||
bomVersionKey: row.bomVersionKey,
|
||||
category: row.category,
|
||||
categoryCode: row.categoryCode,
|
||||
partNumber: row.partNumber,
|
||||
partName: row.partName,
|
||||
side: row.side,
|
||||
materialName: row.materialName,
|
||||
materialCode: row.materialCode,
|
||||
materialSpec: row.materialSpec,
|
||||
processName: row.processName,
|
||||
unit: row.unit,
|
||||
rawLength: row.rawLength,
|
||||
rawWidth: row.rawWidth,
|
||||
rawLongEdge: row.rawLongEdge,
|
||||
rawShortEdge: row.rawShortEdge,
|
||||
rawQuantity: row.rawQuantity,
|
||||
rawCoefficient: row.rawCoefficient,
|
||||
rawArea: row.rawArea,
|
||||
rawEdgeLength: row.rawEdgeLength,
|
||||
rawUnitPrice: row.rawUnitPrice,
|
||||
rawAmount: row.rawAmount,
|
||||
rawNote: row.rawNote,
|
||||
rawCreatedAt: row.rawCreatedAt,
|
||||
lengthMm: row.lengthMm,
|
||||
widthMm: row.widthMm,
|
||||
longEdgeMm: row.longEdgeMm,
|
||||
shortEdgeMm: row.shortEdgeMm,
|
||||
quantity: row.quantity,
|
||||
coefficient: row.coefficient,
|
||||
areaSqm: row.areaSqm,
|
||||
edgeLengthM: row.edgeLengthM,
|
||||
unitPrice: row.unitPrice,
|
||||
amount: row.amount,
|
||||
sourceCreatedAt: row.sourceCreatedAt?.toISOString() ?? null,
|
||||
isSummaryRow: row.isSummaryRow,
|
||||
})
|
||||
return createHash('sha256').update(canonical).digest('hex')
|
||||
}
|
||||
|
||||
export function normalizeBomReferenceRow(
|
||||
input: UpstreamBomReferenceRawRow,
|
||||
): NormalizedBomReferenceResult {
|
||||
const sourceRowId = text(input.sourceRowId)
|
||||
if (!/^\d+$/.test(sourceRowId)) {
|
||||
throw new Error(`Upstream BOM row has invalid Id: ${sourceRowId || '<empty>'}`)
|
||||
}
|
||||
|
||||
const sourceCreatedAt = dateValue(input.rawCreatedAt)
|
||||
const explicitBomVersionKey = cleanBomReferenceCode(input.bomVersionKey)
|
||||
const rowWithoutFingerprint = {
|
||||
sourceRowId: BigInt(sourceRowId).toString(),
|
||||
sourceSku: cleanBomReferenceCode(input.sourceSku),
|
||||
bomVersionKey: explicitBomVersionKey || CURRENT_BOM_VERSION_KEY,
|
||||
category: text(input.category),
|
||||
categoryCode: normalizeCategoryCode(text(input.categoryCode), text(input.category)),
|
||||
partNumber: cleanBomReferenceCode(input.partNumber),
|
||||
partName: text(input.partName),
|
||||
side: text(input.side),
|
||||
materialName: text(input.materialName),
|
||||
materialCode: cleanBomReferenceCode(input.materialCode),
|
||||
materialSpec: text(input.materialSpec),
|
||||
processName: text(input.processName),
|
||||
unit: text(input.unit),
|
||||
rawLength: text(input.rawLength),
|
||||
rawWidth: text(input.rawWidth),
|
||||
rawLongEdge: text(input.rawLongEdge),
|
||||
rawShortEdge: text(input.rawShortEdge),
|
||||
rawQuantity: text(input.rawQuantity),
|
||||
rawCoefficient: text(input.rawCoefficient),
|
||||
rawArea: text(input.rawArea),
|
||||
rawEdgeLength: text(input.rawEdgeLength),
|
||||
rawUnitPrice: text(input.rawUnitPrice),
|
||||
rawAmount: text(input.rawAmount),
|
||||
rawNote: text(input.rawNote),
|
||||
rawCreatedAt: text(input.rawCreatedAt),
|
||||
lengthMm: decimal(input.rawLength),
|
||||
widthMm: decimal(input.rawWidth),
|
||||
longEdgeMm: decimal(input.rawLongEdge),
|
||||
shortEdgeMm: decimal(input.rawShortEdge),
|
||||
quantity: decimal(input.rawQuantity),
|
||||
coefficient: coefficientDecimal(input.rawCoefficient),
|
||||
areaSqm: decimal(input.rawArea),
|
||||
edgeLengthM: decimal(input.rawEdgeLength),
|
||||
unitPrice: decimal(input.rawUnitPrice),
|
||||
amount: decimal(input.rawAmount),
|
||||
sourceCreatedAt,
|
||||
isSummaryRow: summaryRow({
|
||||
category: text(input.category),
|
||||
partName: text(input.partName),
|
||||
materialName: text(input.materialName),
|
||||
note: text(input.rawNote),
|
||||
}),
|
||||
}
|
||||
const row = {
|
||||
...rowWithoutFingerprint,
|
||||
sourceFingerprint: fingerprint(rowWithoutFingerprint),
|
||||
}
|
||||
const warnings: string[] = []
|
||||
if (!row.sourceSku) warnings.push('sourceSku is empty')
|
||||
if (!row.materialName && !row.materialCode && !row.partName) warnings.push('material identity is empty')
|
||||
if (row.rawUnitPrice && row.unitPrice === null) warnings.push('unitPrice is not numeric')
|
||||
if (row.rawQuantity && row.quantity === null) warnings.push('quantity is not numeric')
|
||||
if (row.rawCreatedAt && row.sourceCreatedAt === null) warnings.push('sourceCreatedAt is not parseable')
|
||||
return { row, warnings }
|
||||
}
|
||||
|
||||
export function cleanBomReferenceText(value: unknown) {
|
||||
return text(value)
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
import {
|
||||
BOM_REFERENCE_PAGE_SIZE,
|
||||
assertReportReaderPrincipal,
|
||||
assertReadOnlyPermissionSnapshot,
|
||||
assertRequiredUpstreamBomReferenceColumns,
|
||||
assertUpstreamBomReferenceView,
|
||||
buildUpstreamBomReferenceMssqlConfig,
|
||||
buildUpstreamBomReferencePageQuery,
|
||||
readUpstreamBomReferenceConfig,
|
||||
resolveUpstreamBomReferenceColumns,
|
||||
} from './upstream-bom-reference-source'
|
||||
|
||||
describe('upstream BOM reference source contract', () => {
|
||||
it('requires a complete source identity and keeps the connection pool at one reader', () => {
|
||||
expect(() => readUpstreamBomReferenceConfig({})).toThrow('BOM_COSTING_UPSTREAM_HOST')
|
||||
|
||||
const config = readUpstreamBomReferenceConfig({
|
||||
BOM_COSTING_UPSTREAM_HOST: 'sql.example.test',
|
||||
BOM_COSTING_UPSTREAM_DATABASE: 'reporting',
|
||||
BOM_COSTING_UPSTREAM_USER: 'report_reader',
|
||||
BOM_COSTING_UPSTREAM_PASSWORD: 'not-used-in-test',
|
||||
BOM_COSTING_UPSTREAM_SCHEMA: 'rpt',
|
||||
BOM_COSTING_UPSTREAM_OBJECT: 'pm_bomBaseInfo',
|
||||
BOM_COSTING_UPSTREAM_ID_COLUMN: 'Id',
|
||||
})
|
||||
const connection = buildUpstreamBomReferenceMssqlConfig(config)
|
||||
expect(connection.pool).toMatchObject({ min: 0, max: 1 })
|
||||
expect(connection.options).toMatchObject({ readOnlyIntent: true })
|
||||
expect(connection.requestTimeout).toBe(30_000)
|
||||
})
|
||||
|
||||
it('reuses the existing planning report_reader before the generic report database', () => {
|
||||
const config = readUpstreamBomReferenceConfig({
|
||||
JST_PLANNING_SQLSERVER_HOST: 'planning.example.test',
|
||||
JST_PLANNING_SQLSERVER_PORT: '1434',
|
||||
JST_PLANNING_SQLSERVER_DATABASE: 'planning-reporting',
|
||||
JST_PLANNING_SQLSERVER_USERNAME: 'report_reader',
|
||||
JST_PLANNING_SQLSERVER_PASSWORD: 'planning-password',
|
||||
JST_PLANNING_SQLSERVER_SCHEMA: 'rpt',
|
||||
JST_PLANNING_SQLSERVER_ENCRYPT: 'false',
|
||||
JST_PLANNING_SQLSERVER_TRUST_SERVER_CERTIFICATE: 'true',
|
||||
JST_REPORT_DB_HOST: 'generic.example.test',
|
||||
JST_REPORT_DB_NAME: 'generic-reporting',
|
||||
JST_REPORT_DB_USER: 'other_reader',
|
||||
JST_REPORT_DB_PASSWORD: 'generic-password',
|
||||
})
|
||||
|
||||
expect(config).toMatchObject({
|
||||
server: 'planning.example.test',
|
||||
port: 1434,
|
||||
database: 'planning-reporting',
|
||||
user: 'report_reader',
|
||||
password: 'planning-password',
|
||||
schema: 'rpt',
|
||||
encrypt: false,
|
||||
trustServerCertificate: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves source columns case-insensitively and builds bounded keyset SQL', () => {
|
||||
const resolved = resolveUpstreamBomReferenceColumns([
|
||||
{ columnName: 'ID', dataType: 'bigint', numericScale: null },
|
||||
{ columnName: 'SkuCode', dataType: 'nvarchar', numericScale: null },
|
||||
{ columnName: 'Name', dataType: 'nvarchar', numericScale: null },
|
||||
{ columnName: 'UnitPrice', dataType: 'numeric', numericScale: 2 },
|
||||
], 'Id')
|
||||
expect(resolved.sourceRowId).toBe('ID')
|
||||
expect(resolved.sourceSku).toBe('SkuCode')
|
||||
expect(resolved.partNumber).toBeNull()
|
||||
expect(resolved.partName).toBe('Name')
|
||||
|
||||
const query = buildUpstreamBomReferencePageQuery({
|
||||
schema: 'rpt',
|
||||
object: 'pm_bomBaseInfo',
|
||||
idColumn: 'ID',
|
||||
resolvedColumns: resolved,
|
||||
})
|
||||
expect(query).toContain('TOP (@limit)')
|
||||
expect(query).toContain('[ID] > @afterId')
|
||||
expect(query).toContain('READUNCOMMITTED')
|
||||
expect(query).toContain('MAXDOP 1')
|
||||
expect(query).not.toMatch(/\b(?:INSERT|UPDATE|DELETE|MERGE|DROP|ALTER)\b/i)
|
||||
expect(BOM_REFERENCE_PAGE_SIZE).toBe(100)
|
||||
})
|
||||
|
||||
it('requires the verified pm_bomBaseInfo contract before starting a scan', () => {
|
||||
const columns = [
|
||||
'Id', 'skucode', 'category', 'number', 'name', 'side', 'length', 'width',
|
||||
'longEdge', 'shortEdge', 'unit', 'quantity', 'coefficient', 'area',
|
||||
'edgeLength', 'unitPrice', 'amount', 'note', 'createdAt',
|
||||
].map((columnName) => ({
|
||||
columnName,
|
||||
dataType: columnName === 'Id' ? 'bigint' : 'nvarchar',
|
||||
numericScale: null,
|
||||
}))
|
||||
const resolved = resolveUpstreamBomReferenceColumns(columns, 'Id')
|
||||
|
||||
expect(() => assertRequiredUpstreamBomReferenceColumns(resolved)).not.toThrow()
|
||||
expect(resolved.partNumber).toBe('number')
|
||||
|
||||
const missingQuantity = { ...resolved, rawQuantity: null }
|
||||
expect(() => assertRequiredUpstreamBomReferenceColumns(missingQuantity))
|
||||
.toThrow('rawQuantity')
|
||||
})
|
||||
|
||||
it('rejects a source principal with any write-capable permission', () => {
|
||||
expect(() => assertReadOnlyPermissionSnapshot({
|
||||
canSelect: true,
|
||||
canInsert: true,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
canExecute: false,
|
||||
canControl: false,
|
||||
canAlter: false,
|
||||
canCreateTable: false,
|
||||
isDbOwner: false,
|
||||
isDbDataWriter: false,
|
||||
isDbDdlAdmin: false,
|
||||
isDbSecurityAdmin: false,
|
||||
})).toThrow('INSERT')
|
||||
|
||||
expect(() => assertReadOnlyPermissionSnapshot({
|
||||
canSelect: true,
|
||||
canInsert: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
canExecute: false,
|
||||
canControl: false,
|
||||
canAlter: false,
|
||||
canCreateTable: false,
|
||||
isDbOwner: false,
|
||||
isDbDataWriter: false,
|
||||
isDbDdlAdmin: false,
|
||||
isDbSecurityAdmin: false,
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('only permits the dedicated report_reader principal and rejects app_writer', () => {
|
||||
expect(() => assertReportReaderPrincipal({
|
||||
configuredUser: 'report_reader',
|
||||
loginName: 'REPORT_READER',
|
||||
databaseUser: 'report_reader',
|
||||
})).not.toThrow()
|
||||
|
||||
expect(() => assertReportReaderPrincipal({
|
||||
configuredUser: 'app_writer',
|
||||
})).toThrow('app_writer')
|
||||
|
||||
expect(() => assertReportReaderPrincipal({
|
||||
configuredUser: 'report_reader',
|
||||
loginName: 'app_writer',
|
||||
databaseUser: 'report_reader',
|
||||
})).toThrow('app_writer')
|
||||
|
||||
expect(() => assertReportReaderPrincipal({
|
||||
configuredUser: 'other_reader',
|
||||
})).toThrow('report_reader')
|
||||
})
|
||||
|
||||
it('only accepts the configured upstream object when it is a view', () => {
|
||||
expect(() => assertUpstreamBomReferenceView('VIEW')).not.toThrow()
|
||||
expect(() => assertUpstreamBomReferenceView(' view ')).not.toThrow()
|
||||
expect(() => assertUpstreamBomReferenceView('BASE TABLE')).toThrow('must be a read-only VIEW')
|
||||
expect(() => assertUpstreamBomReferenceView('')).toThrow('must be a read-only VIEW')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,763 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
type MssqlModule = typeof import('mssql')
|
||||
|
||||
async function loadMssql(): Promise<MssqlModule> {
|
||||
const moduleName = 'mssql'
|
||||
const imported = (await import(moduleName)) as MssqlModule & { default?: MssqlModule }
|
||||
return imported.default ?? imported
|
||||
}
|
||||
|
||||
function boundedSyncEnvironmentInteger(
|
||||
name: string,
|
||||
fallback: number,
|
||||
bounds: { min: number; max: number },
|
||||
) {
|
||||
const raw = process.env[name]?.trim()
|
||||
if (!raw) return fallback
|
||||
const value = Number(raw)
|
||||
if (!Number.isSafeInteger(value) || value < bounds.min || value > bounds.max) {
|
||||
throw new Error(`${name} must be an integer between ${bounds.min} and ${bounds.max}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export const BOM_REFERENCE_PAGE_SIZE = boundedSyncEnvironmentInteger(
|
||||
'BOM_COSTING_REFERENCE_PAGE_SIZE',
|
||||
100,
|
||||
{ min: 25, max: 250 },
|
||||
)
|
||||
export const BOM_REFERENCE_PAGE_DELAY_MS = boundedSyncEnvironmentInteger(
|
||||
'BOM_COSTING_REFERENCE_PAGE_DELAY_MS',
|
||||
250,
|
||||
{ min: 50, max: 5_000 },
|
||||
)
|
||||
|
||||
const DEFAULT_SOURCE_SCHEMA = 'rpt'
|
||||
const DEFAULT_SOURCE_OBJECT = 'pm_bomBaseInfo'
|
||||
export const BOM_REFERENCE_REPORT_READER_LOGIN = 'report_reader'
|
||||
const FORBIDDEN_UPSTREAM_LOGIN = 'app_writer'
|
||||
|
||||
export type SourceField =
|
||||
| 'sourceRowId'
|
||||
| 'sourceSku'
|
||||
| 'bomVersionKey'
|
||||
| 'category'
|
||||
| 'categoryCode'
|
||||
| 'partNumber'
|
||||
| 'partName'
|
||||
| 'side'
|
||||
| 'materialName'
|
||||
| 'materialCode'
|
||||
| 'materialSpec'
|
||||
| 'processName'
|
||||
| 'unit'
|
||||
| 'rawLength'
|
||||
| 'rawWidth'
|
||||
| 'rawLongEdge'
|
||||
| 'rawShortEdge'
|
||||
| 'rawQuantity'
|
||||
| 'rawCoefficient'
|
||||
| 'rawArea'
|
||||
| 'rawEdgeLength'
|
||||
| 'rawUnitPrice'
|
||||
| 'rawAmount'
|
||||
| 'rawNote'
|
||||
| 'rawCreatedAt'
|
||||
|
||||
export type UpstreamBomReferenceConfig = {
|
||||
server: string
|
||||
port: number
|
||||
database: string
|
||||
user: string
|
||||
password: string
|
||||
schema: string
|
||||
object: string
|
||||
idColumn: string
|
||||
encrypt: boolean
|
||||
trustServerCertificate: boolean
|
||||
connectionTimeoutMs: number
|
||||
requestTimeoutMs: number
|
||||
}
|
||||
|
||||
export type UpstreamBomReferenceAccess = {
|
||||
database: string
|
||||
login: string
|
||||
databaseUser: string
|
||||
sourceType: string
|
||||
resolvedColumns: Record<SourceField, string | null>
|
||||
}
|
||||
|
||||
export type UpstreamBomReferenceRawRow = Record<SourceField, unknown>
|
||||
|
||||
export interface UpstreamBomReferenceSource {
|
||||
readonly sourceKey: string
|
||||
readonly sourceIdentity: string
|
||||
readonly configFingerprint: string
|
||||
readonly database: string
|
||||
readonly schema: string
|
||||
readonly object: string
|
||||
validateReadOnly(): Promise<UpstreamBomReferenceAccess>
|
||||
listPage(input: { afterId: string; limit: number }): Promise<UpstreamBomReferenceRawRow[]>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
export type SourceColumn = {
|
||||
columnName: string
|
||||
dataType: string
|
||||
numericScale: number | null
|
||||
}
|
||||
|
||||
export type ReadOnlyPermissionSnapshot = {
|
||||
canSelect: boolean
|
||||
canInsert: boolean
|
||||
canUpdate: boolean
|
||||
canDelete: boolean
|
||||
canExecute: boolean
|
||||
canControl: boolean
|
||||
canAlter: boolean
|
||||
canCreateTable: boolean
|
||||
isDbOwner: boolean
|
||||
isDbDataWriter: boolean
|
||||
isDbDdlAdmin: boolean
|
||||
isDbSecurityAdmin: boolean
|
||||
}
|
||||
|
||||
const SOURCE_FIELD_ALIASES: Record<SourceField, readonly string[]> = {
|
||||
sourceRowId: ['Id', 'id'],
|
||||
sourceSku: ['skucode', 'skuCode', 'sku_code', 'sku', 'externalSkuId'],
|
||||
bomVersionKey: ['bomVersionKey', 'bomVersion', 'bom_version', 'versionKey', 'version'],
|
||||
category: ['category', 'type', 'categoryName', 'materialType'],
|
||||
categoryCode: ['categoryCode', 'category_code', 'typeCode', 'materialTypeCode'],
|
||||
partNumber: ['number', 'partNumber', 'part_number'],
|
||||
partName: ['partName', 'name', 'description', 'part'],
|
||||
side: ['side', 'sideName', 'position'],
|
||||
materialName: ['materialName', 'material_name', 'material', 'name'],
|
||||
materialCode: ['materialCode', 'material_code', 'materialNo', 'materialNumber', 'code'],
|
||||
materialSpec: ['materialSpec', 'material_spec', 'specification', 'spec'],
|
||||
processName: ['processName', 'process_name', 'process', 'craft'],
|
||||
unit: ['unit', 'usageUnit', 'priceUnit'],
|
||||
rawLength: ['length', 'lengthMm', 'long', 'l'],
|
||||
rawWidth: ['width', 'widthMm', 'deep', 'w'],
|
||||
rawLongEdge: ['longEdge', 'long_edge'],
|
||||
rawShortEdge: ['shortEdge', 'short_edge'],
|
||||
rawQuantity: ['quantity', 'qty', 'count', 'amountCount'],
|
||||
rawCoefficient: ['coefficient', 'factor', 'lossFactor', 'consumptionFactor'],
|
||||
rawArea: ['area', 'areaSqm', 'sqm', 'squareMeters'],
|
||||
rawEdgeLength: ['edgeLength', 'edgeLengthM', 'edge_length'],
|
||||
rawUnitPrice: ['unitPrice', 'unit_price', 'price'],
|
||||
rawAmount: ['amount', 'totalAmount', 'subtotal'],
|
||||
rawNote: ['note', 'remark', 'remarks', 'memo'],
|
||||
rawCreatedAt: ['createdAt', 'created', 'createTime', 'sourceCreatedAt'],
|
||||
}
|
||||
|
||||
const QUERY_ALIASES: Record<SourceField, string> = {
|
||||
sourceRowId: '__source_row_id',
|
||||
sourceSku: '__source_sku',
|
||||
bomVersionKey: '__bom_version_key',
|
||||
category: '__category',
|
||||
categoryCode: '__category_code',
|
||||
partNumber: '__part_number',
|
||||
partName: '__part_name',
|
||||
side: '__side',
|
||||
materialName: '__material_name',
|
||||
materialCode: '__material_code',
|
||||
materialSpec: '__material_spec',
|
||||
processName: '__process_name',
|
||||
unit: '__unit',
|
||||
rawLength: '__raw_length',
|
||||
rawWidth: '__raw_width',
|
||||
rawLongEdge: '__raw_long_edge',
|
||||
rawShortEdge: '__raw_short_edge',
|
||||
rawQuantity: '__raw_quantity',
|
||||
rawCoefficient: '__raw_coefficient',
|
||||
rawArea: '__raw_area',
|
||||
rawEdgeLength: '__raw_edge_length',
|
||||
rawUnitPrice: '__raw_unit_price',
|
||||
rawAmount: '__raw_amount',
|
||||
rawNote: '__raw_note',
|
||||
rawCreatedAt: '__raw_created_at',
|
||||
}
|
||||
|
||||
function requiredEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
fallbacks: readonly string[] = [],
|
||||
) {
|
||||
for (const candidate of [name, ...fallbacks]) {
|
||||
const value = env[candidate]?.trim()
|
||||
if (value) return value
|
||||
}
|
||||
throw new Error(`Missing required environment variable: ${name}`)
|
||||
}
|
||||
|
||||
function optionalEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
fallbacks: readonly string[],
|
||||
defaultValue: string,
|
||||
) {
|
||||
for (const candidate of [name, ...fallbacks]) {
|
||||
const value = env[candidate]?.trim()
|
||||
if (value) return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
function booleanEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
fallbacks: readonly string[],
|
||||
defaultValue: boolean,
|
||||
) {
|
||||
const value = optionalEnv(env, name, fallbacks, defaultValue ? 'true' : 'false')
|
||||
.toLowerCase()
|
||||
if (['true', '1', 'yes'].includes(value)) return true
|
||||
if (['false', '0', 'no'].includes(value)) return false
|
||||
throw new Error(`${name} must be true or false`)
|
||||
}
|
||||
|
||||
function integerEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
fallbacks: readonly string[],
|
||||
defaultValue: number,
|
||||
bounds: { min: number; max: number },
|
||||
) {
|
||||
const raw = optionalEnv(env, name, fallbacks, String(defaultValue))
|
||||
const value = Number(raw)
|
||||
if (!Number.isSafeInteger(value) || value < bounds.min || value > bounds.max) {
|
||||
throw new Error(`${name} must be an integer between ${bounds.min} and ${bounds.max}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizedPrincipal(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* The mirror is intentionally bound to the dedicated SQL Server login. A
|
||||
* permission snapshot is still checked below, but it is not enough on its
|
||||
* own: a broadly privileged or accidentally swapped application login must
|
||||
* never be allowed to reach the upstream view.
|
||||
*/
|
||||
export function assertReportReaderPrincipal(input: {
|
||||
configuredUser: string
|
||||
loginName?: string | null
|
||||
databaseUser?: string | null
|
||||
}) {
|
||||
const configured = normalizedPrincipal(input.configuredUser)
|
||||
const login = normalizedPrincipal(input.loginName ?? '')
|
||||
const databaseUser = normalizedPrincipal(input.databaseUser ?? '')
|
||||
const principals = [
|
||||
['configured user', configured],
|
||||
...(input.loginName !== undefined ? [['SQL Server login', login] as const] : []),
|
||||
...(input.databaseUser !== undefined ? [['database user', databaseUser] as const] : []),
|
||||
] as const
|
||||
|
||||
const forbidden = principals.find(([, value]) => value === FORBIDDEN_UPSTREAM_LOGIN)
|
||||
if (forbidden) {
|
||||
throw new Error(`BOM costing upstream principal ${forbidden[0]} is forbidden: app_writer`)
|
||||
}
|
||||
|
||||
const invalid = principals.find(([, value]) => value !== BOM_REFERENCE_REPORT_READER_LOGIN)
|
||||
if (invalid) {
|
||||
throw new Error(
|
||||
`BOM costing upstream ${invalid[0]} must be ${BOM_REFERENCE_REPORT_READER_LOGIN}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSqlServerIdentifier(value: string, label: string) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error(`${label} is not a valid SQL Server identifier`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function readUpstreamBomReferenceConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): UpstreamBomReferenceConfig {
|
||||
const schema = assertSqlServerIdentifier(
|
||||
optionalEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_SCHEMA',
|
||||
['JST_PLANNING_SQLSERVER_SCHEMA', 'JST_REPORT_DB_SCHEMA'],
|
||||
DEFAULT_SOURCE_SCHEMA,
|
||||
),
|
||||
'BOM_COSTING_UPSTREAM_SCHEMA',
|
||||
)
|
||||
const object = assertSqlServerIdentifier(
|
||||
optionalEnv(env, 'BOM_COSTING_UPSTREAM_OBJECT', [], DEFAULT_SOURCE_OBJECT),
|
||||
'BOM_COSTING_UPSTREAM_OBJECT',
|
||||
)
|
||||
const idColumn = assertSqlServerIdentifier(
|
||||
optionalEnv(env, 'BOM_COSTING_UPSTREAM_ID_COLUMN', [], 'Id'),
|
||||
'BOM_COSTING_UPSTREAM_ID_COLUMN',
|
||||
)
|
||||
|
||||
const server = requiredEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_HOST',
|
||||
['JST_PLANNING_SQLSERVER_HOST', 'JST_REPORT_DB_HOST'],
|
||||
)
|
||||
const port = integerEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_PORT',
|
||||
['JST_PLANNING_SQLSERVER_PORT', 'JST_REPORT_DB_PORT'],
|
||||
1433,
|
||||
{
|
||||
min: 1,
|
||||
max: 65_535,
|
||||
},
|
||||
)
|
||||
const database = requiredEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_DATABASE',
|
||||
['JST_PLANNING_SQLSERVER_DATABASE', 'JST_REPORT_DB_NAME'],
|
||||
)
|
||||
const password = requiredEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_PASSWORD',
|
||||
['JST_PLANNING_SQLSERVER_PASSWORD', 'JST_REPORT_DB_PASSWORD'],
|
||||
)
|
||||
const user = requiredEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_USER',
|
||||
['JST_PLANNING_SQLSERVER_USERNAME', 'JST_REPORT_DB_USER'],
|
||||
)
|
||||
assertReportReaderPrincipal({ configuredUser: user })
|
||||
|
||||
return {
|
||||
server,
|
||||
port,
|
||||
database,
|
||||
user,
|
||||
password,
|
||||
schema,
|
||||
object,
|
||||
idColumn,
|
||||
encrypt: booleanEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_ENCRYPT',
|
||||
['JST_PLANNING_SQLSERVER_ENCRYPT', 'JST_REPORT_DB_ENCRYPT'],
|
||||
true,
|
||||
),
|
||||
trustServerCertificate: booleanEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_TRUST_SERVER_CERTIFICATE',
|
||||
[
|
||||
'JST_PLANNING_SQLSERVER_TRUST_SERVER_CERTIFICATE',
|
||||
'JST_REPORT_DB_TRUST_SERVER_CERTIFICATE',
|
||||
],
|
||||
true,
|
||||
),
|
||||
connectionTimeoutMs: integerEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_CONNECTION_TIMEOUT_MS',
|
||||
[],
|
||||
15_000,
|
||||
{ min: 1_000, max: 60_000 },
|
||||
),
|
||||
requestTimeoutMs: integerEnv(
|
||||
env,
|
||||
'BOM_COSTING_UPSTREAM_REQUEST_TIMEOUT_MS',
|
||||
[],
|
||||
30_000,
|
||||
{ min: 5_000, max: 120_000 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function getUpstreamBomReferenceSourceIdentity(config: UpstreamBomReferenceConfig) {
|
||||
return [
|
||||
'sqlserver',
|
||||
config.user,
|
||||
config.server,
|
||||
String(config.port),
|
||||
config.database,
|
||||
config.schema,
|
||||
config.object,
|
||||
].join('|')
|
||||
}
|
||||
|
||||
export function getUpstreamBomReferenceSourceKey(config: UpstreamBomReferenceConfig) {
|
||||
return ['sqlserver', config.database, config.schema, config.object].join(':').toLowerCase()
|
||||
}
|
||||
|
||||
export function getUpstreamBomReferenceConfigFingerprint(config: UpstreamBomReferenceConfig) {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify({
|
||||
server: config.server,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
schema: config.schema,
|
||||
object: config.object,
|
||||
idColumn: config.idColumn,
|
||||
encrypt: config.encrypt,
|
||||
trustServerCertificate: config.trustServerCertificate,
|
||||
}))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export function buildUpstreamBomReferenceMssqlConfig(
|
||||
config: UpstreamBomReferenceConfig,
|
||||
): import('mssql').config {
|
||||
return {
|
||||
server: config.server,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
options: {
|
||||
encrypt: config.encrypt,
|
||||
trustServerCertificate: config.trustServerCertificate,
|
||||
readOnlyIntent: true,
|
||||
appName: 'furniture-3d-bom-reference-readonly-sync',
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 1,
|
||||
idleTimeoutMillis: 10_000,
|
||||
},
|
||||
connectionTimeout: config.connectionTimeoutMs,
|
||||
requestTimeout: config.requestTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function assertReadOnlyPermissionSnapshot(snapshot: ReadOnlyPermissionSnapshot) {
|
||||
if (!snapshot.canSelect) {
|
||||
throw new Error('BOM costing upstream principal does not have SELECT permission')
|
||||
}
|
||||
|
||||
const writableCapabilities = [
|
||||
['INSERT', snapshot.canInsert],
|
||||
['UPDATE', snapshot.canUpdate],
|
||||
['DELETE', snapshot.canDelete],
|
||||
['EXECUTE', snapshot.canExecute],
|
||||
['CONTROL', snapshot.canControl],
|
||||
['ALTER', snapshot.canAlter],
|
||||
['CREATE TABLE', snapshot.canCreateTable],
|
||||
['db_owner', snapshot.isDbOwner],
|
||||
['db_datawriter', snapshot.isDbDataWriter],
|
||||
['db_ddladmin', snapshot.isDbDdlAdmin],
|
||||
['db_securityadmin', snapshot.isDbSecurityAdmin],
|
||||
].filter(([, enabled]) => enabled)
|
||||
|
||||
if (writableCapabilities.length > 0) {
|
||||
throw new Error(
|
||||
`BOM costing upstream principal is not read-only: ${writableCapabilities
|
||||
.map(([name]) => name)
|
||||
.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertUpstreamBomReferenceView(sourceType: string | null | undefined) {
|
||||
const normalized = sourceType?.trim().toUpperCase() ?? ''
|
||||
if (normalized !== 'VIEW') {
|
||||
throw new Error('BOM costing upstream source must be a read-only VIEW')
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveUpstreamBomReferenceColumns(
|
||||
columns: readonly SourceColumn[],
|
||||
idColumn: string,
|
||||
): Record<SourceField, string | null> {
|
||||
const actualByLowercase = new Map(
|
||||
columns.map((column) => [column.columnName.toLowerCase(), column.columnName]),
|
||||
)
|
||||
const resolved = {} as Record<SourceField, string | null>
|
||||
|
||||
for (const field of Object.keys(SOURCE_FIELD_ALIASES) as SourceField[]) {
|
||||
const candidates = field === 'sourceRowId'
|
||||
? [idColumn, ...SOURCE_FIELD_ALIASES[field]]
|
||||
: SOURCE_FIELD_ALIASES[field]
|
||||
resolved[field] = candidates
|
||||
.map((candidate) => actualByLowercase.get(candidate.toLowerCase()))
|
||||
.find(Boolean) ?? null
|
||||
}
|
||||
|
||||
if (!resolved.sourceRowId) {
|
||||
throw new Error(`Upstream BOM source is missing keyset column: ${idColumn}`)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
const REQUIRED_UPSTREAM_SOURCE_FIELDS: readonly SourceField[] = [
|
||||
'sourceRowId',
|
||||
'sourceSku',
|
||||
'category',
|
||||
'partNumber',
|
||||
'partName',
|
||||
'side',
|
||||
'unit',
|
||||
'rawLength',
|
||||
'rawWidth',
|
||||
'rawLongEdge',
|
||||
'rawShortEdge',
|
||||
'rawQuantity',
|
||||
'rawCoefficient',
|
||||
'rawArea',
|
||||
'rawEdgeLength',
|
||||
'rawUnitPrice',
|
||||
'rawAmount',
|
||||
'rawNote',
|
||||
'rawCreatedAt',
|
||||
]
|
||||
|
||||
export function assertRequiredUpstreamBomReferenceColumns(
|
||||
resolvedColumns: Record<SourceField, string | null>,
|
||||
) {
|
||||
const missing = REQUIRED_UPSTREAM_SOURCE_FIELDS.filter((field) => !resolvedColumns[field])
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Upstream BOM source is missing required columns: ${missing.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function quoteIdentifier(identifier: string) {
|
||||
assertSqlServerIdentifier(identifier, 'SQL Server identifier')
|
||||
return `[${identifier}]`
|
||||
}
|
||||
|
||||
export function buildUpstreamBomReferencePageQuery(input: {
|
||||
schema: string
|
||||
object: string
|
||||
idColumn: string
|
||||
resolvedColumns: Record<SourceField, string | null>
|
||||
}) {
|
||||
const selections = (Object.keys(QUERY_ALIASES) as SourceField[]).map((field) => {
|
||||
const sourceColumn = input.resolvedColumns[field]
|
||||
const expression = sourceColumn
|
||||
? quoteIdentifier(sourceColumn)
|
||||
: 'CAST(NULL AS nvarchar(1))'
|
||||
return `${expression} AS ${quoteIdentifier(QUERY_ALIASES[field])}`
|
||||
})
|
||||
return `
|
||||
SELECT TOP (@limit)
|
||||
${selections.join(',\n ')}
|
||||
FROM ${quoteIdentifier(input.schema)}.${quoteIdentifier(input.object)} WITH (READUNCOMMITTED)
|
||||
WHERE ${quoteIdentifier(input.idColumn)} > @afterId
|
||||
ORDER BY ${quoteIdentifier(input.idColumn)} ASC
|
||||
OPTION (MAXDOP 1)
|
||||
`
|
||||
}
|
||||
|
||||
function normalizeSourceId(value: unknown) {
|
||||
const raw = typeof value === 'bigint'
|
||||
? value.toString()
|
||||
: typeof value === 'number' && Number.isSafeInteger(value)
|
||||
? String(value)
|
||||
: typeof value === 'string'
|
||||
? value.trim()
|
||||
: ''
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new Error(`Upstream BOM Id is not a non-negative integer: ${raw || '<empty>'}`)
|
||||
}
|
||||
return BigInt(raw).toString()
|
||||
}
|
||||
|
||||
function booleanCell(value: unknown) {
|
||||
return value === true || value === 1
|
||||
}
|
||||
|
||||
export class MssqlUpstreamBomReferenceSource implements UpstreamBomReferenceSource {
|
||||
readonly sourceKey: string
|
||||
readonly sourceIdentity: string
|
||||
readonly configFingerprint: string
|
||||
readonly database: string
|
||||
readonly schema: string
|
||||
readonly object: string
|
||||
|
||||
private mssql: MssqlModule | null = null
|
||||
private pool: import('mssql').ConnectionPool | null = null
|
||||
private access: UpstreamBomReferenceAccess | null = null
|
||||
|
||||
constructor(private readonly config: UpstreamBomReferenceConfig) {
|
||||
this.sourceKey = getUpstreamBomReferenceSourceKey(config)
|
||||
this.sourceIdentity = getUpstreamBomReferenceSourceIdentity(config)
|
||||
this.configFingerprint = getUpstreamBomReferenceConfigFingerprint(config)
|
||||
this.database = config.database
|
||||
this.schema = config.schema
|
||||
this.object = config.object
|
||||
}
|
||||
|
||||
private async getMssql() {
|
||||
this.mssql ??= await loadMssql()
|
||||
return this.mssql
|
||||
}
|
||||
|
||||
private async getPool() {
|
||||
if (this.pool?.connected) return this.pool
|
||||
const mssql = await this.getMssql()
|
||||
this.pool = await new mssql.ConnectionPool(
|
||||
buildUpstreamBomReferenceMssqlConfig(this.config),
|
||||
).connect()
|
||||
return this.pool
|
||||
}
|
||||
|
||||
async validateReadOnly(): Promise<UpstreamBomReferenceAccess> {
|
||||
if (this.access) return this.access
|
||||
const mssql = await this.getMssql()
|
||||
const pool = await this.getPool()
|
||||
const identityResult = await pool.request().query<{
|
||||
database_name: string
|
||||
login_name: string
|
||||
database_user: string
|
||||
}>(`
|
||||
SELECT
|
||||
DB_NAME() AS database_name,
|
||||
ORIGINAL_LOGIN() AS login_name,
|
||||
USER_NAME() AS database_user
|
||||
`)
|
||||
const identity = identityResult.recordset[0]
|
||||
if (!identity || identity.database_name.toLowerCase() !== this.config.database.toLowerCase()) {
|
||||
throw new Error('BOM costing upstream database identity mismatch')
|
||||
}
|
||||
assertReportReaderPrincipal({
|
||||
configuredUser: this.config.user,
|
||||
loginName: identity.login_name,
|
||||
databaseUser: identity.database_user,
|
||||
})
|
||||
|
||||
const permissionResult = await pool
|
||||
.request()
|
||||
.input('schemaName', mssql.NVarChar(128), this.config.schema)
|
||||
.input('objectName', mssql.NVarChar(128), this.config.object)
|
||||
.query<Record<string, unknown>>(`
|
||||
DECLARE @sourceObject nvarchar(517) = @schemaName + N'.' + @objectName;
|
||||
SELECT
|
||||
CASE WHEN ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'SELECT'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(@sourceObject, 'OBJECT', 'SELECT'), 0) = 1
|
||||
THEN 1 ELSE 0 END AS can_select,
|
||||
CASE WHEN ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'INSERT'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(@sourceObject, 'OBJECT', 'INSERT'), 0) = 1
|
||||
THEN 1 ELSE 0 END AS can_insert,
|
||||
CASE WHEN ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'UPDATE'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(@sourceObject, 'OBJECT', 'UPDATE'), 0) = 1
|
||||
THEN 1 ELSE 0 END AS can_update,
|
||||
CASE WHEN ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'DELETE'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(@sourceObject, 'OBJECT', 'DELETE'), 0) = 1
|
||||
THEN 1 ELSE 0 END AS can_delete,
|
||||
HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'EXECUTE') AS can_execute,
|
||||
HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CONTROL') AS can_control,
|
||||
HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'ALTER') AS can_alter,
|
||||
HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE TABLE') AS can_create_table,
|
||||
IS_ROLEMEMBER('db_owner') AS is_db_owner,
|
||||
IS_ROLEMEMBER('db_datawriter') AS is_db_datawriter,
|
||||
IS_ROLEMEMBER('db_ddladmin') AS is_db_ddladmin,
|
||||
IS_ROLEMEMBER('db_securityadmin') AS is_db_securityadmin
|
||||
`)
|
||||
const permission = permissionResult.recordset[0] ?? {}
|
||||
assertReadOnlyPermissionSnapshot({
|
||||
canSelect: booleanCell(permission.can_select),
|
||||
canInsert: booleanCell(permission.can_insert),
|
||||
canUpdate: booleanCell(permission.can_update),
|
||||
canDelete: booleanCell(permission.can_delete),
|
||||
canExecute: booleanCell(permission.can_execute),
|
||||
canControl: booleanCell(permission.can_control),
|
||||
canAlter: booleanCell(permission.can_alter),
|
||||
canCreateTable: booleanCell(permission.can_create_table),
|
||||
isDbOwner: booleanCell(permission.is_db_owner),
|
||||
isDbDataWriter: booleanCell(permission.is_db_datawriter),
|
||||
isDbDdlAdmin: booleanCell(permission.is_db_ddladmin),
|
||||
isDbSecurityAdmin: booleanCell(permission.is_db_securityadmin),
|
||||
})
|
||||
|
||||
const columnsResult = await pool
|
||||
.request()
|
||||
.input('schemaName', mssql.NVarChar(128), this.config.schema)
|
||||
.input('objectName', mssql.NVarChar(128), this.config.object)
|
||||
.query<{
|
||||
column_name: string
|
||||
data_type: string
|
||||
numeric_scale: number | null
|
||||
object_type: string
|
||||
}>(`
|
||||
SELECT
|
||||
column_info.COLUMN_NAME AS column_name,
|
||||
column_info.DATA_TYPE AS data_type,
|
||||
column_info.NUMERIC_SCALE AS numeric_scale,
|
||||
table_info.TABLE_TYPE AS object_type
|
||||
FROM INFORMATION_SCHEMA.COLUMNS AS column_info
|
||||
INNER JOIN INFORMATION_SCHEMA.TABLES AS table_info
|
||||
ON table_info.TABLE_SCHEMA = column_info.TABLE_SCHEMA
|
||||
AND table_info.TABLE_NAME = column_info.TABLE_NAME
|
||||
WHERE column_info.TABLE_SCHEMA = @schemaName
|
||||
AND column_info.TABLE_NAME = @objectName
|
||||
ORDER BY column_info.ORDINAL_POSITION
|
||||
`)
|
||||
const columns = columnsResult.recordset.map((column) => ({
|
||||
columnName: column.column_name,
|
||||
dataType: column.data_type.toLowerCase(),
|
||||
numericScale: column.numeric_scale,
|
||||
}))
|
||||
if (columns.length === 0) {
|
||||
throw new Error(
|
||||
`Missing upstream BOM source: ${this.config.schema}.${this.config.object}`,
|
||||
)
|
||||
}
|
||||
const sourceType = columnsResult.recordset[0]?.object_type ?? ''
|
||||
assertUpstreamBomReferenceView(sourceType)
|
||||
const resolvedColumns = resolveUpstreamBomReferenceColumns(columns, this.config.idColumn)
|
||||
assertRequiredUpstreamBomReferenceColumns(resolvedColumns)
|
||||
const idMetadata = columns.find(
|
||||
(column) => column.columnName.toLowerCase() === resolvedColumns.sourceRowId?.toLowerCase(),
|
||||
)
|
||||
const integerTypes = new Set(['bigint', 'int', 'smallint', 'tinyint'])
|
||||
const fixedInteger = idMetadata &&
|
||||
['numeric', 'decimal'].includes(idMetadata.dataType) &&
|
||||
(idMetadata.numericScale ?? 0) === 0
|
||||
if (!idMetadata || (!integerTypes.has(idMetadata.dataType) && !fixedInteger)) {
|
||||
throw new Error('Upstream BOM keyset column must be an integer-compatible Id')
|
||||
}
|
||||
|
||||
this.access = {
|
||||
database: identity.database_name,
|
||||
login: identity.login_name,
|
||||
databaseUser: identity.database_user,
|
||||
sourceType,
|
||||
resolvedColumns,
|
||||
}
|
||||
return this.access
|
||||
}
|
||||
|
||||
async listPage(input: { afterId: string; limit: number }) {
|
||||
const afterId = normalizeSourceId(input.afterId)
|
||||
if (input.limit !== BOM_REFERENCE_PAGE_SIZE) {
|
||||
throw new Error(`BOM reference sync page size is fixed at ${BOM_REFERENCE_PAGE_SIZE}`)
|
||||
}
|
||||
const access = await this.validateReadOnly()
|
||||
const mssql = await this.getMssql()
|
||||
const pool = await this.getPool()
|
||||
const result = await pool
|
||||
.request()
|
||||
.input('afterId', mssql.BigInt, afterId)
|
||||
.input('limit', mssql.Int, BOM_REFERENCE_PAGE_SIZE)
|
||||
.query<Record<string, unknown>>(buildUpstreamBomReferencePageQuery({
|
||||
schema: this.config.schema,
|
||||
object: this.config.object,
|
||||
idColumn: access.resolvedColumns.sourceRowId ?? this.config.idColumn,
|
||||
resolvedColumns: access.resolvedColumns,
|
||||
}))
|
||||
|
||||
return result.recordset.map((record) => {
|
||||
const row = {} as UpstreamBomReferenceRawRow
|
||||
for (const field of Object.keys(QUERY_ALIASES) as SourceField[]) {
|
||||
row[field] = record[QUERY_ALIASES[field]]
|
||||
}
|
||||
row.sourceRowId = normalizeSourceId(row.sourceRowId)
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.pool) return
|
||||
await this.pool.close()
|
||||
this.pool = null
|
||||
this.mssql = null
|
||||
this.access = null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,423 @@
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||
|
||||
import { BomCostingReferenceSyncStatus } from '@prisma/client'
|
||||
import { normalizeBomReferenceRow } from './upstream-bom-reference-normalizer'
|
||||
import { PrismaBomReferenceMirrorStore } from './upstream-bom-reference-store'
|
||||
|
||||
function normalizedRow() {
|
||||
return normalizeBomReferenceRow({
|
||||
sourceRowId: '1',
|
||||
sourceSku: 'SKU-1',
|
||||
bomVersionKey: 'V1',
|
||||
category: '板材',
|
||||
categoryCode: 'BOARD',
|
||||
partNumber: 'P-001',
|
||||
partName: '侧板',
|
||||
side: '',
|
||||
materialName: '橡胶木',
|
||||
materialCode: 'WOOD-01',
|
||||
materialSpec: '20 mm',
|
||||
processName: '开料',
|
||||
unit: '件',
|
||||
rawLength: '1200',
|
||||
rawWidth: '600',
|
||||
rawLongEdge: '',
|
||||
rawShortEdge: '',
|
||||
rawQuantity: '1',
|
||||
rawCoefficient: '1',
|
||||
rawArea: '',
|
||||
rawEdgeLength: '',
|
||||
rawUnitPrice: '12.5',
|
||||
rawAmount: '12.5',
|
||||
rawNote: '',
|
||||
rawCreatedAt: '2026-08-07T00:00:00.000Z',
|
||||
}).row
|
||||
}
|
||||
|
||||
describe('PrismaBomReferenceMirrorStore snapshot contract', () => {
|
||||
const resumeInput = {
|
||||
sourceSystem: 'JSReportReplica',
|
||||
sourceDatabase: 'reporting',
|
||||
sourceSchema: 'rpt',
|
||||
sourceObject: 'pm_bomBaseInfo',
|
||||
configFingerprint: 'a'.repeat(64),
|
||||
batchKey: 'ignored-on-resume',
|
||||
cursorStart: '0',
|
||||
}
|
||||
|
||||
it('writes each page in a short transaction and copies unchanged rows into the new snapshot', async () => {
|
||||
const row = normalizedRow()
|
||||
const transactionClient = {
|
||||
bomCostingReferenceRow: {
|
||||
findMany: jest.fn()
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'old-row',
|
||||
sourceRowId: row.sourceRowId,
|
||||
sourceSku: row.sourceSku,
|
||||
bomVersionKey: row.bomVersionKey,
|
||||
sourceFingerprint: row.sourceFingerprint,
|
||||
}])
|
||||
.mockResolvedValueOnce([]),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
}
|
||||
const rootClient = {
|
||||
$transaction: jest.fn(async (callback: (tx: typeof transactionClient) => Promise<unknown>) => (
|
||||
callback(transactionClient)
|
||||
)),
|
||||
}
|
||||
const store = new PrismaBomReferenceMirrorStore(rootClient as never)
|
||||
|
||||
const result = await store.writePage({
|
||||
id: 'run-1',
|
||||
previousSuccessfulRunId: 'run-success',
|
||||
}, [row])
|
||||
|
||||
expect(result).toEqual({ created: 0, updated: 0, unchanged: 1, rowsWritten: 1 })
|
||||
expect(rootClient.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(transactionClient.bomCostingReferenceRow.createMany).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps page counters stable when a resume replays rows written before the checkpoint', async () => {
|
||||
const row = normalizedRow()
|
||||
const existing = {
|
||||
id: 'existing-row',
|
||||
sourceRowId: row.sourceRowId,
|
||||
sourceSku: row.sourceSku,
|
||||
bomVersionKey: row.bomVersionKey,
|
||||
sourceFingerprint: row.sourceFingerprint,
|
||||
}
|
||||
const transactionClient = {
|
||||
bomCostingReferenceRow: {
|
||||
findMany: jest.fn()
|
||||
.mockResolvedValueOnce([existing])
|
||||
.mockResolvedValueOnce([existing]),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
}
|
||||
const rootClient = {
|
||||
$transaction: jest.fn(async (callback: (tx: typeof transactionClient) => Promise<unknown>) => (
|
||||
callback(transactionClient)
|
||||
)),
|
||||
}
|
||||
const store = new PrismaBomReferenceMirrorStore(rootClient as never)
|
||||
|
||||
await expect(store.writePage({
|
||||
id: 'run-1',
|
||||
previousSuccessfulRunId: 'run-success',
|
||||
}, [row])).resolves.toEqual({
|
||||
created: 0,
|
||||
updated: 0,
|
||||
unchanged: 1,
|
||||
rowsWritten: 1,
|
||||
})
|
||||
expect(transactionClient.bomCostingReferenceRow.createMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to resume when a later cleanup removed persisted rows from the failed snapshot', async () => {
|
||||
const startedAt = new Date('2026-08-07T00:00:00.000Z')
|
||||
const existing = {
|
||||
id: 'run-failed',
|
||||
startedAt,
|
||||
cursorEnd: '100',
|
||||
rowsRead: 100,
|
||||
rowsWritten: 100,
|
||||
rowsCreated: 90,
|
||||
rowsUpdated: 5,
|
||||
rowsUnchanged: 5,
|
||||
rowsSkipped: 0,
|
||||
errorCount: 1,
|
||||
sourceAsOf: null,
|
||||
}
|
||||
const client = {
|
||||
bomCostingReferenceSyncRun: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce({ id: 'run-success' })
|
||||
.mockResolvedValueOnce({ id: 'run-success' }),
|
||||
update: jest.fn(),
|
||||
},
|
||||
bomCostingReferenceRow: {
|
||||
count: jest.fn().mockResolvedValue(99),
|
||||
},
|
||||
}
|
||||
const store = new PrismaBomReferenceMirrorStore(client as never)
|
||||
|
||||
await expect(store.resumeRun('run-failed', resumeInput)).rejects.toThrow(
|
||||
'missing persisted rows',
|
||||
)
|
||||
expect(client.bomCostingReferenceSyncRun.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to resume a partial snapshot after the visible successful baseline changed', async () => {
|
||||
const startedAt = new Date('2026-08-07T00:00:00.000Z')
|
||||
const existing = {
|
||||
id: 'run-failed',
|
||||
startedAt,
|
||||
cursorEnd: '100',
|
||||
rowsRead: 100,
|
||||
rowsWritten: 100,
|
||||
rowsCreated: 90,
|
||||
rowsUpdated: 5,
|
||||
rowsUnchanged: 5,
|
||||
rowsSkipped: 0,
|
||||
errorCount: 1,
|
||||
sourceAsOf: null,
|
||||
}
|
||||
const client = {
|
||||
bomCostingReferenceSyncRun: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce({ id: 'run-new-success' })
|
||||
.mockResolvedValueOnce({ id: 'run-old-success' }),
|
||||
update: jest.fn(),
|
||||
},
|
||||
bomCostingReferenceRow: {
|
||||
count: jest.fn().mockResolvedValue(100),
|
||||
},
|
||||
}
|
||||
const store = new PrismaBomReferenceMirrorStore(client as never)
|
||||
|
||||
await expect(store.resumeRun('run-failed', resumeInput)).rejects.toThrow(
|
||||
'different successful baseline',
|
||||
)
|
||||
expect(client.bomCostingReferenceSyncRun.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads only the latest successful snapshot even when a newer failed batch exists', async () => {
|
||||
const now = Date.now()
|
||||
const successful = {
|
||||
id: 'run-success',
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
batchKey: 'batch-success',
|
||||
startedAt: new Date(now - 120_000),
|
||||
completedAt: new Date(now - 60_000),
|
||||
sourceAsOf: new Date('2026-08-06T00:00:00.000Z'),
|
||||
errorMessage: '',
|
||||
}
|
||||
const failed = {
|
||||
id: 'run-failed',
|
||||
status: BomCostingReferenceSyncStatus.FAILED,
|
||||
batchKey: 'batch-failed',
|
||||
startedAt: new Date(now - 30_000),
|
||||
completedAt: new Date(now - 20_000),
|
||||
sourceAsOf: null,
|
||||
errorMessage: 'timeout',
|
||||
}
|
||||
const client = {
|
||||
bomCostingReferenceSyncRun: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce(successful)
|
||||
.mockResolvedValueOnce(failed),
|
||||
},
|
||||
bomCostingReferenceRow: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce({ syncRunId: successful.id })
|
||||
.mockResolvedValueOnce(null),
|
||||
},
|
||||
}
|
||||
const store = new PrismaBomReferenceMirrorStore(client as never)
|
||||
|
||||
await expect(store.getLatestRunStatuses()).resolves.toEqual({
|
||||
latestSuccessful: successful,
|
||||
latestFailed: failed,
|
||||
mirrorState: 'READY',
|
||||
mirrorRunIds: ['run-success'],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports mixed when an incomplete snapshot still has mirror rows', async () => {
|
||||
const successful = {
|
||||
id: 'run-success',
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
batchKey: 'batch-success',
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
sourceAsOf: new Date(),
|
||||
errorMessage: '',
|
||||
}
|
||||
const client = {
|
||||
bomCostingReferenceSyncRun: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce(successful)
|
||||
.mockResolvedValueOnce(null),
|
||||
},
|
||||
bomCostingReferenceRow: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce({ syncRunId: successful.id })
|
||||
.mockResolvedValueOnce({ syncRunId: 'run-incomplete' }),
|
||||
},
|
||||
}
|
||||
const store = new PrismaBomReferenceMirrorStore(client as never)
|
||||
|
||||
await expect(store.getLatestRunStatuses()).resolves.toMatchObject({
|
||||
mirrorState: 'MIXED',
|
||||
mirrorRunIds: ['run-success', 'run-incomplete'],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mark a freshly completed mirror stale because source creation timestamps are historical', async () => {
|
||||
const previousMaxAgeDays = process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS
|
||||
process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS = '2'
|
||||
try {
|
||||
const successful = {
|
||||
id: 'run-success',
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
batchKey: 'batch-success',
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
sourceAsOf: new Date(Date.now() - 10 * 86_400_000),
|
||||
errorMessage: '',
|
||||
}
|
||||
const client = {
|
||||
bomCostingReferenceSyncRun: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce(successful)
|
||||
.mockResolvedValueOnce(null),
|
||||
},
|
||||
bomCostingReferenceRow: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce({ syncRunId: successful.id })
|
||||
.mockResolvedValueOnce(null),
|
||||
},
|
||||
}
|
||||
const store = new PrismaBomReferenceMirrorStore(client as never)
|
||||
|
||||
await expect(store.getLatestRunStatuses()).resolves.toMatchObject({ mirrorState: 'READY' })
|
||||
} finally {
|
||||
if (previousMaxAgeDays === undefined) delete process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS
|
||||
else process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS = previousMaxAgeDays
|
||||
}
|
||||
})
|
||||
|
||||
it('reports stale only when the successful mirror completion is too old', async () => {
|
||||
const previousMaxAgeDays = process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS
|
||||
process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS = '2'
|
||||
try {
|
||||
const successful = {
|
||||
id: 'run-success',
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
batchKey: 'batch-success',
|
||||
startedAt: new Date(Date.now() - 11 * 86_400_000),
|
||||
completedAt: new Date(Date.now() - 10 * 86_400_000),
|
||||
sourceAsOf: new Date(Date.now() - 20 * 86_400_000),
|
||||
errorMessage: '',
|
||||
}
|
||||
const client = {
|
||||
bomCostingReferenceSyncRun: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce(successful)
|
||||
.mockResolvedValueOnce(null),
|
||||
},
|
||||
bomCostingReferenceRow: {
|
||||
findFirst: jest.fn()
|
||||
.mockResolvedValueOnce({ syncRunId: successful.id })
|
||||
.mockResolvedValueOnce(null),
|
||||
},
|
||||
}
|
||||
const store = new PrismaBomReferenceMirrorStore(client as never)
|
||||
|
||||
await expect(store.getLatestRunStatuses()).resolves.toMatchObject({ mirrorState: 'STALE' })
|
||||
} finally {
|
||||
if (previousMaxAgeDays === undefined) delete process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS
|
||||
else process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS = previousMaxAgeDays
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PrismaBomReferenceMirrorStore material option refresh', () => {
|
||||
function clientWithMaterials(input: {
|
||||
systemNames?: Array<Record<string, unknown>>
|
||||
categories?: Array<Record<string, unknown>>
|
||||
}) {
|
||||
return {
|
||||
substrateSystemNameOption: {
|
||||
findMany: jest.fn().mockResolvedValue(input.systemNames ?? []),
|
||||
},
|
||||
substrateCategory: {
|
||||
findMany: jest.fn().mockResolvedValue(input.categories ?? []),
|
||||
},
|
||||
bomCostingMaterialOption: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
upsert: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps the configured system-name option and merges matching category aliases', async () => {
|
||||
const client = clientWithMaterials({
|
||||
systemNames: [{
|
||||
id: 'system-rubberwood',
|
||||
name: '橡胶木',
|
||||
sortOrder: 2,
|
||||
isActive: true,
|
||||
substrates: [{ code: 'RW-18', name: '橡胶木板', metadata: null }],
|
||||
}],
|
||||
categories: [{
|
||||
id: 'category-rubberwood',
|
||||
name: '橡胶木',
|
||||
sortOrder: 3,
|
||||
substrates: [{
|
||||
code: 'RW-20',
|
||||
name: '20mm 橡胶木板',
|
||||
metadata: { systemName: '20ENF级橡胶木' },
|
||||
}],
|
||||
}],
|
||||
})
|
||||
const store = new PrismaBomReferenceMirrorStore(client as never)
|
||||
|
||||
await expect(store.refreshMaterialOptions()).resolves.toEqual({
|
||||
sourceCount: 1,
|
||||
enabledCount: 1,
|
||||
})
|
||||
expect(client.bomCostingMaterialOption.upsert).toHaveBeenCalledTimes(1)
|
||||
const upsert = client.bomCostingMaterialOption.upsert.mock.calls[0]?.[0]
|
||||
expect(upsert.where.id).toBe('bom-material-system-rubberwood')
|
||||
expect(upsert.create.aliases).toEqual(expect.arrayContaining([
|
||||
'橡胶木',
|
||||
'RW-18',
|
||||
'RW-20',
|
||||
'20ENF级橡胶木',
|
||||
]))
|
||||
})
|
||||
|
||||
it('derives board choices from real substrate categories when the option table is empty', async () => {
|
||||
const client = clientWithMaterials({
|
||||
categories: [
|
||||
{
|
||||
id: 'category-rubberwood',
|
||||
name: '橡胶木',
|
||||
sortOrder: 4,
|
||||
substrates: [{
|
||||
code: 'RW-18',
|
||||
name: '18mm 橡胶木板',
|
||||
metadata: { systemName: '18ENF级橡胶木' },
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'category-carton',
|
||||
name: '纸箱',
|
||||
sortOrder: 5,
|
||||
substrates: [{ code: 'BOX-1', name: '五层纸箱', metadata: null }],
|
||||
},
|
||||
],
|
||||
})
|
||||
const store = new PrismaBomReferenceMirrorStore(client as never)
|
||||
|
||||
await expect(store.refreshMaterialOptions()).resolves.toEqual({
|
||||
sourceCount: 1,
|
||||
enabledCount: 1,
|
||||
})
|
||||
expect(client.bomCostingMaterialOption.upsert).toHaveBeenCalledTimes(1)
|
||||
expect(client.bomCostingMaterialOption.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'bom-material-category-category-rubberwood' },
|
||||
create: expect.objectContaining({
|
||||
code: 'SUBSTRATE_CATEGORY:category-rubberwood',
|
||||
name: '橡胶木',
|
||||
category: 'BOARD',
|
||||
enabled: true,
|
||||
aliases: expect.arrayContaining(['RW-18', '18ENF级橡胶木']),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,670 @@
|
||||
import { Client } from 'pg'
|
||||
import {
|
||||
BomCostingReferenceSyncStatus,
|
||||
Prisma,
|
||||
type PrismaClient,
|
||||
} from '@prisma/client'
|
||||
import { prisma as defaultPrisma } from '@/lib/prisma'
|
||||
import {
|
||||
isNonBoardSubstrate,
|
||||
normalizeBomMaterialName,
|
||||
} from '@/lib/bom-costing/material-family'
|
||||
import type { NormalizedBomReferenceRow } from './upstream-bom-reference-normalizer'
|
||||
|
||||
export type BomReferenceSyncRunInput = {
|
||||
sourceSystem: string
|
||||
sourceDatabase: string
|
||||
sourceSchema: string
|
||||
sourceObject: string
|
||||
configFingerprint: string
|
||||
batchKey: string
|
||||
cursorStart: string
|
||||
}
|
||||
|
||||
export type BomReferenceSyncProgress = {
|
||||
cursorEnd: string
|
||||
rowsRead: number
|
||||
rowsWritten: number
|
||||
rowsCreated: number
|
||||
rowsUpdated: number
|
||||
rowsUnchanged: number
|
||||
rowsSkipped: number
|
||||
errorCount: number
|
||||
durationMs: number
|
||||
/** Maximum source timestamp observed in the view; not a freshness clock. */
|
||||
sourceAsOf: Date | null
|
||||
}
|
||||
|
||||
export type BomReferencePageCounts = {
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
rowsWritten: number
|
||||
}
|
||||
|
||||
export type BomCostingMaterialRefreshCounts = {
|
||||
sourceCount: number
|
||||
enabledCount: number
|
||||
}
|
||||
|
||||
export type BomReferenceSyncRunHandle = {
|
||||
id: string
|
||||
previousSuccessfulRunId?: string | null
|
||||
cursorEnd?: string
|
||||
rowsRead?: number
|
||||
rowsWritten?: number
|
||||
rowsCreated?: number
|
||||
rowsUpdated?: number
|
||||
rowsUnchanged?: number
|
||||
rowsSkipped?: number
|
||||
errorCount?: number
|
||||
sourceAsOf?: Date | null
|
||||
}
|
||||
|
||||
export type BomReferenceRunStatusSnapshot = {
|
||||
id: string
|
||||
status: BomCostingReferenceSyncStatus
|
||||
batchKey: string
|
||||
startedAt: Date
|
||||
completedAt: Date | null
|
||||
sourceAsOf: Date | null
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export type BomReferenceLatestRunStatuses = {
|
||||
latestSuccessful: BomReferenceRunStatusSnapshot | null
|
||||
latestFailed: BomReferenceRunStatusSnapshot | null
|
||||
mirrorState: 'EMPTY' | 'READY' | 'MIXED' | 'STALE'
|
||||
mirrorRunIds: string[]
|
||||
}
|
||||
|
||||
export interface BomReferenceMirrorStore {
|
||||
countRows(): Promise<number>
|
||||
startRun(input: BomReferenceSyncRunInput): Promise<BomReferenceSyncRunHandle>
|
||||
resumeRun(runId: string, input: BomReferenceSyncRunInput): Promise<BomReferenceSyncRunHandle | null>
|
||||
inspectPage(rows: readonly NormalizedBomReferenceRow[]): Promise<BomReferencePageCounts>
|
||||
writePage(
|
||||
run: BomReferenceSyncRunHandle,
|
||||
rows: readonly NormalizedBomReferenceRow[],
|
||||
): Promise<BomReferencePageCounts>
|
||||
finishRun(run: BomReferenceSyncRunHandle, progress: BomReferenceSyncProgress): Promise<void>
|
||||
checkpointRun(run: BomReferenceSyncRunHandle, progress: BomReferenceSyncProgress): Promise<void>
|
||||
failRun(run: BomReferenceSyncRunHandle, progress: BomReferenceSyncProgress, error: unknown): Promise<void>
|
||||
pruneRowsOutsideActiveSnapshot(run: BomReferenceSyncRunHandle): Promise<number>
|
||||
refreshMaterialOptions(): Promise<BomCostingMaterialRefreshCounts>
|
||||
getLatestRunStatuses(): Promise<BomReferenceLatestRunStatuses>
|
||||
}
|
||||
|
||||
function rowKey(row: Pick<NormalizedBomReferenceRow, 'sourceRowId' | 'sourceSku' | 'bomVersionKey'>) {
|
||||
return `${row.sourceRowId}\u0000${row.sourceSku}\u0000${row.bomVersionKey}`
|
||||
}
|
||||
|
||||
function rowWhere(row: Pick<NormalizedBomReferenceRow, 'sourceRowId' | 'sourceSku' | 'bomVersionKey'>) {
|
||||
return {
|
||||
sourceRowId: row.sourceRowId,
|
||||
sourceSku: row.sourceSku,
|
||||
bomVersionKey: row.bomVersionKey,
|
||||
}
|
||||
}
|
||||
|
||||
function toDbData(row: NormalizedBomReferenceRow, runId: string, syncedAt: Date) {
|
||||
return {
|
||||
sourceRowId: row.sourceRowId,
|
||||
sourceSku: row.sourceSku,
|
||||
bomVersionKey: row.bomVersionKey,
|
||||
category: row.category,
|
||||
categoryCode: row.categoryCode,
|
||||
partNumber: row.partNumber,
|
||||
partName: row.partName,
|
||||
side: row.side,
|
||||
materialName: row.materialName,
|
||||
materialCode: row.materialCode,
|
||||
materialSpec: row.materialSpec,
|
||||
processName: row.processName,
|
||||
unit: row.unit,
|
||||
rawLength: row.rawLength,
|
||||
rawWidth: row.rawWidth,
|
||||
rawLongEdge: row.rawLongEdge,
|
||||
rawShortEdge: row.rawShortEdge,
|
||||
rawQuantity: row.rawQuantity,
|
||||
rawCoefficient: row.rawCoefficient,
|
||||
rawArea: row.rawArea,
|
||||
rawEdgeLength: row.rawEdgeLength,
|
||||
rawUnitPrice: row.rawUnitPrice,
|
||||
rawAmount: row.rawAmount,
|
||||
rawNote: row.rawNote,
|
||||
rawCreatedAt: row.rawCreatedAt,
|
||||
lengthMm: row.lengthMm,
|
||||
widthMm: row.widthMm,
|
||||
longEdgeMm: row.longEdgeMm,
|
||||
shortEdgeMm: row.shortEdgeMm,
|
||||
quantity: row.quantity,
|
||||
coefficient: row.coefficient,
|
||||
areaSqm: row.areaSqm,
|
||||
edgeLengthM: row.edgeLengthM,
|
||||
unitPrice: row.unitPrice,
|
||||
amount: row.amount,
|
||||
sourceCreatedAt: row.sourceCreatedAt,
|
||||
sourceFingerprint: row.sourceFingerprint,
|
||||
isSummaryRow: row.isSummaryRow,
|
||||
syncRunId: runId,
|
||||
syncedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type ExistingRow = {
|
||||
id: string
|
||||
sourceRowId: string
|
||||
sourceSku: string
|
||||
bomVersionKey: string
|
||||
sourceFingerprint: string
|
||||
}
|
||||
|
||||
function classifyRows(
|
||||
rows: readonly NormalizedBomReferenceRow[],
|
||||
existingRows: readonly ExistingRow[],
|
||||
): BomReferencePageCounts {
|
||||
const existing = new Map(existingRows.map((row) => [rowKey(row), row]))
|
||||
let created = 0
|
||||
let updated = 0
|
||||
let unchanged = 0
|
||||
for (const row of rows) {
|
||||
const current = existing.get(rowKey(row))
|
||||
if (!current) created += 1
|
||||
else if (current.sourceFingerprint === row.sourceFingerprint) unchanged += 1
|
||||
else updated += 1
|
||||
}
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
unchanged,
|
||||
rowsWritten: created + updated,
|
||||
}
|
||||
}
|
||||
|
||||
export class PrismaBomReferenceMirrorStore implements BomReferenceMirrorStore {
|
||||
constructor(private readonly db: PrismaClient | Prisma.TransactionClient = defaultPrisma) {}
|
||||
|
||||
private hasTransactionApi(
|
||||
client: PrismaClient | Prisma.TransactionClient,
|
||||
): client is PrismaClient {
|
||||
return '$transaction' in client
|
||||
}
|
||||
|
||||
private async withTransaction<T>(
|
||||
callback: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (this.hasTransactionApi(this.db)) {
|
||||
return this.db.$transaction(callback)
|
||||
}
|
||||
return callback(this.db)
|
||||
}
|
||||
|
||||
private async latestSuccessfulRunId(client: PrismaClient | Prisma.TransactionClient) {
|
||||
const latest = await client.bomCostingReferenceSyncRun.findFirst({
|
||||
where: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
select: { id: true },
|
||||
})
|
||||
return latest?.id ?? null
|
||||
}
|
||||
|
||||
async countRows() {
|
||||
const syncRunId = await this.latestSuccessfulRunId(this.db)
|
||||
return syncRunId
|
||||
? this.db.bomCostingReferenceRow.count({ where: { syncRunId } })
|
||||
: 0
|
||||
}
|
||||
|
||||
async refreshMaterialOptions(): Promise<BomCostingMaterialRefreshCounts> {
|
||||
const systemNamePrefix = 'SUBSTRATE_SYSTEM_NAME:'
|
||||
const categoryPrefix = 'SUBSTRATE_CATEGORY:'
|
||||
const [systemNameSources, categorySources] = await Promise.all([
|
||||
this.db.substrateSystemNameOption.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
sortOrder: true,
|
||||
isActive: true,
|
||||
substrates: {
|
||||
where: { isActive: true },
|
||||
select: { code: true, name: true, metadata: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
this.db.substrateCategory.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
sortOrder: true,
|
||||
substrates: {
|
||||
where: {
|
||||
isActive: true,
|
||||
suggestedPrice: { gt: 0 },
|
||||
thicknessSpec: { isActive: true },
|
||||
},
|
||||
select: { code: true, name: true, metadata: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
type MaterialSource = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
normalizedName: string
|
||||
aliases: Set<string>
|
||||
enabled: boolean
|
||||
sortOrder: number
|
||||
}
|
||||
const sourcesByName = new Map<string, MaterialSource>()
|
||||
const readMetadataSystemName = (metadata: Prisma.JsonValue | null) => {
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return ''
|
||||
const systemName = (metadata as Record<string, unknown>).systemName
|
||||
return typeof systemName === 'string' ? systemName.trim() : ''
|
||||
}
|
||||
const aliasesOf = (
|
||||
name: string,
|
||||
substrates: Array<{ code: string; name: string; metadata: Prisma.JsonValue | null }>,
|
||||
) => new Set([
|
||||
name,
|
||||
...substrates.flatMap((substrate) => [
|
||||
substrate.name,
|
||||
substrate.code,
|
||||
readMetadataSystemName(substrate.metadata),
|
||||
]),
|
||||
].map((value) => value.trim()).filter(Boolean))
|
||||
|
||||
for (const source of systemNameSources) {
|
||||
if (isNonBoardSubstrate(source.name)) continue
|
||||
const normalizedName = normalizeBomMaterialName(source.name)
|
||||
if (!normalizedName) continue
|
||||
sourcesByName.set(normalizedName, {
|
||||
id: `bom-material-${source.id}`,
|
||||
code: `${systemNamePrefix}${source.id}`,
|
||||
name: source.name.trim(),
|
||||
normalizedName,
|
||||
aliases: aliasesOf(source.name, source.substrates),
|
||||
enabled: source.isActive,
|
||||
sortOrder: source.sortOrder,
|
||||
})
|
||||
}
|
||||
|
||||
for (const source of categorySources) {
|
||||
if (source.substrates.length === 0 || isNonBoardSubstrate(source.name)) continue
|
||||
const normalizedName = normalizeBomMaterialName(source.name)
|
||||
if (!normalizedName) continue
|
||||
const aliases = aliasesOf(source.name, source.substrates)
|
||||
const existing = sourcesByName.get(normalizedName)
|
||||
if (existing) {
|
||||
for (const alias of aliases) existing.aliases.add(alias)
|
||||
continue
|
||||
}
|
||||
sourcesByName.set(normalizedName, {
|
||||
id: `bom-material-category-${source.id}`,
|
||||
code: `${categoryPrefix}${source.id}`,
|
||||
name: source.name.trim(),
|
||||
normalizedName,
|
||||
aliases,
|
||||
enabled: true,
|
||||
sortOrder: 1000 + source.sortOrder,
|
||||
})
|
||||
}
|
||||
const sources = Array.from(sourcesByName.values())
|
||||
|
||||
await this.db.bomCostingMaterialOption.updateMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ code: { startsWith: systemNamePrefix } },
|
||||
{ code: { startsWith: categoryPrefix } },
|
||||
],
|
||||
},
|
||||
data: { enabled: false },
|
||||
})
|
||||
for (const source of sources) {
|
||||
await this.db.bomCostingMaterialOption.upsert({
|
||||
where: { id: source.id },
|
||||
create: {
|
||||
id: source.id,
|
||||
code: source.code,
|
||||
name: source.name,
|
||||
normalizedName: source.normalizedName,
|
||||
category: 'BOARD',
|
||||
aliases: Array.from(source.aliases),
|
||||
enabled: source.enabled,
|
||||
sortOrder: source.sortOrder,
|
||||
},
|
||||
update: {
|
||||
name: source.name,
|
||||
normalizedName: source.normalizedName,
|
||||
category: 'BOARD',
|
||||
aliases: Array.from(source.aliases),
|
||||
enabled: source.enabled,
|
||||
sortOrder: source.sortOrder,
|
||||
},
|
||||
})
|
||||
}
|
||||
return {
|
||||
sourceCount: sources.length,
|
||||
enabledCount: sources.filter((source) => source.enabled).length,
|
||||
}
|
||||
}
|
||||
|
||||
async startRun(input: BomReferenceSyncRunInput) {
|
||||
const previousSuccessfulRunId = await this.latestSuccessfulRunId(this.db)
|
||||
const run = await this.db.bomCostingReferenceSyncRun.create({
|
||||
data: {
|
||||
sourceSystem: input.sourceSystem,
|
||||
sourceDatabase: input.sourceDatabase,
|
||||
sourceSchema: input.sourceSchema,
|
||||
sourceObject: input.sourceObject,
|
||||
status: BomCostingReferenceSyncStatus.RUNNING,
|
||||
batchKey: input.batchKey,
|
||||
configFingerprint: input.configFingerprint,
|
||||
cursorStart: input.cursorStart,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
return { ...run, previousSuccessfulRunId, cursorEnd: input.cursorStart }
|
||||
}
|
||||
|
||||
async resumeRun(runId: string, input: BomReferenceSyncRunInput) {
|
||||
const existing = await this.db.bomCostingReferenceSyncRun.findFirst({
|
||||
where: {
|
||||
id: runId,
|
||||
status: BomCostingReferenceSyncStatus.FAILED,
|
||||
sourceSystem: input.sourceSystem,
|
||||
sourceDatabase: input.sourceDatabase,
|
||||
sourceSchema: input.sourceSchema,
|
||||
sourceObject: input.sourceObject,
|
||||
configFingerprint: input.configFingerprint,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startedAt: true,
|
||||
cursorEnd: true,
|
||||
rowsRead: true,
|
||||
rowsWritten: true,
|
||||
rowsCreated: true,
|
||||
rowsUpdated: true,
|
||||
rowsUnchanged: true,
|
||||
rowsSkipped: true,
|
||||
errorCount: true,
|
||||
sourceAsOf: true,
|
||||
},
|
||||
})
|
||||
if (!existing) return null
|
||||
const [previousSuccessfulRunId, baselineSuccessfulRun, snapshotRowCount] = await Promise.all([
|
||||
this.latestSuccessfulRunId(this.db),
|
||||
this.db.bomCostingReferenceSyncRun.findFirst({
|
||||
where: {
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
completedAt: { lte: existing.startedAt },
|
||||
},
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
select: { id: true },
|
||||
}),
|
||||
this.db.bomCostingReferenceRow.count({ where: { syncRunId: existing.id } }),
|
||||
])
|
||||
const expectedSnapshotRows = Math.max(existing.rowsRead - existing.rowsSkipped, 0)
|
||||
if (snapshotRowCount < expectedSnapshotRows) {
|
||||
throw new Error(
|
||||
'Interrupted BOM reference batch is missing persisted rows; start a new full sync',
|
||||
)
|
||||
}
|
||||
if (existing.rowsRead > 0 &&
|
||||
(baselineSuccessfulRun?.id ?? null) !== previousSuccessfulRunId) {
|
||||
throw new Error(
|
||||
'Interrupted BOM reference batch has a different successful baseline; start a new full sync',
|
||||
)
|
||||
}
|
||||
await this.db.bomCostingReferenceSyncRun.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: BomCostingReferenceSyncStatus.RUNNING,
|
||||
completedAt: null,
|
||||
errorMessage: '',
|
||||
},
|
||||
})
|
||||
return { ...existing, previousSuccessfulRunId }
|
||||
}
|
||||
|
||||
private async findExisting(
|
||||
client: PrismaClient | Prisma.TransactionClient,
|
||||
rows: readonly NormalizedBomReferenceRow[],
|
||||
syncRunId: string | null,
|
||||
): Promise<ExistingRow[]> {
|
||||
if (rows.length === 0 || !syncRunId) return []
|
||||
const unique = new Map(rows.map((row) => [rowKey(row), row]))
|
||||
return client.bomCostingReferenceRow.findMany({
|
||||
where: {
|
||||
syncRunId,
|
||||
OR: Array.from(unique.values()).map(rowWhere),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
sourceRowId: true,
|
||||
sourceSku: true,
|
||||
bomVersionKey: true,
|
||||
sourceFingerprint: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async inspectPage(rows: readonly NormalizedBomReferenceRow[]) {
|
||||
const latestSuccessfulRunId = await this.latestSuccessfulRunId(this.db)
|
||||
const existing = await this.findExisting(this.db, rows, latestSuccessfulRunId)
|
||||
return classifyRows(rows, existing)
|
||||
}
|
||||
|
||||
async writePage(run: BomReferenceSyncRunHandle, rows: readonly NormalizedBomReferenceRow[]) {
|
||||
if (rows.length === 0) {
|
||||
return { created: 0, updated: 0, unchanged: 0, rowsWritten: 0 }
|
||||
}
|
||||
const duplicateKeys = new Set<string>()
|
||||
for (const row of rows) {
|
||||
const key = rowKey(row)
|
||||
if (duplicateKeys.has(key)) throw new Error(`Duplicate upstream BOM row key in page: ${key}`)
|
||||
duplicateKeys.add(key)
|
||||
}
|
||||
|
||||
return this.withTransaction(async (tx) => {
|
||||
const [previousRows, currentRunRows] = await Promise.all([
|
||||
this.findExisting(tx, rows, run.previousSuccessfulRunId ?? null),
|
||||
this.findExisting(tx, rows, run.id),
|
||||
])
|
||||
const currentRunKeys = new Set(currentRunRows.map(rowKey))
|
||||
const now = new Date()
|
||||
const newRows = rows.filter((row) => !currentRunKeys.has(rowKey(row)))
|
||||
const creates = newRows.map((row) => toDbData(row, run.id, now))
|
||||
if (creates.length > 0) {
|
||||
await tx.bomCostingReferenceRow.createMany({ data: creates })
|
||||
}
|
||||
|
||||
return {
|
||||
// Classify the full upstream page against the last visible snapshot.
|
||||
// When a checkpoint was lost after the page insert, `newRows` is empty
|
||||
// but the page still needs to contribute its stable counters on resume.
|
||||
...classifyRows(rows, previousRows),
|
||||
rowsWritten: rows.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async checkpointRun(run: BomReferenceSyncRunHandle, progress: BomReferenceSyncProgress) {
|
||||
await this.db.bomCostingReferenceSyncRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
cursorEnd: progress.cursorEnd,
|
||||
rowsRead: progress.rowsRead,
|
||||
rowsWritten: progress.rowsWritten,
|
||||
rowsCreated: progress.rowsCreated,
|
||||
rowsUpdated: progress.rowsUpdated,
|
||||
rowsUnchanged: progress.rowsUnchanged,
|
||||
rowsSkipped: progress.rowsSkipped,
|
||||
errorCount: progress.errorCount,
|
||||
durationMs: progress.durationMs,
|
||||
sourceAsOf: progress.sourceAsOf,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async finishRun(run: BomReferenceSyncRunHandle, progress: BomReferenceSyncProgress) {
|
||||
await this.db.bomCostingReferenceSyncRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: BomCostingReferenceSyncStatus.SUCCEEDED,
|
||||
cursorEnd: progress.cursorEnd,
|
||||
rowsRead: progress.rowsRead,
|
||||
rowsWritten: progress.rowsWritten,
|
||||
rowsCreated: progress.rowsCreated,
|
||||
rowsUpdated: progress.rowsUpdated,
|
||||
rowsUnchanged: progress.rowsUnchanged,
|
||||
rowsSkipped: progress.rowsSkipped,
|
||||
errorCount: progress.errorCount,
|
||||
durationMs: progress.durationMs,
|
||||
sourceAsOf: progress.sourceAsOf,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async failRun(run: BomReferenceSyncRunHandle, progress: BomReferenceSyncProgress, error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await this.db.bomCostingReferenceSyncRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: BomCostingReferenceSyncStatus.FAILED,
|
||||
cursorEnd: progress.cursorEnd,
|
||||
rowsRead: progress.rowsRead,
|
||||
rowsWritten: progress.rowsWritten,
|
||||
rowsCreated: progress.rowsCreated,
|
||||
rowsUpdated: progress.rowsUpdated,
|
||||
rowsUnchanged: progress.rowsUnchanged,
|
||||
rowsSkipped: progress.rowsSkipped,
|
||||
errorCount: progress.errorCount + 1,
|
||||
errorMessage: message.slice(0, 4000),
|
||||
durationMs: progress.durationMs,
|
||||
sourceAsOf: progress.sourceAsOf,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep one operational mirror snapshot to cap local storage. Historical
|
||||
* scenario lines persist their calculation inputs and provenance separately.
|
||||
*/
|
||||
async pruneRowsOutsideActiveSnapshot(run: BomReferenceSyncRunHandle) {
|
||||
const removed = await this.db.bomCostingReferenceRow.deleteMany({
|
||||
where: { syncRunId: { not: run.id } },
|
||||
})
|
||||
return removed.count
|
||||
}
|
||||
|
||||
async getLatestRunStatuses(): Promise<BomReferenceLatestRunStatuses> {
|
||||
const select = {
|
||||
id: true,
|
||||
status: true,
|
||||
batchKey: true,
|
||||
startedAt: true,
|
||||
completedAt: true,
|
||||
sourceAsOf: true,
|
||||
errorMessage: true,
|
||||
} as const
|
||||
const [latestSuccessful, latestFailed] = await Promise.all([
|
||||
this.db.bomCostingReferenceSyncRun.findFirst({
|
||||
where: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
select,
|
||||
}),
|
||||
this.db.bomCostingReferenceSyncRun.findFirst({
|
||||
where: { status: BomCostingReferenceSyncStatus.FAILED },
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
select,
|
||||
}),
|
||||
])
|
||||
const [visibleRow, foreignRow] = latestSuccessful
|
||||
? await Promise.all([
|
||||
this.db.bomCostingReferenceRow.findFirst({
|
||||
where: { syncRunId: latestSuccessful.id },
|
||||
select: { syncRunId: true },
|
||||
}),
|
||||
this.db.bomCostingReferenceRow.findFirst({
|
||||
where: { syncRunId: { not: latestSuccessful.id } },
|
||||
select: { syncRunId: true },
|
||||
}),
|
||||
])
|
||||
: [
|
||||
null,
|
||||
await this.db.bomCostingReferenceRow.findFirst({
|
||||
select: { syncRunId: true },
|
||||
}),
|
||||
]
|
||||
const maxAgeDays = Number(process.env.BOM_COSTING_REFERENCE_MAX_AGE_DAYS ?? '2')
|
||||
// The upstream view currently exposes a historical creation timestamp,
|
||||
// not a reliable update marker. Freshness therefore means "when did we
|
||||
// successfully scan the view", while sourceAsOf remains audit metadata.
|
||||
const mirroredAt = latestSuccessful?.completedAt ?? null
|
||||
const isFresh = mirroredAt !== null && Number.isFinite(maxAgeDays) &&
|
||||
Date.now() - mirroredAt.getTime() <= Math.max(0, maxAgeDays) * 86_400_000
|
||||
const mirrorRunIds = Array.from(new Set(
|
||||
[visibleRow?.syncRunId, foreignRow?.syncRunId].filter((value): value is string => Boolean(value)),
|
||||
))
|
||||
const mirrorState: BomReferenceLatestRunStatuses['mirrorState'] = mirrorRunIds.length === 0
|
||||
? 'EMPTY'
|
||||
: !latestSuccessful || !visibleRow || foreignRow
|
||||
? 'MIXED'
|
||||
: isFresh ? 'READY' : 'STALE'
|
||||
return { latestSuccessful, latestFailed, mirrorState, mirrorRunIds }
|
||||
}
|
||||
}
|
||||
|
||||
export type BomReferenceSyncLease = { release(): Promise<void> }
|
||||
|
||||
export interface BomReferenceSyncLock {
|
||||
acquire(sourceKey: string): Promise<BomReferenceSyncLease | null>
|
||||
}
|
||||
|
||||
/** Session-scoped PostgreSQL advisory lock; no lock row is written in dry-run. */
|
||||
export class PostgresBomReferenceSyncLock implements BomReferenceSyncLock {
|
||||
constructor(private readonly connectionString = process.env.DATABASE_URL?.trim() ?? '') {}
|
||||
|
||||
async acquire(sourceKey: string) {
|
||||
if (!this.connectionString) throw new Error('DATABASE_URL is required for the BOM sync lock')
|
||||
const client = new Client({ connectionString: this.connectionString, connectionTimeoutMillis: 10_000 })
|
||||
await client.connect()
|
||||
try {
|
||||
const result = await client.query<{ acquired: boolean }>(
|
||||
'SELECT pg_try_advisory_lock($1::integer, hashtext($2)) AS acquired',
|
||||
[4_805_321, sourceKey],
|
||||
)
|
||||
if (!result.rows[0]?.acquired) {
|
||||
await client.end()
|
||||
return null
|
||||
}
|
||||
let released = false
|
||||
return {
|
||||
release: async () => {
|
||||
if (released) return
|
||||
released = true
|
||||
try {
|
||||
await client.query(
|
||||
'SELECT pg_advisory_unlock($1::integer, hashtext($2))',
|
||||
[4_805_321, sourceKey],
|
||||
)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
await client.end().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,352 @@
|
||||
import type {
|
||||
UpstreamBomReferenceAccess,
|
||||
UpstreamBomReferenceRawRow,
|
||||
UpstreamBomReferenceSource,
|
||||
} from './upstream-bom-reference-source'
|
||||
import type {
|
||||
BomReferenceMirrorStore,
|
||||
BomReferenceSyncLease,
|
||||
BomReferenceSyncLock,
|
||||
} from './upstream-bom-reference-store'
|
||||
import { UpstreamBomReferenceSyncService } from './upstream-bom-reference-sync.service'
|
||||
|
||||
const access: UpstreamBomReferenceAccess = {
|
||||
database: 'reporting',
|
||||
login: 'bom_reader',
|
||||
databaseUser: 'bom_reader',
|
||||
sourceType: 'VIEW',
|
||||
resolvedColumns: {} as UpstreamBomReferenceAccess['resolvedColumns'],
|
||||
}
|
||||
|
||||
function rawRow(id: number): UpstreamBomReferenceRawRow {
|
||||
return {
|
||||
sourceRowId: String(id),
|
||||
sourceSku: `sku-${id}`,
|
||||
bomVersionKey: 'v1',
|
||||
category: '板材',
|
||||
categoryCode: 'board',
|
||||
partNumber: `part-number-${id}`,
|
||||
partName: `part-${id}`,
|
||||
side: '',
|
||||
materialName: '橡胶木',
|
||||
materialCode: 'wood-01',
|
||||
materialSpec: '20 mm',
|
||||
processName: '开料',
|
||||
unit: '件',
|
||||
rawLength: '1200',
|
||||
rawWidth: '600',
|
||||
rawLongEdge: '',
|
||||
rawShortEdge: '',
|
||||
rawQuantity: '1',
|
||||
rawCoefficient: '1',
|
||||
rawArea: '',
|
||||
rawEdgeLength: '',
|
||||
rawUnitPrice: '12.5',
|
||||
rawAmount: '12.5',
|
||||
rawNote: '',
|
||||
rawCreatedAt: '2026-08-07T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function makeHarness(pages: UpstreamBomReferenceRawRow[][]) {
|
||||
let pageIndex = 0
|
||||
const source: jest.Mocked<UpstreamBomReferenceSource> = {
|
||||
sourceKey: 'sqlserver:reporting:rpt:pm_bombaseinfo',
|
||||
sourceIdentity: 'sqlserver|bom_reader|sql.example.test|1433|reporting|rpt|pm_bomBaseInfo',
|
||||
configFingerprint: 'a'.repeat(64),
|
||||
database: 'reporting',
|
||||
schema: 'rpt',
|
||||
object: 'pm_bomBaseInfo',
|
||||
validateReadOnly: jest.fn().mockResolvedValue(access),
|
||||
listPage: jest.fn(async () => pages[pageIndex++] ?? []),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
const mirror: jest.Mocked<BomReferenceMirrorStore> = {
|
||||
countRows: jest.fn().mockResolvedValue(0),
|
||||
startRun: jest.fn().mockResolvedValue({ id: 'run-1' }),
|
||||
resumeRun: jest.fn(),
|
||||
inspectPage: jest.fn().mockResolvedValue({
|
||||
created: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
rowsWritten: 0,
|
||||
}),
|
||||
writePage: jest.fn().mockResolvedValue({
|
||||
created: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
rowsWritten: 0,
|
||||
}),
|
||||
finishRun: jest.fn().mockResolvedValue(undefined),
|
||||
checkpointRun: jest.fn().mockResolvedValue(undefined),
|
||||
failRun: jest.fn().mockResolvedValue(undefined),
|
||||
pruneRowsOutsideActiveSnapshot: jest.fn().mockResolvedValue(0),
|
||||
refreshMaterialOptions: jest.fn().mockResolvedValue({ sourceCount: 0, enabledCount: 0 }),
|
||||
getLatestRunStatuses: jest.fn().mockResolvedValue({
|
||||
latestSuccessful: null,
|
||||
latestFailed: null,
|
||||
mirrorState: 'EMPTY',
|
||||
mirrorRunIds: [],
|
||||
}),
|
||||
}
|
||||
const lease: jest.Mocked<BomReferenceSyncLease> = {
|
||||
release: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
const lock: jest.Mocked<BomReferenceSyncLock> = {
|
||||
acquire: jest.fn().mockResolvedValue(lease),
|
||||
}
|
||||
return {
|
||||
source,
|
||||
mirror,
|
||||
lease,
|
||||
lock,
|
||||
service: new UpstreamBomReferenceSyncService(source, mirror, lock),
|
||||
}
|
||||
}
|
||||
|
||||
describe('UpstreamBomReferenceSyncService', () => {
|
||||
it('keeps dry-run read-only while still comparing local mirror rows', async () => {
|
||||
const harness = makeHarness([[rawRow(1)]])
|
||||
harness.mirror.countRows.mockResolvedValue(1)
|
||||
harness.mirror.inspectPage.mockResolvedValue({
|
||||
created: 0,
|
||||
updated: 0,
|
||||
unchanged: 1,
|
||||
rowsWritten: 0,
|
||||
})
|
||||
|
||||
const result = await harness.service.sync({ apply: false })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
apply: false,
|
||||
batchId: null,
|
||||
complete: true,
|
||||
consistency: 'PREVIEW',
|
||||
pageCount: 1,
|
||||
cursor: '1',
|
||||
rowsRead: 1,
|
||||
rowsAccepted: 1,
|
||||
rowsWritten: 0,
|
||||
unchanged: 1,
|
||||
wouldRemove: 0,
|
||||
removed: 0,
|
||||
retryCount: 0,
|
||||
})
|
||||
expect(harness.mirror.inspectPage).toHaveBeenCalledTimes(1)
|
||||
expect(harness.mirror.startRun).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.writePage).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.pruneRowsOutsideActiveSnapshot).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.finishRun).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.failRun).not.toHaveBeenCalled()
|
||||
expect(harness.source.close).toHaveBeenCalledTimes(1)
|
||||
expect(harness.lease.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies fixed-size keyset pages, throttles full pages and finalizes the run', async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => rawRow(index + 1))
|
||||
const harness = makeHarness([firstPage, [rawRow(101)]])
|
||||
const sleep = jest.fn().mockResolvedValue(undefined)
|
||||
harness.mirror.countRows.mockResolvedValue(1)
|
||||
harness.mirror.writePage
|
||||
.mockResolvedValueOnce({ created: 99, updated: 1, unchanged: 0, rowsWritten: 100 })
|
||||
.mockResolvedValueOnce({ created: 1, updated: 0, unchanged: 0, rowsWritten: 1 })
|
||||
|
||||
const result = await harness.service.sync({ apply: true, sleep })
|
||||
|
||||
expect(harness.source.listPage).toHaveBeenNthCalledWith(1, { afterId: '0', limit: 100 })
|
||||
expect(harness.source.listPage).toHaveBeenNthCalledWith(2, { afterId: '100', limit: 100 })
|
||||
expect(sleep).toHaveBeenCalledWith(250)
|
||||
expect(harness.mirror.startRun).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sourceDatabase: 'reporting',
|
||||
sourceSchema: 'rpt',
|
||||
sourceObject: 'pm_bomBaseInfo',
|
||||
configFingerprint: 'a'.repeat(64),
|
||||
cursorStart: '0',
|
||||
}))
|
||||
expect(harness.mirror.writePage).toHaveBeenCalledTimes(2)
|
||||
expect(harness.mirror.checkpointRun).toHaveBeenCalledTimes(2)
|
||||
expect(harness.mirror.pruneRowsOutsideActiveSnapshot).toHaveBeenCalledWith({ id: 'run-1' })
|
||||
expect(harness.mirror.finishRun).toHaveBeenCalledWith(
|
||||
{ id: 'run-1' },
|
||||
expect.objectContaining({ cursorEnd: '101', rowsRead: 101, rowsWritten: 101 }),
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
apply: true,
|
||||
batchId: 'run-1',
|
||||
complete: true,
|
||||
consistency: 'SNAPSHOT',
|
||||
pageCount: 2,
|
||||
cursor: '101',
|
||||
rowsRead: 101,
|
||||
created: 100,
|
||||
updated: 1,
|
||||
rowsWritten: 101,
|
||||
wouldRemove: 0,
|
||||
removed: 0,
|
||||
retryCount: 0,
|
||||
})
|
||||
expect(harness.source.close).toHaveBeenCalledTimes(1)
|
||||
expect(harness.lease.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records an apply failure and releases resources when the cursor does not advance', async () => {
|
||||
const harness = makeHarness([[rawRow(1), rawRow(1)]])
|
||||
|
||||
await expect(harness.service.sync({ apply: true })).rejects.toThrow(
|
||||
'keyset cursor did not advance',
|
||||
)
|
||||
|
||||
expect(harness.mirror.writePage).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.failRun).toHaveBeenCalledWith(
|
||||
{ id: 'run-1' },
|
||||
expect.objectContaining({ cursorEnd: '1', rowsRead: 0, rowsWritten: 0 }),
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(harness.source.close).toHaveBeenCalledTimes(1)
|
||||
expect(harness.lease.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('blocks an empty upstream source even when the local mirror is also empty', async () => {
|
||||
const harness = makeHarness([])
|
||||
|
||||
await expect(harness.service.sync()).rejects.toThrow('empty upstream source')
|
||||
|
||||
expect(harness.mirror.startRun).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.pruneRowsOutsideActiveSnapshot).not.toHaveBeenCalled()
|
||||
expect(harness.source.close).toHaveBeenCalledTimes(1)
|
||||
expect(harness.lease.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('limits a dry-run without calculating a misleading removal count', async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => rawRow(index + 1))
|
||||
const harness = makeHarness([firstPage, [rawRow(101)]])
|
||||
const sleep = jest.fn().mockResolvedValue(undefined)
|
||||
harness.mirror.inspectPage.mockResolvedValue({
|
||||
created: 100,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
rowsWritten: 100,
|
||||
})
|
||||
|
||||
const result = await harness.service.sync({ maxPages: 1, sleep })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
apply: false,
|
||||
complete: false,
|
||||
cursor: '100',
|
||||
pageCount: 1,
|
||||
wouldRemove: null,
|
||||
})
|
||||
expect(harness.source.listPage).toHaveBeenCalledTimes(1)
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries the same keyset page after a transient source failure', async () => {
|
||||
const harness = makeHarness([])
|
||||
const sleep = jest.fn().mockResolvedValue(undefined)
|
||||
harness.source.listPage
|
||||
.mockRejectedValueOnce(new Error('temporary SQL timeout'))
|
||||
.mockResolvedValueOnce([rawRow(1)])
|
||||
harness.mirror.inspectPage.mockResolvedValue({
|
||||
created: 1,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
rowsWritten: 1,
|
||||
})
|
||||
|
||||
const result = await harness.service.sync({ sleep })
|
||||
|
||||
expect(harness.source.listPage).toHaveBeenNthCalledWith(1, { afterId: '0', limit: 100 })
|
||||
expect(harness.source.listPage).toHaveBeenNthCalledWith(2, { afterId: '0', limit: 100 })
|
||||
expect(sleep).toHaveBeenCalledWith(500)
|
||||
expect(result.retryCount).toBe(1)
|
||||
})
|
||||
|
||||
it('resumes a failed snapshot from its persisted cursor without replacing the visible batch early', async () => {
|
||||
const harness = makeHarness([[rawRow(101)]])
|
||||
const sourceAsOf = new Date('2026-08-07T00:00:00.000Z')
|
||||
harness.mirror.countRows.mockResolvedValue(10)
|
||||
harness.mirror.resumeRun.mockResolvedValue({
|
||||
id: 'run-failed',
|
||||
previousSuccessfulRunId: 'run-success',
|
||||
cursorEnd: '100',
|
||||
rowsRead: 100,
|
||||
rowsWritten: 100,
|
||||
rowsCreated: 90,
|
||||
rowsUpdated: 5,
|
||||
rowsUnchanged: 5,
|
||||
rowsSkipped: 0,
|
||||
errorCount: 1,
|
||||
sourceAsOf,
|
||||
})
|
||||
harness.mirror.writePage.mockResolvedValue({
|
||||
created: 1,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
rowsWritten: 1,
|
||||
})
|
||||
|
||||
const result = await harness.service.sync({
|
||||
apply: true,
|
||||
resumeBatchId: 'run-failed',
|
||||
})
|
||||
|
||||
expect(harness.mirror.startRun).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.resumeRun).toHaveBeenCalledWith(
|
||||
'run-failed',
|
||||
expect.objectContaining({ cursorStart: '0' }),
|
||||
)
|
||||
expect(harness.source.listPage).toHaveBeenCalledWith({ afterId: '100', limit: 100 })
|
||||
expect(harness.mirror.finishRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'run-failed' }),
|
||||
expect.objectContaining({
|
||||
cursorEnd: '101',
|
||||
rowsRead: 101,
|
||||
rowsWritten: 101,
|
||||
rowsCreated: 91,
|
||||
errorCount: 1,
|
||||
}),
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
batchId: 'run-failed',
|
||||
cursor: '101',
|
||||
rowsRead: 101,
|
||||
rowsWritten: 101,
|
||||
consistency: 'SNAPSHOT',
|
||||
})
|
||||
})
|
||||
|
||||
it('requires a complete source scan for a new apply snapshot', async () => {
|
||||
const harness = makeHarness([[rawRow(1)]])
|
||||
|
||||
await expect(harness.service.sync({ apply: true, maxPages: 1 })).rejects.toThrow(
|
||||
'maxPages is dry-run only',
|
||||
)
|
||||
await expect(harness.service.sync({ apply: true, startAfterId: '10' })).rejects.toThrow(
|
||||
'startAfterId is dry-run only',
|
||||
)
|
||||
|
||||
expect(harness.lock.acquire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks a large removal and marks an apply run failed before deleting rows', async () => {
|
||||
const harness = makeHarness([[rawRow(1)]])
|
||||
harness.mirror.countRows.mockResolvedValue(10)
|
||||
harness.mirror.writePage.mockResolvedValue({
|
||||
created: 1,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
rowsWritten: 1,
|
||||
})
|
||||
|
||||
await expect(harness.service.sync({ apply: true })).rejects.toThrow(
|
||||
'would remove 10/10 rows',
|
||||
)
|
||||
|
||||
expect(harness.mirror.pruneRowsOutsideActiveSnapshot).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.finishRun).not.toHaveBeenCalled()
|
||||
expect(harness.mirror.failRun).toHaveBeenCalledTimes(1)
|
||||
expect(harness.source.close).toHaveBeenCalledTimes(1)
|
||||
expect(harness.lease.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,438 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
BOM_REFERENCE_PAGE_DELAY_MS,
|
||||
BOM_REFERENCE_PAGE_SIZE,
|
||||
type UpstreamBomReferenceAccess,
|
||||
type UpstreamBomReferenceRawRow,
|
||||
type UpstreamBomReferenceSource,
|
||||
} from './upstream-bom-reference-source'
|
||||
import { normalizeBomReferenceRow, type NormalizedBomReferenceRow } from './upstream-bom-reference-normalizer'
|
||||
import {
|
||||
type BomReferenceLatestRunStatuses,
|
||||
type BomReferenceMirrorStore,
|
||||
type BomReferencePageCounts,
|
||||
type BomReferenceSyncRunHandle,
|
||||
type BomReferenceSyncLock,
|
||||
} from './upstream-bom-reference-store'
|
||||
|
||||
const DEFAULT_SOURCE_SYSTEM = 'JSReportReplica'
|
||||
const MAX_SAFE_REMOVAL_RATIO = 0.5
|
||||
const DEFAULT_MAX_RETRIES = 2
|
||||
const DEFAULT_RETRY_DELAY_MS = 500
|
||||
|
||||
export type UpstreamBomReferenceSyncOptions = {
|
||||
apply?: boolean
|
||||
allowLargeRemoval?: boolean
|
||||
sourceSystem?: string
|
||||
/** Dry-run only. A full page at this limit is reported as incomplete. */
|
||||
maxPages?: number
|
||||
/** Dry-run only. Apply always starts at zero so deletion accounting stays complete. */
|
||||
startAfterId?: string
|
||||
/** Apply only. Continue an interrupted FAILED snapshot from its saved cursor. */
|
||||
resumeBatchId?: string
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
sleep?: (milliseconds: number) => Promise<void>
|
||||
onPage?: (progress: UpstreamBomReferenceSyncPageProgress) => void | Promise<void>
|
||||
}
|
||||
|
||||
export type UpstreamBomReferenceSyncPageProgress = {
|
||||
page: number
|
||||
cursor: string
|
||||
rowsRead: number
|
||||
rowsAccepted: number
|
||||
rowsWritten: number
|
||||
rowsSkipped: number
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
warningCount: number
|
||||
}
|
||||
|
||||
export type UpstreamBomReferenceSyncResult = {
|
||||
apply: boolean
|
||||
sourceKey: string
|
||||
sourceIdentity: string
|
||||
sourceDatabase: string
|
||||
sourceSchema: string
|
||||
sourceObject: string
|
||||
access: UpstreamBomReferenceAccess
|
||||
batchId: string | null
|
||||
complete: boolean
|
||||
consistency: 'PREVIEW' | 'SNAPSHOT'
|
||||
pageCount: number
|
||||
cursor: string
|
||||
rowsRead: number
|
||||
rowsAccepted: number
|
||||
rowsWritten: number
|
||||
rowsSkipped: number
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
warningCount: number
|
||||
retryCount: number
|
||||
wouldRemove: number | null
|
||||
removed: number
|
||||
cleanupWarning: string
|
||||
syncState: BomReferenceLatestRunStatuses
|
||||
preview: Array<{
|
||||
sourceRowId: string
|
||||
sourceSku: string
|
||||
materialCode: string
|
||||
materialName: string
|
||||
unitPrice: string | null
|
||||
sourceFingerprint: string
|
||||
warnings: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
function defaultSleep(milliseconds: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: number | undefined,
|
||||
fallback: number | null,
|
||||
label: string,
|
||||
bounds: { min: number; max: number },
|
||||
) {
|
||||
if (value === undefined) return fallback
|
||||
if (!Number.isSafeInteger(value) || value < bounds.min || value > bounds.max) {
|
||||
throw new Error(`${label} must be an integer between ${bounds.min} and ${bounds.max}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeStartCursor(value: string | undefined) {
|
||||
const cursor = value?.trim() || '0'
|
||||
if (!/^\d+$/.test(cursor)) {
|
||||
throw new Error('startAfterId must be a non-negative integer')
|
||||
}
|
||||
return BigInt(cursor).toString()
|
||||
}
|
||||
|
||||
function normalizeResumeBatchId(value: string | undefined) {
|
||||
const batchId = value?.trim() ?? ''
|
||||
if (!batchId) return ''
|
||||
if (batchId.length > 200 || !/^[A-Za-z0-9_-]+$/.test(batchId)) {
|
||||
throw new Error('resumeBatchId is invalid')
|
||||
}
|
||||
return batchId
|
||||
}
|
||||
|
||||
function ensureCursorAdvances(previous: string, next: string) {
|
||||
try {
|
||||
if (BigInt(next) <= BigInt(previous)) {
|
||||
throw new Error(`Upstream BOM keyset cursor did not advance: ${previous} -> ${next}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('did not advance')) throw error
|
||||
throw new Error(`Upstream BOM keyset cursor is invalid: ${next}`)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeCounts(target: BomReferencePageCounts, source: BomReferencePageCounts) {
|
||||
target.created += source.created
|
||||
target.updated += source.updated
|
||||
target.unchanged += source.unchanged
|
||||
target.rowsWritten += source.rowsWritten
|
||||
}
|
||||
|
||||
export class UpstreamBomReferenceSyncService {
|
||||
constructor(
|
||||
private readonly source: UpstreamBomReferenceSource,
|
||||
private readonly mirror: BomReferenceMirrorStore,
|
||||
private readonly lock: BomReferenceSyncLock,
|
||||
) {}
|
||||
|
||||
async sync(options: UpstreamBomReferenceSyncOptions = {}): Promise<UpstreamBomReferenceSyncResult> {
|
||||
const apply = options.apply === true
|
||||
const sourceSystem = options.sourceSystem?.trim() || DEFAULT_SOURCE_SYSTEM
|
||||
const sleep = options.sleep ?? defaultSleep
|
||||
const maxPages = boundedInteger(options.maxPages, null, 'maxPages', { min: 1, max: 10_000 })
|
||||
const maxRetries = boundedInteger(
|
||||
options.maxRetries,
|
||||
DEFAULT_MAX_RETRIES,
|
||||
'maxRetries',
|
||||
{ min: 0, max: 10 },
|
||||
) as number
|
||||
const retryDelayMs = boundedInteger(
|
||||
options.retryDelayMs,
|
||||
DEFAULT_RETRY_DELAY_MS,
|
||||
'retryDelayMs',
|
||||
{ min: 0, max: 60_000 },
|
||||
) as number
|
||||
const startCursor = normalizeStartCursor(options.startAfterId)
|
||||
const resumeBatchId = normalizeResumeBatchId(options.resumeBatchId)
|
||||
if (apply && maxPages !== null) {
|
||||
throw new Error('maxPages is dry-run only; apply must scan the complete upstream source')
|
||||
}
|
||||
if (apply && startCursor !== '0') {
|
||||
throw new Error('startAfterId is dry-run only; apply must start at zero')
|
||||
}
|
||||
if (!apply && resumeBatchId) {
|
||||
throw new Error('resumeBatchId requires apply mode')
|
||||
}
|
||||
const lease = await this.lock.acquire(this.source.sourceKey)
|
||||
if (!lease) {
|
||||
throw new Error(`BOM reference sync is already running for ${this.source.sourceKey}`)
|
||||
}
|
||||
|
||||
let run: BomReferenceSyncRunHandle | null = null
|
||||
let access: UpstreamBomReferenceAccess | null = null
|
||||
let activated = false
|
||||
const startedAt = Date.now()
|
||||
let cursor = startCursor
|
||||
let pageCount = 0
|
||||
let rowsRead = 0
|
||||
let rowsAccepted = 0
|
||||
let rowsSkipped = 0
|
||||
let warningCount = 0
|
||||
let retryCount = 0
|
||||
let errorCount = 0
|
||||
let complete = apply || startCursor === '0'
|
||||
const pageCounts: BomReferencePageCounts = {
|
||||
created: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
rowsWritten: 0,
|
||||
}
|
||||
const preview: UpstreamBomReferenceSyncResult['preview'] = []
|
||||
let currentCount = 0
|
||||
let sourceAsOf: Date | null = null
|
||||
|
||||
try {
|
||||
access = await this.source.validateReadOnly()
|
||||
currentCount = await this.mirror.countRows()
|
||||
|
||||
if (apply) {
|
||||
const runInput = {
|
||||
sourceSystem,
|
||||
sourceDatabase: this.source.database,
|
||||
sourceSchema: this.source.schema,
|
||||
sourceObject: this.source.object,
|
||||
configFingerprint: this.source.configFingerprint,
|
||||
batchKey: randomUUID(),
|
||||
cursorStart: cursor,
|
||||
}
|
||||
if (resumeBatchId) {
|
||||
run = await this.mirror.resumeRun(resumeBatchId, runInput)
|
||||
if (!run) {
|
||||
throw new Error('Interrupted BOM reference batch was not found or no longer matches the configured source')
|
||||
}
|
||||
cursor = normalizeStartCursor(run.cursorEnd)
|
||||
rowsRead = run.rowsRead ?? 0
|
||||
rowsSkipped = run.rowsSkipped ?? 0
|
||||
rowsAccepted = Math.max(rowsRead - rowsSkipped, 0)
|
||||
pageCount = rowsRead > 0 ? Math.ceil(rowsRead / BOM_REFERENCE_PAGE_SIZE) : 0
|
||||
pageCounts.rowsWritten = run.rowsWritten ?? 0
|
||||
pageCounts.created = run.rowsCreated ?? 0
|
||||
pageCounts.updated = run.rowsUpdated ?? 0
|
||||
pageCounts.unchanged = run.rowsUnchanged ?? 0
|
||||
errorCount = run.errorCount ?? 0
|
||||
sourceAsOf = run.sourceAsOf ?? null
|
||||
} else {
|
||||
run = await this.mirror.startRun(runInput)
|
||||
}
|
||||
}
|
||||
|
||||
const execute = async (activeMirror: BomReferenceMirrorStore) => {
|
||||
while (true) {
|
||||
let rawRows: UpstreamBomReferenceRawRow[]
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
rawRows = await this.source.listPage({
|
||||
afterId: cursor,
|
||||
limit: BOM_REFERENCE_PAGE_SIZE,
|
||||
})
|
||||
break
|
||||
} catch (error) {
|
||||
if (attempt >= maxRetries) throw error
|
||||
const delay = Math.min(retryDelayMs * (2 ** attempt), 60_000)
|
||||
attempt += 1
|
||||
retryCount += 1
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
if (rawRows.length === 0) break
|
||||
if (rawRows.length > BOM_REFERENCE_PAGE_SIZE) {
|
||||
throw new Error('Upstream BOM source returned more rows than the fixed page size')
|
||||
}
|
||||
|
||||
const normalizedRows: NormalizedBomReferenceRow[] = []
|
||||
let pageWarnings = 0
|
||||
for (const rawRow of rawRows) {
|
||||
const sourceRowId = String(rawRow.sourceRowId ?? '').trim()
|
||||
ensureCursorAdvances(cursor, sourceRowId)
|
||||
const normalized = normalizeBomReferenceRow(rawRow)
|
||||
normalizedRows.push(normalized.row)
|
||||
pageWarnings += normalized.warnings.length
|
||||
if (preview.length < 5) {
|
||||
preview.push({
|
||||
sourceRowId: normalized.row.sourceRowId,
|
||||
sourceSku: normalized.row.sourceSku,
|
||||
materialCode: normalized.row.materialCode,
|
||||
materialName: normalized.row.materialName,
|
||||
unitPrice: normalized.row.unitPrice,
|
||||
sourceFingerprint: normalized.row.sourceFingerprint,
|
||||
warnings: normalized.warnings,
|
||||
})
|
||||
}
|
||||
if (normalized.row.sourceCreatedAt &&
|
||||
(!sourceAsOf || normalized.row.sourceCreatedAt > sourceAsOf)) {
|
||||
sourceAsOf = normalized.row.sourceCreatedAt
|
||||
}
|
||||
cursor = normalized.row.sourceRowId
|
||||
}
|
||||
|
||||
const counts = apply && run
|
||||
? await activeMirror.writePage(run, normalizedRows)
|
||||
: await activeMirror.inspectPage(normalizedRows)
|
||||
mergeCounts(pageCounts, counts)
|
||||
pageCount += 1
|
||||
rowsRead += rawRows.length
|
||||
rowsAccepted += normalizedRows.length
|
||||
warningCount += pageWarnings
|
||||
if (apply && run) {
|
||||
await activeMirror.checkpointRun(run, {
|
||||
cursorEnd: cursor,
|
||||
rowsRead,
|
||||
rowsWritten: pageCounts.rowsWritten,
|
||||
rowsCreated: pageCounts.created,
|
||||
rowsUpdated: pageCounts.updated,
|
||||
rowsUnchanged: pageCounts.unchanged,
|
||||
rowsSkipped,
|
||||
errorCount,
|
||||
durationMs: Date.now() - startedAt,
|
||||
sourceAsOf,
|
||||
})
|
||||
}
|
||||
await options.onPage?.({
|
||||
page: pageCount,
|
||||
cursor,
|
||||
rowsRead,
|
||||
rowsAccepted,
|
||||
rowsWritten: pageCounts.rowsWritten,
|
||||
rowsSkipped,
|
||||
created: pageCounts.created,
|
||||
updated: pageCounts.updated,
|
||||
unchanged: pageCounts.unchanged,
|
||||
warningCount,
|
||||
})
|
||||
|
||||
if (rawRows.length < BOM_REFERENCE_PAGE_SIZE) break
|
||||
if (maxPages !== null && pageCount >= maxPages) {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
await sleep(BOM_REFERENCE_PAGE_DELAY_MS)
|
||||
}
|
||||
|
||||
const matchedExisting = pageCounts.updated + pageCounts.unchanged
|
||||
const wouldRemove = complete
|
||||
? Math.max(currentCount - matchedExisting, 0)
|
||||
: null
|
||||
if (complete && rowsRead === 0 && !options.allowLargeRemoval) {
|
||||
throw new Error(
|
||||
'BOM reference sync received an empty upstream source; ' +
|
||||
'pass allowLargeRemoval only after checking the upstream source',
|
||||
)
|
||||
}
|
||||
if (wouldRemove !== null && wouldRemove > 0 && currentCount > 0) {
|
||||
const removalRatio = wouldRemove / currentCount
|
||||
if (!options.allowLargeRemoval && removalRatio > MAX_SAFE_REMOVAL_RATIO) {
|
||||
throw new Error(
|
||||
`BOM reference sync would remove ${wouldRemove}/${currentCount} rows; ` +
|
||||
'pass allowLargeRemoval only after checking the upstream source',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let removed = 0
|
||||
let cleanupWarning = ''
|
||||
if (apply && run) {
|
||||
// Keep the target-material selector aligned with the same committed
|
||||
// local snapshot. This is local PostgreSQL work and never touches
|
||||
// the upstream connection.
|
||||
await activeMirror.refreshMaterialOptions()
|
||||
await activeMirror.finishRun(run, {
|
||||
cursorEnd: cursor,
|
||||
rowsRead,
|
||||
rowsWritten: pageCounts.rowsWritten,
|
||||
rowsCreated: pageCounts.created,
|
||||
rowsUpdated: pageCounts.updated,
|
||||
rowsUnchanged: pageCounts.unchanged,
|
||||
rowsSkipped,
|
||||
errorCount,
|
||||
durationMs: Date.now() - startedAt,
|
||||
sourceAsOf,
|
||||
})
|
||||
activated = true
|
||||
try {
|
||||
removed = await activeMirror.pruneRowsOutsideActiveSnapshot(run)
|
||||
} catch (error) {
|
||||
cleanupWarning = error instanceof Error ? error.message : String(error)
|
||||
warningCount += 1
|
||||
}
|
||||
}
|
||||
return {
|
||||
removed,
|
||||
wouldRemove,
|
||||
cleanupWarning,
|
||||
syncState: await activeMirror.getLatestRunStatuses(),
|
||||
}
|
||||
}
|
||||
|
||||
const execution = await execute(this.mirror)
|
||||
|
||||
return {
|
||||
apply,
|
||||
sourceKey: this.source.sourceKey,
|
||||
sourceIdentity: this.source.sourceIdentity,
|
||||
sourceDatabase: this.source.database,
|
||||
sourceSchema: this.source.schema,
|
||||
sourceObject: this.source.object,
|
||||
access: access as UpstreamBomReferenceAccess,
|
||||
batchId: run?.id ?? null,
|
||||
complete,
|
||||
consistency: apply ? 'SNAPSHOT' : 'PREVIEW',
|
||||
pageCount,
|
||||
cursor,
|
||||
rowsRead,
|
||||
rowsAccepted,
|
||||
rowsWritten: pageCounts.rowsWritten,
|
||||
rowsSkipped,
|
||||
created: pageCounts.created,
|
||||
updated: pageCounts.updated,
|
||||
unchanged: pageCounts.unchanged,
|
||||
warningCount,
|
||||
retryCount,
|
||||
wouldRemove: execution.wouldRemove,
|
||||
removed: execution.removed,
|
||||
cleanupWarning: execution.cleanupWarning,
|
||||
syncState: execution.syncState,
|
||||
preview,
|
||||
}
|
||||
} catch (error) {
|
||||
if (run && !activated) {
|
||||
await this.mirror.failRun(run, {
|
||||
cursorEnd: cursor,
|
||||
rowsRead,
|
||||
rowsWritten: pageCounts.rowsWritten,
|
||||
rowsCreated: pageCounts.created,
|
||||
rowsUpdated: pageCounts.updated,
|
||||
rowsUnchanged: pageCounts.unchanged,
|
||||
rowsSkipped,
|
||||
errorCount,
|
||||
durationMs: Date.now() - startedAt,
|
||||
sourceAsOf,
|
||||
}, error).catch(() => undefined)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await this.source.close().catch(() => undefined)
|
||||
await lease.release().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user