feat(bom-costing): add auditable dynamic company cost engine

This commit is contained in:
WuYongJie 2026-08-09 14:23:45 +08:00
parent b1566874e1
commit ead86d523e
48 changed files with 12932 additions and 467 deletions

View File

@ -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` 的生产部署目标。

View File

@ -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",

View File

@ -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;

View File

@ -0,0 +1,2 @@
ALTER TABLE "BomCostingScenario"
ALTER COLUMN "rulesetVersion" SET DEFAULT 'dynamic-bom-cost/v3';

View File

@ -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

View 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
})

View File

@ -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

View 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
})

View File

@ -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,
)

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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
}
})
})

View File

@ -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>
</>
)
}

View File

@ -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()
})
})

View File

@ -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('向上取整')
})
})

View 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)),
}
}

View File

@ -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 {

View File

@ -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
>

View File

@ -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()
})
})

View File

@ -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],
}
}

View File

@ -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('价格来源已超过允许的新鲜度')
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
export * from './dynamic-bom-cost-domain'

View File

@ -0,0 +1,2 @@
export * from './dynamic-bom-cost-domain'

View File

@ -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') ?? ''))
}

View File

@ -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
}
})
})

View File

@ -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()

View File

@ -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

View File

@ -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),
},
})

View File

@ -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 () => {

View File

@ -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)

View File

@ -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 })

View File

@ -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,
}

View File

@ -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()
})
})

View File

@ -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: {

View File

@ -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: [

View File

@ -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

View File

@ -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)
})
})

View File

@ -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)
}

View File

@ -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')
})
})

View File

@ -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
}
}

View File

@ -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级橡胶木']),
}),
}))
})
})

View File

@ -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
}
}
}

View File

@ -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)
})
})

View File

@ -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)
}
}
}