merge: bring data foundation updates into xuxiake mainline

This commit is contained in:
WuYongJie 2026-06-02 16:13:26 +08:00
commit 1c74c2b630
341 changed files with 68636 additions and 6372 deletions

View File

@ -42,8 +42,12 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
.database-backups/
.master-data-audits/
.master-data-cleanup/
# local workspace artifacts
.codex-runtime/
.codex-logs/
_logs/
output/

View File

@ -59,10 +59,13 @@ docker-compose -f docker-compose.lightweight.yml up -d
### 4. 初始化数据库
```bash
# 进入应用容器执行迁移
docker exec -it furniture3d-app npx prisma db push
docker exec -it furniture3d-app npx prisma migrate deploy
# 或在本地执行(需要数据库可访问)
npx prisma db push
npx prisma migrate deploy
# 迁移后验证主数据地基
npm run db:verify-master-data:all:strict
```
### 5. 访问系统
@ -134,19 +137,22 @@ JWT_EXPIRES_IN=7d
### 首次部署
```bash
# 推送 schema 到数据库
npx prisma db push
# 应用 Prisma migration,保留 _prisma_migrations 历史
npx prisma migrate deploy
# 生成 Prisma Client
npx prisma generate
# 验证主数据迁移、视图、约束和 JSON 边界
npm run db:verify-master-data:all:strict
```
### 后续更新(Schema 变更)
```bash
# 开发环境:直接推送
npx prisma db push
# 仅本地临时开发环境可用;脚本会拒绝 production、非本机库和疑似生产库名
npm run prisma:push
# 生产环境:使用迁移(推荐)
# 生产环境:必须使用迁移
npx prisma migrate deploy
```
@ -272,10 +278,13 @@ docker-compose -f docker-compose.prod.yml build app
docker-compose -f docker-compose.prod.yml up -d app
# 执行数据库迁移(如有)
docker exec -it furniture3d-app npx prisma db push
docker exec -it furniture3d-app npx prisma migrate deploy
docker exec -it furniture3d-app npm run db:verify-master-data:all:strict
```
### 重要提示
- 更新前请先备份数据库
- 测试环境验证后再更新生产环境
- 保留旧版本镜像以便回滚
- 生产/准生产禁止用 `prisma db push` 替代迁移;否则会绕过 `_prisma_migrations`,后续无法证明数据库结构来源。
- 仓库内 `npm run prisma:push` 只作为本地开发保护入口:默认只允许 `localhost / 127.0.0.1 / ::1`,拒绝 `NODE_ENV=production`、`VERCEL_ENV=production` 和疑似生产库名。

View File

@ -15,15 +15,15 @@
## 本仓库部署脚本
- `scripts/deploy-yydd-prod-from-git.ps1`
- `scripts/create-yydd-prod-deploy.ps1`
- 外层仓库脚本:`../scripts/create-yydd-prod-deploy.ps1`
执行部署或生产核查前,优先读取上面两个脚本的默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。
执行部署或生产核查前,优先读取上面脚本的默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。该脚本生成的远端发布脚本会执行 `npx prisma migrate deploy`,因此生产发布前必须先确认迁移目录、迁移历史和主数据 strict 巡检口径没有分叉。
## 花名册同步
- 花名册同步属于当前 `furniture-3d-system` 自有能力,不依赖 `shujuku2` 或旧入口机。
- 项目命令:`npm run sync:employee-directory`
- 项目默认命令:`npm run sync:employee-directory` 只做 dry-run,不提交同步载荷。
- 真实同步命令:`npm run sync:employee-directory:apply`,生产 systemd 脚本必须显式使用 `--apply`。
- 生产定时器安装脚本:`furniture-3d-system/scripts/install-employee-directory-sync-timer.sh`
- 生产 systemd 名称:`furniture-employee-directory-sync.service` / `furniture-employee-directory-sync.timer`
- 定时频率:每天 `03:00` 和 `15:00`。

View File

@ -142,6 +142,8 @@ npm run prisma:generate
npm run prisma:push
```
`npm run prisma:push` 只允许本地开发库使用。生产或准生产数据库必须走 `npx prisma migrate deploy`,并在迁移后执行 `npm run db:verify-master-data:all:strict`。
### 方案 C:如果本地几乎没有演示数据
可以先试:
@ -496,6 +498,8 @@ npm run prisma:generate
npm run prisma:push
```
这里的 `npm run prisma:push` 仍然只用于本地开发库;如果你面对的是服务器、生产库或准生产库,停下来改用迁移流程,不要直接推 schema。
然后重启 `npm run dev`。
### 情况 3:页面没变化

View File

@ -0,0 +1,435 @@
# 内部主数据地基治理方案
创建时间:2026-05-29
适用范围:`furniture-3d-system` 内部主数据、草稿链路、正式产品链路、非集成开品链路。
配套巡检:
- `scripts/master-data-truth-sources.js`
- `scripts/verify-master-data-truth-sources.js`
- `scripts/sql/preflight-intake-non-integrated-sku-items.sql`
- `scripts/sql/check-internal-master-data-foundation.sql`
- `docs/架构设计/内部主数据巡检基线-20260529.md`
- `scripts/verify-master-data-code-boundaries.js`
- `scripts/apply-master-data-cleanup-plan.js`
- `scripts/plan-pre-sku-formalization-repair.js`
- `scripts/master-data-required-migrations.js`
- `scripts/verify-master-data-readiness.js`
- `scripts/diagnose-prisma-migration-baseline.js`
- `scripts/preflight-database-foundation-change.js`
- `scripts/create-postgres-backup.js`
- `scripts/run-database-foundation-migrations.js`
- `scripts/apply-master-data-foundation-migrations.js`
- `scripts/rehearse-database-foundation-migration.js`
- `scripts/rehearse-prisma-structure-repair.js`
- `scripts/plan-prisma-baseline-repair.js`
- `scripts/apply-prisma-structure-repair.js`
- `scripts/run-master-data-foundation-audit.js`
- `scripts/print-master-data-foundation-checklist.js`
- `scripts/print-master-data-foundation-decision-package.js`
- `npm run db:verify-master-data:all`
- `npm run db:verify-master-data:audit`
- `npm run db:verify-master-data:readiness`
- `npm run db:verify-master-data:readiness:audit`
- `node scripts/verify-master-data-code-boundaries.js --mode reference-estimator-cost-boundary`
- `npm run db:diagnose-prisma-migration-baseline`
- `npm run db:preflight-database-foundation-change`
- `npm run db:preflight-database-foundation-change:audit`
- `npm run db:backup:postgres`
- `npm run db:foundation:migrate`
- `npm run db:foundation:migrate:audit`
- `npm run db:foundation:apply-required`
- `npm run db:foundation:apply-required:audit`
- `npm run db:foundation:rehearse-migration`
- `npm run db:foundation:rehearse-structure-repair`
- `npm run db:foundation:baseline-repair-plan`
- `npm run db:foundation:apply-structure-repair`
- `npm run db:foundation:apply-structure-repair:audit`
- `npm run db:foundation:audit`
- `npm run db:foundation:audit:cleanup-template`
- `npm run db:foundation:completion-checklist`
- `npm run db:foundation:decision-package`
## 1. 先把话说直
这份方案不是给外部系统看的壳,而是给我们自己系统打地基。
目标只有一个:让系统内部知道“谁是真相源,谁是草稿,谁是结果,谁只是过程快照”。
这个分层现在不只写在文档里,还要进入 `scripts/master-data-truth-sources.js` 的机器可读清单;`npm run db:verify-master-data:truth` 会检查核心对象的主表、快照字段、写入边界文件和数据库护栏是否仍然存在,并反向检查每个数据库护栏 relation 都已被真相源矩阵覆盖,且每个必需护栏都属于当前条目的主关系范围。真相源矩阵声明的写入边界还必须进入代码层服务/脚本写入口批准表;代码层标为 `compatibility-snapshot` 的 Prisma JSON 字段也必须被真相源矩阵覆盖,避免后续新增对象或改迁移时把真相源边界改成只靠口头约定,或让 JSON 分类表和矩阵分叉。
治理迁移也不能靠人肉记忆维护。`scripts/master-data-required-migrations.js` 是巡检 dry-run、strict 判定、只读迁移状态和历史清洗脚本共用的唯一迁移目录;清单会先自检 path、顺序、时间戳、目标 relation 和 SQL 引用,再连接数据库执行验证。迁移目录本身也要自检:新增 Prisma migration 目录必须符合 `<14位时间戳>_name`,当前仅允许 `20251213_performance_optimization` 和 `add_packaging_plan` 两个历史非标准目录作为显式债务保留。迁移 SQL 在 dry-run 事务中执行后,必须复查声明的目标 relation 或 column 是否真实出现;结构治理迁移必须真实落库才能通过 strict,幂等视图和护栏迁移允许在巡检事务中重放但不能冒充真实落库。只读迁移状态还必须报告 Prisma `_prisma_migrations` 历史表是否存在、是否存在未完成或已回滚迁移;目标表/视图碰巧存在但没有迁移历史,不能被视为稳固地基。历史清洗真正 `--apply` 时口径更严:只要清洗事务中还需要执行任何治理迁移,无论结构迁移还是幂等视图/护栏迁移,都必须先拒绝清洗,要求先独立完成迁移和 strict 验收。当前治理窗口内新增外部 v1 视图、约束、唯一索引或触发器时,测试会反查它是否进入迁移清单;`alwaysApply` 迁移如果包含 DML,必须进入显式分类清单,不能悄悄把幂等护栏迁移变成无说明的数据修复脚本。`npm run db:verify-master-data:migration-status` 只读查询目标 relation / column 是否存在,不执行迁移 SQL,适合在生产或准生产先确认真实结构状态。巡检和清洗报告必须拆分 `structureMigrations` 与 `idempotentMigrations`,并输出 `migrationApplicationStatus.strictReady / pendingStructureMigrationCount / replayableIdempotentMigrationCount`,避免把幂等重放误判成真实结构缺口,也方便部署和清洗前置检查使用同一套机器口径。
`alwaysApply` 迁移不能再无声退化成“目标表存在就算 ready”。如果某个幂等迁移没有声明 `columnCheck / columnDefaultChecks / guardChecks / relationColumnChecks / dataChecks`,清单必须显式填写 `targetOnlyReadinessAssurance`,说明它由外部只读合同、数据库护栏汇总或地基质量汇总兜底验证;否则 `validateRequiredMigrationCatalog()` 会直接失败。这个分类不是放松要求,而是把“为什么这个迁移没有单独 readiness check”写进机器清单,防止后续新增迁移靠碰巧存在的 relation 误判为已就绪。
“十分稳健”不能只看单个巡检是否通过。`npm run db:verify-master-data:readiness` 会把 Prisma schema 校验、Prisma 官方迁移状态、当前库到 `schema.prisma` 的 diff、真相源矩阵、代码边界、兼容快照读点、过程快照读点、正式 SKU attributes 写入、参考图预估成本边界、只读迁移状态和数据库 dry-run 巡检串成一个总就绪度报告。`npm run db:verify-master-data` 本身也会输出 `foundationHardReadyGate`,把迁移真实落库、数据库护栏、资产中心源数据、正式 SKU、准 SKU、非集成产品 profile、JSON/快照漂移、外部只读契约和质量决策缺口集中成总门禁;需要留存完整本地证据时使用 `npm run db:verify-master-data:audit`,完整报告写入 `.master-data-audits/foundation-hard-ready-gate-current.json`,脱敏摘要写入 `.master-data-audits/foundation-hard-ready-gate-current.summary.json`。完整报告可能包含历史 SKU、产品头、准 SKU、草稿组编号等行级样本,只能本机排查;协作汇报和阶段结论必须使用摘要文件或 `manifest.foundationHardReadyGateSummary`。只有所有命令通过、Prisma 不再报告未应用 migration、当前库结构和 schema 一致、治理迁移达到 `fullyReady`、Prisma 迁移历史可审计,且历史 `INCOMPLETE` SKU、产品头缺品类/开发类型、资产中心活动源缺系列/模型/预览图、资产规格款重复条码、非集成结构化表与 payload 漂移、正式 SKU 缺名称或缺 asset profile、正式 SKU 资产 profile 与兼容快照漂移、准 SKU 缺基础资料或与旧快照漂移、外部视图 JSON 依赖和内部 id 兜底等质量债都归零时,才输出 `hardReady: true`。否则报告只代表“可继续治理”,不能宣称底层数据已经十分稳固。readiness 的 blocker 必须尽量携带可定位证据:当前库/schema diff 需要输出脱敏 DDL 摘要,pending migration 需要输出 migration 名单,迁移历史缺失需要输出禁止捷径和授权施工顺序,历史质量债需要输出样本行。报告还会输出 `nextActionPlan`,把剩余 blocker 按“Prisma 结构和迁移历史修复 -> 治理护栏落库 -> 历史主数据字段清洗 -> 准 SKU 生命周期专用处理”的顺序分组,避免把授权施工、治理迁移和业务清洗混在一起。
正式 SKU 软删除生命周期也要按结构化字段对齐。`ProductSKU.deletedAt` 是 SKU 删除状态主字段,`ProductSkuAssetProfile.skuDeletedAt` 是为释放活动条码池和外部同步副本准备的结构化副本;数据库触发器 `ProductSKU_sync_asset_profile_deleted_at` 会在 SKU 删除态变化时兜底同步 profile,dry-run 巡检也会比对二者是否一致,任何 `skuDeletedAt` 漂移都会进入 `readiness` hard blocker,不能靠旧 `attributes` 里的生命周期字段或一次性回填结果长期兜底。删除态正式 SKU 还必须保留 `deletedCodeAt`,随产品删除时 `deletedByProductId / productDeletedAt` 必须成对出现,避免条码释放、恢复冲突检查和级联恢复缺少可审计墓碑资料;`productSkuSoftDeleteTombstoneRiskCount` 会把这类半截墓碑状态列为 hard blocker。
允许业务分阶段补齐资料时,也不能把“有原因”当成“已完整”。清洗脚本会把 `allowIncomplete=true + incompleteReason` 写入 `MasterDataQualityDecision`,用于追溯谁在什么时候确认了暂不完整、缺哪些字段、原因是什么;但 `dataQualityStatus=INCOMPLETE` 仍然是 hard blocker,外部 ready 视图和正式流转入口仍必须排除它。`MasterDataQualityDecision` 是质量审计表,不是第二套主数据,也不是放行缺失资料的后门。
需要留存当前阻断证据时,使用 `npm run db:verify-master-data:readiness:audit`,或手动执行 `node scripts/verify-master-data-readiness.js --output .master-data-audits/readiness-current.json`。该入口即使巡检失败也会先写出完整 JSON 报告,方便对比治理前后 blocker、迁移状态和历史质量债变化。`.master-data-audits/` 是本地审计证据目录,可能包含真实库结构、阻断样本和环境信息,不提交 Git。
完整地基审计批次使用 `npm run db:foundation:audit`。它会在 `.master-data-audits/foundation-audit-<timestamp>/` 下依次写出 `readiness.json`、`foundation-hard-ready-gate.json`、`foundation-preflight.json`、`foundation-migrate-plan.json` 和 `manifest.json`;即使其中任一门禁失败,也会继续收集后续证据并在 manifest 中汇总 blocker。这个入口只做只读/干跑证据收集,不生成备份、不执行迁移、不清洗数据;它的用途是给每次治理形成同一时刻的可复核证据包。`manifest.json` 会写入脱敏 `readinessSummary` 和 `foundationHardReadyGateSummary`,包含 `hardReady`、blocker 数、blocker key、数量值和迁移应用状态摘要,但不会复制历史 SKU、产品头或准 SKU 的样本行。`manifest.hardReady` 必须同时满足 readiness 总就绪度和 foundationHardReadyGate 总门禁都为 true,任何一个缺失或失败都不能宣称地基已十分稳固。
最终完成判断必须看 `manifest.foundationCompletionChecklist`,不能把某个局部命令的绿色结果当成全目标完成。该清单把本目标拆成 8 个机器可判定项:数据库结构与 Prisma 迁移历史、核心对象唯一真相源矩阵、后端写入边界、JSON/快照边界、文件系统边界、历史主数据字段清洗、PreSku 正式化生命周期和授权结构修复施工链。`npm run db:foundation:completion-checklist` 默认读取 `.master-data-audits/foundation-audit-current-goal/manifest.json`,输出脱敏清单;只要任一项 `blocked / missing`,命令就以非 0 状态退出。它不重新审计数据库,只是把同批证据的完成判定单独拎出来,给协作、验收和 goal completion audit 使用。
如果完成清单没有 8/8 通过,下一步施工判断看 `npm run db:foundation:decision-package`。它同样默认读取 `.master-data-audits/foundation-audit-current-goal/manifest.json`,只从 manifest 的脱敏摘要和完成清单生成“决策包”,把剩余阻断拆成结构修复授权、治理迁移落库、历史字段业务确认、PreSku 生命周期专用决策和最终验证五段。这个入口不写数据库、不复制敏感候选行、不输出绝对路径;用途是防止后续把 `apply-structure`、`resolve-baseline`、`apply-required`、历史清洗和 PreSku 专用修复混成一条危险捷径。决策包的 `nextGate` 只表示下一道施工门禁,不表示该门禁已经被授权或已完成。
决策包还必须输出 `executionChecklist`,把真实施工拆成“授权前刷新证据 -> 真实执行结构修复 SQL -> 结构修复后重新预检 -> 逐项补齐 Prisma 迁移历史 -> 应用 bootstrap-repair 结构/回填修复 -> 应用最终治理迁移/护栏 -> 业务确认后清洗历史质量债 -> 最终 hardReady 审计”。其中所有 `writeDatabase=true` 的阶段都必须有明确用户授权;`apply-structure` 成功前禁止进入 `resolve-baseline / apply-required / 历史清洗`,`post-repair preflight` 未证明结构 diff 归零前禁止 baseline resolve,治理迁移未 `fullyReady` 前禁止历史清洗。这个顺序是地基施工门禁,不是建议步骤。
如果要让批次审计同时保留 shadow diff 证据,可以显式追加 `--use-docker-shadow`,例如 `npm run db:foundation:audit:cleanup-template -- --use-docker-shadow --docker-shadow-port 65446`。该模式会启动临时 Docker PostgreSQL 作为 `SHADOW_DATABASE_URL`,审计结束后自动删除容器,并在 manifest 写入 `shadowDiffEvidence`;它不会修改目标数据库,也不会替代备份证据。默认不启用,避免普通审计命令偷偷拉起容器。
如果要把克隆库迁移演练也放进同一批证据包,显式追加 `--include-migration-rehearsal`,并可用 `--docker-rehearsal-port <port>` 指定端口。该步骤会调用 `scripts/rehearse-database-foundation-migration.js`,输出 `migration-rehearsal.json`;它预期在当前历史库状态下失败并复现 P3005,因此失败本身是阻断证据,不是脚本异常。
如果要证明“结构修复 SQL 是否真的能把当前备份恢复出的库修到 schema 和 migrations 等价”,显式追加 `--include-structure-repair-rehearsal`,并可用 `--docker-structure-repair-port <port>` 指定端口。该步骤会调用 `scripts/rehearse-prisma-structure-repair.js`,输出 `structure-repair-rehearsal.json`;它只在临时 Docker 克隆库上生成和执行结构修复 SQL,遇到 `DROP TABLE / DROP COLUMN / DROP TYPE / DROP SCHEMA` 会拒绝自动执行。通过也只代表“方案可演练”,不代表可以直接写真实库。
同一批次启用结构修复演练时,还会生成 `authorized-structure-repair-apply-plan.json`。该计划由 `scripts/apply-prisma-structure-repair.js` 只读生成,会把 `foundation-preflight.json`、`structure-repair-rehearsal.json` 和 `structure-repair.sql` 绑定校验:备份证据、迁移链重放、SQL 文件大小、SHA-256、SQL 文件本体无 DROP/TRUNCATE 类语句、preflight pending migration 数量、克隆库 baseline resolve 数量必须一致。`preflight` 会记录备份文件 SHA-256,真实执行阶段会重新计算并比对,不能只靠文件名或大小。`structure-repair-rehearsal` 还会记录 `prisma/migrations` 的 migration manifest(迁移 ID、相对路径、文件大小和 SHA-256),真实执行阶段会重新扫描当前迁移目录;只要演练后迁移文件增删或内容变化,就必须重新演练。它默认不会写库;真实执行必须分阶段显式传入 `--phase=apply-structure --apply --confirm=apply-prisma-structure-repair` 或 `--phase=resolve-baseline --apply --confirm=apply-prisma-structure-repair`,并且会重新读取当前 `DATABASE_URL`,要求它和 preflight 报告里的数据库身份一致。数据库身份包含协议、用户名、主机、端口、库名和 schema;结构修复后的 diff 验证、baseline resolve 和 migrate status 都固定使用同一个已确认的 `DATABASE_URL` 上下文,避免环境变量漂移。真实执行还必须显式传入 `--expected-database-identity` 和 `--expected-backup-sha256`,脚本会重新计算当前备份文件指纹并比对 preflight 记录,避免错库、错备份或旧备份继续施工。默认还要求 preflight 和结构修复演练报告不超过 60 分钟,避免拿旧证据施工。
批次审计还会生成 `baseline-repair-plan.json`。它读取同批次的 `foundation-preflight.json`,并可接收 `migration-rehearsal.json` 与 `structure-repair-rehearsal.json`,把当前状态明确分成 `ready-for-migrate-deploy`、`ready-for-baseline-resolve`、`ready-for-authorized-structure-repair`、`manual-structure-repair-required` 或 `evidence-blocked`。这份施工图只读生成,不会写库;它的作用是防止把四件事混在一起:迁移链能否重放、当前库是否等价、直接 `migrate deploy` 是否已被证伪、以及结构修复 + baseline resolve 是否已在克隆库演练通过。只要 `migrationDiff` 非空或当前库到 schema diff 非空,就不能直接 `resolve --applied`;只要克隆库演练复现 P3005,就不能在真实库上再“试一次”。`ready-for-authorized-structure-repair` 只代表可进入授权施工讨论,不代表真实库已经 hard-ready。
真相源矩阵也必须作为独立证据落盘:`truth-sources.json` 逐项输出每个核心业务对象的 `id / layer / primaryRelations / compatibilitySnapshots / notTruthSources / writeBoundary / requiredGuardNames`,并同时报告缺模型、缺快照字段、缺写入边界、缺数据库护栏、护栏关系未覆盖等问题。报告里的 `truthSourceEntryAudits` 会给每个对象单独输出 `ok / issueCount / issues`,用于定位到底是哪个业务对象的真相源边界不稳。`scripts/verify-master-data-truth-sources.js` 支持 `--output <json-file>`,因此“资产中心、草稿组、正式产品、非集成开品、人员组织、旧 CAD、基础资料”等对象的真相源边界必须能被机器报告复核,而不是只靠文档口头描述。
正式 BOM 和正式成本也必须有机器口径。`formal-bom-structure` 条目把已确认 BOM 结构固定到 `Panel / PanelMaterial / ProductHardware / Hardware`,`formal-sku` 条目把正式 SKU 成本固定到 `ProductSKU.costPrice`;`PreSku.bomPreview`、参考图预估成本、`REFERENCE_ESTIMATOR_*`、`reference-image-cost-catalog`、`PreSku.displaySummary` 汇总值和外部视图派生列都不能反向成为正式 BOM 或正式成本真相。`ProductSKU_price_cost_stock_non_negative` 会作为数据库护栏阻断负售价、负成本和负库存进入正式 SKU;`readiness` 还会把当前库中任何负售价、负成本或负库存作为 hard blocker。
其中代码边界和 JSON/快照专项报告也必须落进同一批次证据包:`code-boundaries.json` 记录服务/脚本写入口、原生 SQL、运行时 DDL、JSON 字段分类和总预算;`compatibility-snapshot-read.json`、`process-snapshot-read.json` 和 `product-sku-attributes-write.json` 分别记录兼容快照读点、过程快照读点和正式 SKU `attributes` 写入面。`scripts/verify-master-data-code-boundaries.js` 支持 `--output <json-file>`,因此这些边界报告不能只存在于终端滚动输出里。
如果要把历史质量债候选行也放进同一批证据,使用 `npm run db:foundation:audit:cleanup-template`。它会额外生成 `cleanup-template.json`,内容来自 `scripts/apply-master-data-cleanup-plan.js --template --allow-migration-dry-run`,用于暴露待补齐 SKU、产品头资料和准 SKU 正式化生命周期错位。这个文件可能包含真实业务资料和确认字段,只能留在本地 `.master-data-audits/`,不能提交 Git,也不能把 `--allow-migration-dry-run` 误解为治理迁移已经真实落库。准 SKU 正式化生命周期错位只允许作为人工决策候选暴露;通用清洗脚本会拒绝带有 `preSkuFormalizationMismatches.action.decision` 的计划,后续必须用专用服务/专用计划处理。专用只读计划入口是 `npm run db:foundation:plan-pre-sku-formalization-repair -- --output .master-data-audits/pre-sku-formalization-repair-plan-current.json`,它只输出候选、允许决策和全局护栏,不执行任何落库。
带清洗模板的批次审计会在 `manifest.json` 里额外写入 `cleanupTemplateSummary`。该摘要只包含候选 SKU 数、产品头数、空白待填字段统计、准 SKU 正式化错位数量/原因分布和迁移状态计数,不包含产品名、SKU 资料、草稿组编号、确认人、业务备注或迁移路径明细;日常汇报优先看这个脱敏摘要,只有真正准备业务确认时才打开敏感 `cleanup-template.json`。
如果 `readiness` 或 `prisma migrate status` 显示目标库缺少 `_prisma_migrations` 或存在大量未应用 migration,先运行 `npm run db:diagnose-prisma-migration-baseline`。该入口只读诊断当前库、Prisma schema、当前库到 `schema.prisma` 的 diff、迁移目录和官方 pending migration 列表,并输出是否需要 baseline 方案。它不会自动执行 `migrate deploy`、`migrate resolve` 或任何数据库写入;如果配置了只用于 diff 的 `SHADOW_DATABASE_URL`,它会分别比较两件事:`migrationDiff` 证明 migrations 重放结果和当前库是否等价,`migrationSchemaDiff` 证明 migrations 重放结果和当前 `schema.prisma` 是否等价。`SHADOW_DATABASE_URL` 必须和目标库不同,且数据库名必须包含 `shadow / test / tmp / temp` 这类明确标记,禁止误把真实业务库当 shadow 库。对于历史 `db push` 或手工 SQL 形成的库,必须先逐项确认结构是否等价于 migration 结果,再决定是 `migrate deploy` 还是按已验证 migration 逐项 `resolve --applied`,不能直接整批标记为已应用;没有 shadow diff 或等价性证据时,不能做 baseline resolve。只要当前库到 `schema.prisma` 的 diff 非空,就说明结构本身还没补齐,必须先迁移或补结构,不能只修迁移历史。
真正进入数据库结构变更前,先运行 `npm run db:preflight-database-foundation-change`。该入口仍然只读,但会额外要求 `DATABASE_BACKUP_FILE` 或 `DB_BACKUP_FILE` 指向一个真实存在且非空的备份文件;没有可核验备份时,无论是 `migrate deploy` 还是 baseline resolve 都不能继续。预检会同时报告 `pg_dump / psql` 是否可用:如果没有备份文件且本机也没有 `pg_dump`,要先补备份工具链或提供外部已完成的备份文件。预检会根据当前库/schema diff、pending migrations 和 shadow diff 证据输出建议动作:结构缺失时优先 `migrate-deploy-required`;结构等价但迁移历史缺失时才可能进入 `baseline-resolve-candidate`;缺 shadow diff 证据时固定阻断 baseline。预检报告里的 `migrationSchemaDiff.status = 0` 只说明迁移链本身能重放到当前 schema,不代表当前目标库已经补齐结构;`migrationDiff.status = 2` 仍然表示目标库和迁移结果不等价,不能直接 baseline resolve。
预检需要留档时使用 `npm run db:preflight-database-foundation-change:audit`,报告会写入 `.master-data-audits/foundation-preflight-current.json`。这份报告用于证明结构变更前是否具备备份证据、PostgreSQL client 工具链、迁移状态和推荐动作,不能替代真实备份或用户授权。
如果 `pg_dump / pg_restore / psql` 不在 `PATH`,预检报告会输出 `backupToolingHint`:包括缺失命令、已扫描目录、候选 PostgreSQL client bin 目录和可直接设置的 `POSTGRES_BIN_DIR` 建议。默认会扫描 `POSTGRES_CLIENT_SEARCH_DIRS`、`POSTGRES_BIN_DIR`、`PG_BIN_DIR` 以及常见 PostgreSQL 安装目录;Windows 便携版或自定义安装路径可以先设置 `POSTGRES_CLIENT_SEARCH_DIRS=<候选根目录>` 再跑预检。这个提示只帮助定位工具链,不会绕过可核验备份要求。
如果本机没有 PostgreSQL client,但 Docker 可用,`backupToolingHint.dockerFallback` 会报告是否存在可用的 `POSTGRES_DOCKER_IMAGE`(默认 `postgres:16`)。Docker 兜底只说明“可能有备份工具链替代路径”,仍然必须生成 PostgreSQL custom 格式备份,并用 `pg_restore --list` 验证后,把文件路径作为 `DATABASE_BACKUP_FILE` 提供给预检;不能因为 Docker 可用就跳过备份证据。
本地或可控环境可用 `npm run db:backup:postgres` 生成标准 PostgreSQL custom 格式备份。该脚本只调用 `pg_dump` 读取数据库并写入 `.database-backups/*.dump`,不修改数据库;数据库密码只通过 `PGPASSWORD` 环境变量传给子进程,不写入命令参数、报告或 Git。生成后还必须用 `pg_restore --list` 验证备份能被读取,避免空文件或损坏 dump 冒充兜底。没有 `pg_dump / pg_restore` 时脚本会失败,不能用空文件替代备份。Windows 或便携版 PostgreSQL client 不在 `PATH` 时,可用 `PG_DUMP_PATH` 指向 `pg_dump.exe`,用 `PSQL_PATH` 指向 `psql.exe`,用 `PG_RESTORE_PATH` 指向 `pg_restore.exe`,也可以用 `POSTGRES_BIN_DIR` 或 `PG_BIN_DIR` 指向 client 的 `bin` 目录;需要调整备份目录时使用 `npm run db:backup:postgres -- --output-dir <目录>` 或 `DATABASE_BACKUP_DIR`。`.database-backups/` 是本地敏感备份目录,禁止提交 Git。
如果本机缺少 PostgreSQL client,但 Docker 可用,可以显式使用 `npm run db:backup:postgres -- --use-docker` 作为备份工具链兜底。该入口默认使用 `POSTGRES_DOCKER_IMAGE` 或 `postgres:16`,默认不隐式拉取镜像;镜像不存在时必须先人工准备镜像,或显式追加 `--allow-docker-pull`。Docker 兜底同样生成 custom dump,并在容器内执行 `pg_restore --list` 校验;数据库密码只通过子进程环境传给 Docker,再以 `--env PGPASSWORD` 变量名传入容器,不能拼成 `PGPASSWORD=value` 出现在命令行。Docker 生成的备份文件仍必须作为 `DATABASE_BACKUP_FILE` / `DB_BACKUP_FILE` 输入给 `npm run db:preflight-database-foundation-change`,不能把“Docker 命令成功”当成结构变更授权。
当本机没有 `pg_restore` 但 Docker PostgreSQL client 镜像已准备好时,结构变更 preflight 会用 Docker `pg_restore --list` 重新验证 `DATABASE_BACKUP_FILE` 指向的备份文件。这样 Docker 兜底不是只在备份脚本里有效,也会被后续迁移门禁承认;但前提仍然是备份文件真实存在、非空且可读,不能用 Docker 可用本身替代备份证据。
结构缺失且备份预检通过后,先运行 `npm run db:foundation:migrate` 查看执行计划。该脚本默认只输出 dry-run 计划;只有预检为 `ready: true`、建议动作为 `migrate-deploy-required`,并显式传入 `--apply --confirm=migrate-deploy --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`,且脚本现场重算的目标库身份和备份 SHA-256 都一致时,才会执行 `prisma migrate deploy`。它不负责 baseline resolve,避免把“补结构”和“补迁移历史”混成一条危险捷径。迁移后必须继续运行 `npm run db:verify-master-data:readiness`,不能把 migration 命令成功当成地基已经十分稳。
执行计划需要留档时使用 `npm run db:foundation:migrate:audit`,报告会写入 `.master-data-audits/foundation-migrate-plan-current.json`。该 audit 命令仍是 dry-run;真正执行迁移仍必须显式追加 `--apply --confirm=migrate-deploy --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`,且前置预检必须已经通过。
如果要证明“当前备份恢复出的非空库是否能直接 `migrate deploy`”,使用 `npm run db:foundation:rehearse-migration -- --output .master-data-audits/foundation-migration-rehearsal-current.json`。该脚本只操作临时 Docker 克隆库:从 `DATABASE_BACKUP_FILE / DB_BACKUP_FILE` 指向的 PostgreSQL custom dump 恢复数据,再在克隆库上运行 `prisma migrate deploy`,最后删除容器并写出 JSON 报告。当前本地备份演练已复现 `P3005`,即“目标库非空且缺少迁移历史,不能直接 migrate deploy”;这类结果必须被当作 baseline/结构修复方案的证据,而不是在真实库上再试一次。
如果要演练结构修复本身,使用 `npm run db:foundation:rehearse-structure-repair -- --output .master-data-audits/structure-repair-rehearsal-current.json`。该脚本会把备份恢复到临时 Docker 克隆库,生成 `当前克隆库 -> schema.prisma` 的修复 SQL;只有 SQL 不含 DROP 类破坏语句时才会在克隆库执行,然后再次验证克隆库到 `schema.prisma` 为空 diff、克隆库到 migrations 重放结果也为空 diff。若传入 `--repair-sql-output <sql-file>`,会把本次演练实际使用的结构修复 SQL 落盘并写入 `sizeBytes / sha256`,用于后续授权前核对施工包。当前本地演练结果为 `structure-repair-rehearsal-passed`,修复 SQL 不含 DROP 类语句;最新 SQL 文件大小为 17304 字节,SHA-256 为 `f569af9e2cf8c26e3e0e007217b9187e3d30a20aaa3dac3c5cd1db843ade6f28`。演练报告还会在结构对齐后输出 93 个 `migrate resolve --applied` 候选 migration id,并在克隆库逐项演练 resolve;当前 93 个全部 applied,随后 `prisma migrate status` 返回 up to date。再应用 38 项当前仍未就绪的治理迁移/护栏后,readiness 仍会剩 `structuredSkuItems.incomplete`、非集成产品 profile 缺品类/开发类型和 PreSku 生命周期错位等历史质量债。这仍然不是生产写库授权,只是证明结构修复、baseline resolve 和迁移状态恢复路径在克隆库可重复。
基线和结构修复施工图使用 `npm run db:foundation:baseline-repair-plan`,也可以显式传入 `--preflight <json>`、`--rehearsal <json>` 和 `--structure-rehearsal <json>` 复用同一批审计证据。该脚本不会执行 `migrate deploy`、不会执行 `resolve --applied`、不会生成或执行结构修复 SQL;它只把证据整理成阶段计划和禁止动作。只有 `migrationSchemaDiff` 为空、备份证据可用、且当前库和 migrations/schema 等价时,才会输出 `ready-for-baseline-resolve`;结构不等价但结构修复演练已通过时,输出 `ready-for-authorized-structure-repair` 并列出“重新备份、重新预检、授权执行计划、真实库结构修复、diff 归零、逐项 baseline resolve、应用治理护栏、readiness/cleanup”的施工顺序;结构不等价且没有演练证据时固定输出 `manual-structure-repair-required`。
真实库结构修复和 baseline resolve 不再建议手工复制 `psql` 或 `migrate resolve` 命令直接施工,而是走 `npm run db:foundation:apply-structure-repair`。该入口默认只输出授权计划;`apply-structure` 阶段只负责执行已经校验 SHA-256、SQL 本体安全、证据新鲜且目标库身份一致的结构修复 SQL,成功后必须重新跑 preflight;`resolve-baseline` 阶段要求 post-repair preflight 已证明当前库到 `schema.prisma`、migrations 到当前库的 diff 都为空,再按演练报告中的 migration id 清单逐项 resolve。这样把“补结构”和“补迁移历史”拆成两个可验收阶段,避免一次命令混做两件高风险动作。
baseline resolve 完成后,治理迁移、外部只读视图、约束、触发器和护栏统一走 `npm run db:foundation:apply-required`,但必须分阶段看待。该入口默认只输出 dry-run 计划;如果 `_prisma_migrations` 不存在、存在失败/回滚记录,仍会固定阻断真实执行。第一阶段可显式使用 `--phase=bootstrap-repair --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`,只处理缺失目标结构和清单中登记过 `repairSql` 的真实回填;目标结构已经存在但没有 `repairSql` 的未就绪迁移会被阻断,不能盲目重跑整段 migration。第二阶段再使用不带 `--phase` 的默认 `finalize` 命令应用最终治理护栏;该阶段仍会要求结构迁移和历史数据检查全部 ready,应用后若不能达到 `fullyReady` 会回滚。这个入口只服务“结构修复 + baseline resolve 之后”的治理迁移补齐,不能代替 `migrate deploy`,也不能用于绕过结构修复或业务清洗。
## 2. 内部真相源分层
### 2.1 资产中心是真相源
- `AssetSeries`:系列真相源。
- `AssetFamily`:资产族真相源。
- `AssetVariant`:规格款真相源。
- `Material` / `Hardware`:基础物料真相源。
要求:
- 系列、资产族、规格款的编码和状态必须可追溯。
- `AssetSeries.code / AssetFamily.code / AssetVariant.code`、规格款 `identityKey` 和活动规格款 `barcode` 必须由数据库唯一约束兜底,不能只靠页面校验或导入前检查。
- 资产族和系列的兼容关系必须由数据库唯一约束去重,避免同一归属关系在后续草稿或正式入库中被重复解释。
- `AssetVariant` 的 `code / name / barcode / modelUrl / thumbnailUrl / dimensions` 不能长期依赖 JSON 兜底。
- 任何从资产中心抄出去的资料,都必须保留来源 ID 和同步时间。
### 2.2 辅助装配和草稿组是过程层
- `DraftGroup`:一次装配结果的承接层。
- `PreSku`:正式入库前的准 SKU。
要求:
- 草稿组不是正式产品。
- 准 SKU 不是正式 SKU。
- 草稿里的 `summary / familySelection / variantSelection / displaySummary / bomPreview / packagingSnapshot` 只能算过程快照,不能反过来当正式主数据真相。
### 2.3 正式产品是结果层
- `Product`:正式型号主数据。
- `ProductSKU`:正式 SKU 真相源。
- `ProductSkuAssetProfile`:正式 SKU 的资产资料结构化同步副本。
要求:
- 对外和对内读取正式产品时,优先信 `Product / ProductSKU`。
- `ProductSkuAssetProfile` 承接来源资产 ID、型号、规格码、条码、尺寸、颜色、材质、图片、来源状态和同步时间。
- `ProductSkuAssetProfile.skuDeletedAt` 单独承接正式 SKU 生命周期,不能把 SKU 删除状态混进 `sourceStatus`;活动条码唯一池只约束 `sourceStatus = ACTIVE` 且 `skuDeletedAt IS NULL` 的资料副本,且数据库触发器会跟随 `ProductSKU.deletedAt` 自动同步。
- `ProductSKU.attributes.assetSkuSnapshot` 只保留为兼容快照和历史排查证据,不再作为主读真相。
- 资产资料自动同步时,上一版资料和锁定字段以 `ProductSkuAssetProfile` 为准;`attributes.assetSkuSnapshot` 只允许在 profile 缺失时做一次性兼容兜底。
- `ProductSKU.skuCode / name` 是正式 SKU 的编码和名称结构化字段,不再依赖 `attributes.skuName` 作为主读来源。
- `ProductSKU.attributes` 的新增写入只允许使用集中登记的兼容快照键;`barcode / model / specCode / productName / versionNo / color / material / size` 等正式资料字段必须写 `ProductSKU` 结构化列或 `ProductSkuAssetProfile`,不能重新把 JSON 变成第二真相。
- `PreSku.variantSelection / bomPreview / installStepPreview / packagingSnapshot` 是过程快照,只允许用于辅助装配恢复、草稿只读展示、BOM/安装步骤/包装输入和正式入库物料生成;正式编码、条码、品类、人员和权限必须来自 `PreSkuBasicInfo`、资产中心结构化表、正式产品结构化列或人员组织表,不能从过程快照反推。
- 正式 SKU DTO 输出必须把兼容 JSON 命名为 `legacyAttributes`,并同时输出结构化 `assetProfile`;列表、详情、分享、回收站删除态等对外/前端合同都不能继续用 `attributes` 这个名字暗示它是正式主数据合同字段。
- 正式 SKU 创建、CAD 导入、产品上传、SKU 编辑、轻仓库概览和 DTO 输出都必须同步读写 `ProductSKU.name`;普通属性编辑不得顺手删除 `assetSkuSnapshot` 兼容快照。
- 任何正式 SKU 的关键字段都要能从数据库结构化字段直接读出来。
- 对外正式 SKU 视图必须显式输出稳定业务键:`product_business_key = Product.modelNumber`,`sku_business_key = ProductSKU.skuCode` 的规范化大写值;内部 `product_id / sku_id` 只能用于追踪和排错。
- 对外正式产品素材和 BOM 明细视图也必须携带同一套业务键;明细视图不能迫使外部系统回头用内部 `product_id / sku_id` 做关联。
- 外部只读视图的实际字段必须和字段字典完全一致:承诺列缺失要失败,多出来的未登记列也要失败;`*_code / *_business_key` 这类业务字段不能用内部行 `id` 兜底。
### 2.4 非集成开品是业务过程层
- `IntakeNonIntegratedProduct`:非集成开品工作区。
- `IntakeNonIntegratedProductProfile`:非集成开品产品头主数据结构化承接表。
- `IntakeNonIntegratedSkuItem`:非集成开品 SKU 结构化明细承接表。
- `ProductPatentRecord`:产品专利台账结构化承接表。
- `IntakeReferenceLibraryItem`:产品资料侧资料库型条目的服务端承接表,用于逐步替换包装材料、配件、封边、钢架、SPU 规则等浏览器本地资料库。
- `ApprovalWorkflow / ApprovalWorkflowFieldChange`:围绕产品资料协作的流程记录和字段变更审计,只能承载过程协作状态,不能反向成为产品头或 SKU 主数据真相。
- `PlanningTemplate / PlanningWorkspace / PlanningDraft`:产品规划过程层,模板槽位、工作台筛选和草稿候选快照只服务开品前组合推演,不能反向成为资产中心或正式产品主数据真相。
- `User / Department / Team / EmployeeDirectory`:人员、组织架构、业务类型和外部花名册同步真相源,决定登录身份、权限范围和自动业务类型划归。
- `Panel / Hole / ProductHardware / AssemblyStep / AssemblyVersion`:旧 CAD 兼容链结构数据真相源,只服务历史产品、CAD 导入、装配预览和制造/3D 兼容能力,不能反向主导新平台资产中心或草稿组主链。
- `ProductCategory / MaterialCategory / HardwareCategory / CommonComponentCategory` 以及 `SubstrateCategory / SubstrateEnvironmentGrade / SubstrateThicknessSpec / SubstrateSurfaceOption / SubstrateSystemNameOption`:基础资料分类和基材字典真相源,不能被页面下拉、历史 payload 或本地缓存替代。
- `HardwareMapping / SubstrateFinish / PanelMaterial / AssetVariantLocationBinding / PreSkuLocationBinding / PackagingPlan`:匹配记忆、关系绑定和旧包装方案结构化真相源,JSON 只能保存受控快照,不承担身份判断。
要求:
- `payload` 只能承载过程数据和快照,不可以长期承担正式主数据真相。
- 产品名称、主图、品类、销售分类、年份、状态、开发类型、货品类型、材质类型、生产类型和主要负责人字段从 `payload / openingReference` 拉出到 `IntakeNonIntegratedProductProfile`。
- 聚水潭存量商品来源字段从 `payload.metadata.source*` 拉出到 `IntakeNonIntegratedProductProfile`,包括 `sourceSpu / sourceSkuId / sourceCategory / sourceItemType / sourceSalesStatus / sourceEnabled / sourceStockDisabled / stockSkuCount / stockHasOnSaleSku`。
- SKU 主数据字段从 `payload.skuItems` 拉出到 `IntakeNonIntegratedSkuItem`,包括 `skuCode / specName / model / barcode / size / color / material / manufacturer` 等字段。
- `skuItemId` 保留原始明细身份,`sortOrder / skuSequence / batchNo` 保留业务排序和批次口径。
- 非空 `skuCode` 和非空 `barcode` 必须在结构化表层收口唯一性,不能只靠页面或 JSON 约定避免重复。
- `dataQualityStatus` 和 `missingRequiredFields` 是结构化主数据质量标记,`INCOMPLETE` 记录只能作为待清洗资料,不应自动流转成完整商品资料。
- `MasterDataQualityDecision` 只记录分阶段清洗或历史资料确认的质量决策;它不保存 SKU 真值,不改变 `READY / INCOMPLETE` 判断,也不能替代补齐 `skuCode / specName / model`。
- 非集成 SKU 的完整性判定必须由后端统一规则生成;结构化表写入、巡检、合同等正式流转入口不能各自实现一套判断。
- 对外非集成产品和 SKU 视图必须显式输出稳定业务键:`product_business_key = 品牌:SPU`,`sku_business_key = 品牌:SPU:SKU编码`;内部技术行 ID 只能用于追踪和排错,不能作为外部系统同步身份。
- 空字段、半成品字段、内部流程字段要能区分。
- 立项前/草稿类记录不能被当作完整正式资料直接流转到后续系统。
- 专利台账不能保存在浏览器 `localStorage` 里;数据库 `ProductPatentRecord` 才是主数据真相,前端只允许保留当前会话内存态用于测试或 API 失败后的临时显示。
- 资料库型条目不能继续只有浏览器 `localStorage` 一份真相;需要多人协作、BOM、成本、编码或外部同步依赖的资料库,必须写入 `IntakeReferenceLibraryItem` 或更专用的服务端表。
- 参考图预估成本工具里的内置包装/封边/五金样本单价只属于页面辅助估算,不是正式成本、BOM、包装方案或外部只读契约真相源;正式成本必须来自结构化物料/五金/包装资料库或明确的后端算法,并由 `reference-estimator-cost-boundary` 代码巡检阻断误用。
- 审批流编号、标题、访问密钥、参与人列表、业务角色、字段矩阵和字段变更来源必须有服务层与数据库双护栏;审批摘要只保存协作填报结果,正式资料仍要回到结构化产品资料表承接。
- 审批流访问范围只能由结构化 `businessRoles / participantUserIds` 决定;`fieldMatrix` 只表达字段责任矩阵,不能在 `businessRoles` 为空时反推流程可见范围。
- 规划模板编码、模板名称、槽位定义、工作台名称、可见范围、槽位筛选、草稿候选 key 和草稿快照 JSON 形态必须有服务层与数据库双护栏;`rules / metadata / selectedAssets / sourceSnapshot` 等 JSON 只允许作为受控过程对象。
- 人员和组织主数据必须先规范化再入库:用户邮箱小写、花名去空格、业务类型去重,员工目录的钉钉 ID、工号、手机号、邮箱和外部部门 ID 都不能用空字符串表达缺失;花名册同步不能把同一个员工或部门因为空格/大小写差异写成多条。
- 非集成产品资料权限判断必须以结构化 `IntakeNonIntegratedProductProfile` 和产品表明确列为准;旧 `payload` 里的人员字段只能作为历史过程快照,不能参与编辑/下载权限判断。
- 审批流权限判断必须以 `ApprovalWorkflow.businessRoles / participantUserIds` 为准;`ApprovalWorkflow.fieldMatrix` 只能参与字段级责任判断,不能替代结构化可见范围。
- 旧 CAD 兼容链的结构数据也必须有明确边界:板件、孔位、产品五金、装配步骤和装配版本可以保留 JSON 几何/动画/快照,但身份、尺寸、枚举、孔位几何、置信度、关联文本和数组引用必须由服务层与数据库双护栏兜住。
- 基础资料和关系绑定也必须有明确边界:分类/字典名称按去空格小写后的语义唯一,排序非负;五金匹配记忆每行只表达图块名或尺寸哈希之一;基材花色、板件材质、规格款库位和准 SKU 库位绑定不能保存空引用;旧正式产品 `PackagingPlan` 的名称、箱数、重量/体积和 `boxes` 数组形态必须由服务层与数据库兜底。
## 3. 当前地基状态和治理重点
### 3.1 编码
重点检查:
- 系列编码
- 资产族编码
- 规格款编码
- SKU 编码
- 条码
目标:
- 编码生成和冲突检测必须在后端统一收口。
- 数据库必须保留唯一性约束和可追溯字段。
- 非集成开品 SKU 的 `skuCode / barcode` 已新增结构化承接和非空唯一约束;写入链路和巡检继续拦截重复风险,避免历史 JSON 或后续写入绕开主数据约束。
### 3.2 生命周期
重点检查:
- DRAFT
- ACTIVE / AVAILABLE
- ARCHIVED / DELETED
- FORMALIZED / INCOMPLETE
目标:
- 草稿、过程、正式结果不能混成一个状态。
- 外部和内部都要能一眼看出当前记录处在哪个阶段。
- 非集成 SKU 明细用 `READY / INCOMPLETE` 表达资料完整度;`INCOMPLETE` 不是业务失败,而是历史或草稿资料尚未达到主数据可流转标准。
### 3.3 数据质量
重点检查:
- 空编码
- 重复编码
- 关键字段缺失
- JSON 快照里藏着关键主数据
- 从草稿层误流到正式层的数据
目标:
- 已把非集成开品 SKU 明细从 `payload.skuItems` 结构化承接出来,让问题从 JSON 隐藏状态变成数据库可巡检状态。
- 已把正式 SKU 名称结构化到 `ProductSKU.name`,并把资产资料从 `ProductSKU.attributes.assetSkuSnapshot` 结构化承接到 `ProductSkuAssetProfile`;正式入库和资产同步任务会同时写入 profile,外部正式 SKU 视图改为读取结构化字段。
- 正式 SKU 资产同步和入库前条码冲突检测已收紧到 `ProductSkuAssetProfile` 优先;旧 `attributes.assetSkuSnapshot` 只有在 profile 缺失时参与兼容判断,避免旧快照误导同步合并和冲突检测。
- 正式 SKU 资产同步和准 SKU 基础资料条码冲突检测已进一步收口为只读 `ProductSkuAssetProfile`;`ProductSKU.attributes.assetSkuSnapshot` 仍可被同步写入为兼容副本和排查证据,但不再参与常规业务判断。
- 正式入库的重复/相似度判断已收口为只读现有关联 `sourcePreSku` 的 `variantSelection / bomPreview`;旧 `ProductSKU.attributes.variantSelectionSummary / bomPreview` 只保留为历史快照,不再参与正式阻断判断。
- 正式入库新建 `ProductSKU.attributes` 不再写入整包 `PreSku.displaySummary`;草稿展示快照不得随正式 SKU 一起沉淀成第二套主数据,正式基础资料必须来自 `PreSkuBasicInfo`、`ProductSKU` 和 `ProductSkuAssetProfile`。
- 正式入库时 `PreSkuBasicInfo.formalSkuCode` 是要落入 `ProductSKU.skuCode` 的结构化规格码,`PreSkuBasicInfo.specName` 是正式 SKU 名称优先来源;接口传入的临时 `skuCode / skuName` 不能覆盖结构化基础资料。如果该编码已存在,后端会阻断入库并要求回草稿组修正基础资料,不能自动追加 `-1` 之类后缀把规格码静默改写成另一套编码。
- 新建正式 SPU 时,`Product.modelNumber` 和 `Product.name` 必须由产品资料显式传入,后端不再使用 `SPU-${Date.now()}` 这类临时型号兜底;型号冲突必须阻断并回到产品资料层处理。
- `Product.modelNumber` 是正式 SPU 永久身份,软删除 Product 不释放型号池;如果业务确实要复用型号,必须先走显式恢复/废弃/改号流程,不能像 SKU 墓碑码一样自动改写。
- 旧 CAD 导入和回收站恢复链路也不得在 SKU 编码冲突时自动追加 `-1 / -2` 后缀;冲突必须阻断,避免旧兼容链绕开正式 SKU 编码池规则。
- SKU 级 CAD 导入创建链路必须经过专用后端 schema 校验;该 schema 复用正式 SKU 编码、价格、库存等主数据边界,同时允许上传链真实使用的站内模型/图片路径和对象型 CAD 元数据,避免 CAD 上传旁路用弱校验写入正式 SKU。
- 传统产品上传链路不得从 `skuAttributes.skuName` 反写 `ProductSKU.name`;上传属性只能作为兼容元数据保存,结构化 SKU 名称没有显式输入时固定使用 `skuCode`。
- 传统 demo/test seed 数据也必须写结构化 `ProductSkuAssetProfile`;颜色、材质、尺寸不能因为“只是示例”继续沉淀到 `ProductSKU.attributes`。
- 正式 SKU 条码重复风险已纳入内部巡检,按未删除正式 SKU 且 `ProductSkuAssetProfile.sourceStatus = ACTIVE` 的非空 `barcode` 分组检查;软删除正式 SKU 释放编码时会使用结构化 `originalSkuCode`,并始终用原始编码重建墓碑编码,避免重复释放后出现多层 `__deleted__` 膨胀。正式 SKU 删除/恢复会由服务层和数据库触发器共同维护 `ProductSkuAssetProfile.skuDeletedAt`,让删除态 SKU 释放条码唯一池,恢复时遇到活动条码冲突则阻断。
- SKU 列表/详情 DTO 已输出 `assetProfile`、`legacyAttributes` 与统一展示标签 `displayTags`;回收站删除态 SKU 也只输出过滤后的 `legacyAttributes`,不再暴露裸 `attributes`;轻仓库名称读取顺序为 `ProductSKU.name -> ProductSkuAssetProfile.specName -> skuCode`,不再从 `attributes.skuName` 兜底。
- 公开分享详情已输出 `name / assetProfile / legacyAttributes`,不再把 `attributes` 作为公开 SKU 合同字段;分享页展示标签优先来自 `ProductSkuAssetProfile`,旧属性仅做兼容兜底。
- 分享链接、传统上传和协作者权限已继续补齐底层护栏:`ShareLink` 只作为公开访问记录,必须有非空 token、非负访问次数和晚于创建时间的过期时间;传统模型/CAD 上传链路的 `visibility` 必须落在固定枚举;`ProductCollaborator.role` 只能是 `editor / viewer`,非法角色不会再被读取逻辑兜底提升为编辑权限。
- 旧产品详情和旧 SKU 详情已把可见规格标签收口到共享展示规则:优先 `ProductSkuAssetProfile.specName / color / material`,旧 `attributes` 只在结构化资料缺失时作为过滤后的兜底;CAD 内部字段继续保留但不展示。
- 普通 SKU 编辑链路不再接收或提交 `ProductSKU.attributes`;旧属性袋只能作为 `legacyAttributes` 展示、CAD 兼容和软删除恢复证据保留。SKU 名称、价格、库存、编码等正式字段必须走结构化字段,不能通过 attributes 旧属性袋间接更新或清空。
- `ProductSKU.attributes.skuName` 不再是允许新增写入的兼容键;正式 SKU 名称只能写 `ProductSKU.name`。普通 SKU 创建、CAD 导入创建和正式入库都会阻断或移除 `skuName / barcode / model / specCode / color / material / size` 等正式资料字段写入旧属性袋;代码层巡检同时覆盖 `skuName:` 和对象简写 `{ skuName }`。
- 已把准 SKU 基础资料从 `PreSku.displaySummary / familySelection` 结构化承接到 `PreSkuBasicInfo`;`displaySummary` 只保留为展示兼容快照。
- 草稿组 DTO 已显式返回 `PreSkuBasicInfo` 的 `basicInfo` 字段;产品资料入口、草稿组基础资料编辑和准 SKU 展示优先读取 `basicInfo`,`displaySummary / familySelection` 只作为旧数据兼容兜底。
- 准 SKU 基础资料冲突检测已只查 `PreSkuBasicInfo`;历史 `findByDisplaySummaryField(s)` JSON path 查询入口已移除,`findByBasicInfoField(s)` 不再回查 `displaySummary`。
- 草稿组基础资料保存链路不再把旧 `displaySummary / familySelection` 字段自动提升为结构化主数据;自动建议只来自资产中心快照,已有值只认 `PreSkuBasicInfo`。保存时会清理 `displaySummary` 中残留的基础资料字段,避免旧展示快照继续形成第二真相源。
- 草稿组承接层本体也进入数据库护栏:`DraftGroup` 的编号、名称、可选文本和 `summary` 对象形态,`PreSku` 的序号、默认名称、来源候选 key、过程快照 JSON 形态、组件编码数组和预估重量都由 CHECK 约束兜底;readiness 会把草稿组/准 SKU 本体坏形态,以及准 SKU `FORMALIZED` 状态和正式 SKU 来源关系不一致,作为 hard blocker。准 SKU 正式化生命周期错位不只输出数量,还必须输出最多 20 条样本,包含草稿组编号、准 SKU 序号、当前状态、关联正式 SKU 和错位原因;readiness blocker 会直接携带最多 5 条样本证据,后续清洗只能通过计划/服务闭环处理,不能手工 SQL 猜改状态。
- `ProductSKU.attributes` 只能保存登记过的兼容快照和追溯信息,不得再保存 `skuName / model / specCode / barcode / category / color / material / size` 等正式资料字段;数据库 CHECK 和 readiness 会同时阻断这些字段重新进入 JSON 兼容袋。
- 准 SKU 基础资料保存时,`model / formalSkuCode / barcode / versionNo / attributeCode / drawingId` 这类编码字段在服务层统一规范化为大写后再参与冲突检测和结构化落库,避免数据库大小写敏感唯一池被小写编码绕开。
- 核心编码和条码不再只依赖服务层规范化:数据库迁移会先按 `upper(btrim(...))` 检查历史语义重复,再确定性清洗可安全字段,并用表达式唯一索引和 `CHECK` 约束兜底。覆盖范围包括 `AssetSeries.code`、`AssetFamily.code`、`DraftGroup.code`、`PlanningTemplate.code`、`Substrate.code`、`WarehouseLocation.code`、`PackagedItem.code`、`Material.code`、`Hardware.code`、`CommonComponent.code`、`Product.modelNumber`、活动 `ProductSKU.skuCode`、`AssetVariant.code / barcode`、`PreSkuBasicInfo.formalSkuCode / barcode`、`ProductSkuAssetProfile.barcode`、`IntakeNonIntegratedSkuItem.skuCode / barcode`、`IntakeReferenceLibraryItem.code`、`SpuNumberCounter.prefix`、`IntakeNonIntegratedProduct.spu / spuBase / brand`;删除态正式 SKU 墓碑编码不强行大写,但不参与活动 SKU 归一唯一池。
- 后端写入入口也必须和数据库护栏同口径,不能把数据库约束当成第一道业务校验。当前已收紧 `PlanningTemplate.code`、`WarehouseLocation.code`、`Substrate.code`、`Material.code`、`Hardware.code`、`CommonComponent.code`、正式 `Product.modelNumber`、正式 `ProductSKU.skuCode`、`IntakeNonIntegratedSkuItem.skuCode / barcode`、SKU CAD 导入、传统产品上传、准 SKU 包装方案保存、品牌物料准备项、正式入库承接链路、协作者角色和产品上传可见范围:进入仓储层前先规范化身份字段,空编码/空身份、游离状态、负数数量、重复物理库位、非法权限角色和可提前发现的冲突由服务层直接阻断。
- 仓储查询入口不能再把“调用方已经规范化”当隐含前提。资产中心、正式产品、正式 SKU、基础物料、库位和规划模板等核心 `findByCode / findBySkuCode / findByModelNumber / codeExists` 查询会先做 `trim + uppercase`;资产 CAD 导入也会在查找已有资产族、创建规格款和生成模型存储路径前归一手填编码,避免同一编码因大小写或首尾空格在服务层表现不一致。
- 正式 SKU 资产同步副本的身份字段必须在快照构建层二次规范化:`ProductSkuAssetProfile.model / specCode / barcode` 以及 `sourceVariants` 中对应字段进入结构化副本前统一 `trim + uppercase`,人工锁定字段也不能绕过数据库 `CHECK` 护栏。
- `PackagedItem.code` 由正式 `ProductSKU.skuCode` 和包装箱编号生成;正式入库前必须校验包装箱编号是唯一正整数,且包装箱尺寸必须大于 0、预估重量不能为负数,避免用重复箱号、小数箱号或脏箱规生成冲突、不稳定的包装成品和轻仓库数据。
- `SpuNumberCounter.productCategory / prefix` 是正式 SPU 号段身份,不只要求非空和大写,还必须数据库级唯一;产品品类还要按去空格和大小写归一后唯一。不同产品品类不能共用同一个前缀,同一产品品类也不能因为空格差异形成两个号池,否则并发占号会变成不可追溯的双计数器。
- 正式入库前会校验 `PreSku.variantSelection` 引用的 `AssetVariant` 仍存在、未删除、状态可用、资产族和资产类型未漂移;正式生成板件、孔位和五金时读取当前 `AssetVariant` 结构化数据,不再信任旧快照里的尺寸和孔位。
- 已把非集成产品头资料结构化到 `IntakeNonIntegratedProductProfile`,外部 `intake_products_v1` 改读 profile,不再从 `payload` 直接取产品头主数据;负责人兼容列也必须优先读 profile 中的结构设计、商品企划、采购负责人,旧产品列只允许作为兼容兜底。
- 外部 `intake_sku_items_v1` 已改读 `IntakeNonIntegratedSkuItem`,不再从 `payload.skuItems` 拆 SKU 明细;`payload` 只作为历史过程快照和对账证据。
- 外部 `intake_products_v1 / intake_sku_items_v1` 已追加稳定业务键,ready 视图还会要求业务键非空;巡检会阻断 ready 视图中业务键缺失或重复,防止外部系统误用内部技术 ID。
- 聚水潭存量商品同步链路也必须同步写入 `IntakeNonIntegratedProductProfile` 和 `IntakeNonIntegratedSkuItem`,不能绕过保存链路只写 `payload`。来源 SPU、来源 SKU/条码和规格码在同步入口统一 `trim + uppercase` 后再进入 profile、payload、reviewSheet 和结构化 SKU 明细,不能让外部 MySQL 的大小写差异变成内部第二套身份。同步脚本默认只能 dry-run;真实写入必须同时传入 `--apply`、`--confirm=sync-jst-sku-stock`、`--expected-database-identity` 和 `--expected-source-database-identity`,把目标 PostgreSQL 和来源 MySQL 都钉死,避免错库同步。
- 存量商品列表筛选、分类下拉、页面返回、同步查找、软删除排除判断都必须优先使用 `IntakeNonIntegratedProductProfile.source*` 或系统生成的 `jst-stock-*` 业务 ID;`payload.metadata` 只作为历史记录兼容兜底和展示快照,不能参与来源身份判断。
- 非集成列表筛选、负责人搜索和权限判定已收紧为优先使用 `IntakeNonIntegratedProductProfile`;旧 `payload` 人员/开发类型字段只有在结构化 profile 缺失的历史记录上才允许兜底,不能和 profile 并列参与主判断。
- 审批流产品标题、主图和关键词检索已改为优先读取 `IntakeNonIntegratedProductProfile`;`payload.openingReference` 只作为旧数据兼容兜底。
- 规划域已补齐写入口和数据库双护栏:模板编码统一 `trim + uppercase`,模板槽位 key 去重且 attach 父槽位必须先定义;工作台名称、可见范围、槽位选择和元数据在服务层规范化,数据库进一步兜住模板/工作台/草稿的 JSON 形态,明确它们是过程配置和快照,不是正式产品资料真相。
- 非集成产品列表、分页列表和详情返回时,先用 `payload` 还原完整工作区对象,再用结构化 `IntakeNonIntegratedProductProfile` 和 `IntakeNonIntegratedSkuItem` 覆盖产品头与 SKU 主字段;未结构化的上传、BOM、流程确认等过程字段继续保留为工作区草稿。`profile` 一旦存在,产品头主字段和存量商品来源字段以 profile 为准,profile 空值会真实暴露为缺资料,不再被旧 `payload/openingReference/metadata` 自动补齐。
- 专利台账已新增 `ProductPatentRecord` 结构化表、服务和 API;前端保存时主写数据库,浏览器缓存主路径已移除,仅保留当前会话内存态作为临时展示/测试态;保存前会阻断缺少来源产品、分组键或专利身份的记录,并把草稿态临时随机 id 收敛为 `product-patent-group-${patentGroupKey}` 稳定 id,数据库也会用唯一索引和 check 约束兜底。
- 普通 SKU 写入链已补齐结构化名称:显式 `name` 优先,其次 `attributes.skuName`,最后回退 `skuCode`;编辑时若只改编码则保留原名称,若更新 `attributes.skuName` 则同步结构化 `name`。
- 不允许靠页面显示“看起来有值”来当数据库已经稳了。
- 创建/更新非集成产品时,后端负责同步 `IntakeNonIntegratedSkuItem`;更新时使用显式事务先删除旧 SKU 明细,再写入新 SKU 明细,避免同一 SKU 编码更新时撞唯一约束。
- 软删除非集成产品时清理结构化 SKU 明细;后端 repository 和数据库触发器双重兜底,避免已删除业务记录继续占用 SKU/条码唯一约束。恢复软删除产品时由保存链路重新生成结构化 SKU 明细。
- 巡检固定覆盖:结构化表重复 SKU、重复条码、`INCOMPLETE` 数量、`payload.skuItems` 与 `IntakeNonIntegratedSkuItem` 的同步差异、非集成产品 profile 缺失、关键字段缺失、profile 与 `payload/openingReference` 的同步差异、存量商品 `profile.source*` 与 `payload.metadata.source*` 的同步差异,以及正式 SKU profile 缺失、profile 与兼容快照差异、来源 ID 不一致。结构化表和兼容快照一旦出现漂移,普通巡检也会失败;这类差异不能再只作为“报告里看看”的提示项。
- 正式 SKU 巡检还覆盖 `ProductSkuAssetProfile.barcode` 的活动资料重复风险;这类风险需要先处理软删除复用和资产同步状态,再决定是否提升为数据库唯一约束。
- 巡检不能只输出数量;`INCOMPLETE` SKU、非集成产品头缺字段、准 SKU 正式化生命周期错位都必须输出最多 20 条 sample,包含产品 ID、SPU、SKU item、草稿组编号、准 SKU 序号、当前值、缺失字段或错位原因,作为后续人工确认、清洗脚本或正式化关系修复服务的入口。readiness 的 hard blocker 必须同步携带可定位证据:历史质量债带样本,待落库迁移带路径摘要,避免验收报告只剩数量而无法施工。
- 历史资料清洗必须走显式计划:`npm run db:cleanup-master-data -- --template` 输出当前待清洗模板;填写业务确认后的真实值后,用 `npm run db:cleanup-master-data -- --plan <file>` 先 dry-run 回滚;只有计划文件包含确认字段、业务确认人 `confirmedBy`,并且真实落库命令带 `--apply --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`,才允许真正落库。清洗计划里的空字符串不会被当作有效补充值,`productDevelopmentKind` 只能填写 `product / series`;落库时会把 `confirmedBy` 写回产品记录 `updatedBy`,让历史清洗可追溯。
- 清洗脚本对 `IntakeNonIntegratedSkuItem.skuCode / barcode` 的处理必须和数据库唯一池同口径:写入前先 `trim + uppercase`,冲突检测使用 `upper(btrim(...))`,不能让小写或首尾空格编码靠脚本层漏过,再依赖数据库报错兜底。
- 清洗脚本会同时更新结构化表和对应 `payload` 快照,避免清洗后 `payloadSyncDiffCount` 反而变脏;脚本在 dry-run 和真正 `--apply` 的提交前都会复用主数据巡检漂移断言,确认结构化字段与兼容快照没有分叉。清洗模板、dry-run 和 `--apply` 默认都要求治理迁移已经全部真实落库,结构迁移、视图迁移和护栏迁移任何一类缺失都会拒绝继续;只有本地调试模板或 dry-run 才允许显式追加 `--allow-migration-dry-run`。计划文件只能填业务确认后的真实资料,`UNKNOWN / 待补齐 / 默认值 / 默认值-规格` 这类占位值或默认值会被拒绝,不能为了让巡检变绿而制造假主数据。清洗报告和 strict 巡检报告共用 `migrationApplicationStatus` 与只读迁移状态;真正落库清洗不得和任何治理迁移混在一次提交里。
- 清洗计划在连库 dry-run 前还有一层静态校验:`npm run db:cleanup-master-data:validate -- --plan <file>` 不读取数据库,只确认计划文件具备 apply 所需的确认字段、业务确认人、模板 evidence、真实填充值、字段白名单和补齐完整度。这一层不能替代数据库冲突检查,但可以先阻断格式错误、伪资料和明显覆盖已有主数据的计划。
- 清洗计划必须是新鲜模板:脚本会比对计划里的 `current` 值和当前数据库值,发现字段已变化会要求重新导出模板;默认只能补齐当前仍为空的必填主数据,不能覆盖已有值,不能只补可选字段。`--apply` 下被处理的 SKU 或产品头默认必须补到完整,确实需要分阶段清洗时,单条计划必须显式写 `allowIncomplete=true` 和 `incompleteReason`,并落库到 `MasterDataQualityDecision`;这只提供审计追溯,不会让 readiness 通过。
- 剩余数据质量状态只按结构化表判断;页面显示和 payload 快照只能作为排查证据,不能替代结构化状态。
- 产品定厂合同生成属于正式流转资料,已接入同一套非集成产品头和 SKU 完整性规则;产品名称、品类、开发类型或目标批次 SKU 主数据未补齐时,后端直接拒绝生成,不能靠人工下载后再发现问题。
- 已新增代码层主数据边界巡检:`scripts/verify-master-data-code-boundaries.js` 会阻断新增的危险读取点,例如准 SKU 正式字段重新从 `displaySummary / familySelection` 主读、正式 SKU 名称重新从 `attributes.skuName` 主读、草稿基础资料冲突检测重新调用 `findByDisplaySummaryField(s)`、或 `assetSkuSnapshot / variantSelectionSummary / bomPreview` 在未批准位置被当作正式来源使用。
- 代码层主数据边界巡检也会阻断上传链从 `skuAttributes.skuName` 反写结构化 SKU 名称,防止旧模型上传入口重新把 JSON 属性升级成正式主数据。
- 正式 SKU 软删除编码释放已有单测覆盖:恢复原始编码、已有墓碑标记不重复追加、结构化保存 `originalSkuCode`、清理旧 attributes 生命周期元数据、不处理原始编码不匹配记录,防止删除态编码池继续累积不可读脏值。
- 产品级联删除 SKU 会写入结构化 `deletedByProductId / productDeletedAt`,产品恢复时只恢复本次产品删除带下去的 SKU;旧 `attributes._deletedByProductId / _productDeletedAt` 只作为历史清理对象,不再参与恢复归属判断,用户原本单独删除的 SKU 不会被产品恢复误激活。
- 旧产品页仍需读取旧 CAD 兼容载荷时,只能通过 `sku-cad-data` 中的显式兼容 helper 获取 `_skuCadData / _cadExcludedHoles / _cadExcludedHolesCount`;页面层和正式产品主链不得再手写拆解 `ProductSKU.attributes`。
- 正式入库挂接已有 SPU 时必须阻断已软删除 Product;正式入库和旧 CAD Product 导入创建 SKU 前会在同一事务释放遗留删除态 SKU 编码,传统产品上传链路也会在创建前释放删除态编码,避免旧删除态记录卡住结构化规格码。
- SKU 墓碑编码构造、原始编码恢复和事务内释放统一走 `sku-soft-delete.helper`,不允许 Product repository 或其他兼容入口再维护第二套后缀规则。
- SKU 墓碑编码恢复和释放必须和活动编码池同口径:恢复出的原始 SKU 编码统一 `trim + uppercase`,事务内释放删除态记录按大小写不敏感匹配,产品级联恢复和单 SKU 恢复的条码冲突检查也统一使用规范化条码。
- 同一个巡检脚本已把 `prisma/schema.prisma` 中所有 `Json` 字段纳入分类清单;当前 80 个 JSON 字段全部被归类为结构化 JSON、兼容快照、过程快照、几何数据、审计日志、集成缓存、UI 配置或系统配置。后续新增 JSON 字段如果不补分类,或分类清单残留 schema 已删除/改名的字段,`npm run db:verify-master-data:code` 会失败,避免新的 JSON 主数据暗坑进入系统,也避免边界台账和真实 schema 分叉。
- 数据地基巡检现在分三层:代码层先跑 `npm run db:verify-master-data:code`,只读迁移状态再跑 `npm run db:verify-master-data:migration-status`,数据库层最后跑 `npm run db:verify-master-data`;完整检查入口 `npm run db:verify-master-data:all` 已把只读迁移状态作为硬前置,不再允许把事务内 dry-run 回滚结果误读成真实 ready。代码层检查不连接数据库,专门防止后续开发把已经收口的真相源边界重新打散。部署后和生产验收使用 `npm run db:verify-master-data:strict` 或 `npm run db:verify-master-data:all:strict`,要求结构迁移、幂等视图迁移和护栏迁移都已真实落库;如只需确认迁移目标是否真实存在,先跑 `npm run db:verify-master-data:migration-status`,该入口不执行迁移 SQL。
- `npm run prisma:push` 已降级为本地开发保护入口,由 `scripts/prisma-db-push-local.js` 先检查目标库:production 环境标记、缺失/非法 `DATABASE_URL`、非本机 host、疑似生产库名都会被拒绝。生产和准生产只允许 `npx prisma migrate deploy + npm run db:verify-master-data:all:strict`,不能再用 `db push` 绕过迁移历史。
- 代码层巡检会阻断 `src/app` Route/Page 直接写产品资料、资产中心、草稿组、非集成主数据、配置、权限、反馈、审计和 AI 生图地基表;这些写入必须通过 service/repository,不能绕开统一的规范化、质量状态、快照同步和审计边界;资料库条目身份禁止用数据库 `id`、前端临时 `id`、`itemKey: item.id`、`code || item.id` 或 `` `item:${item.id}` `` 兜底,有稳定编码的资料必须用 `code` 作为身份,缺显式编码的资料必须用业务规格派生稳定 key;草稿组编号禁止只用 `Date.now()` 生成,必须由服务层生成可读且带足够熵的稳定编号。
- 代码层巡检还会阻断核心编码查询回退:`code / skuCode / modelNumber / barcode` 这类身份字段不能用 Prisma shorthand 或同名变量直接查询,必须显式归一或使用已经归一后的变量,保证服务层、仓储层和数据库表达式唯一索引语义一致。
- 代码层巡检还会扫描 `scripts/` 和 `prisma/seed.ts` 的主数据直写。已知 demo/test/维护脚本必须明确分类;未分类脚本直写产品资料、资产中心、基础资料、旧 CAD 结构、仓库、人员组织、配置、审批、分享、反馈或审计等地基表会直接失败,生产修复和正式历史清洗必须走迁移、专用服务或 `scripts/apply-master-data-cleanup-plan.js` 这种显式计划。
- 代码层巡检还会扫描应用代码和脚本中的原生 SQL 主数据写入。只要出现 `INSERT INTO / UPDATE / DELETE FROM` 写核心表,就必须被分类为显式清洗计划、事务内计数器或其他受控入口,并且必须同时匹配批准文件和批准表名;不能因为没有走 `prisma.model.create/update` 就绕过写入口巡检,也不能在已批准文件里顺手写职责外核心表。
- 代码层巡检还会扫描应用代码和脚本中的原生 SQL 主数据读取。只要出现 `FROM / JOIN` 读取核心表,也必须被分类为内部巡检、显式清洗、schema readiness、事务内计数器读取或其他受控入口,并且必须同时匹配批准文件和批准表名;不能因为只是读取就绕过 Repository、结构化 helper 和真相源边界。
- 代码层巡检还会阻断运行时 DDL 和 `$executeRawUnsafe`。应用代码和普通脚本不能在请求或服务调用过程中 `CREATE TABLE / CREATE INDEX / ALTER TABLE / DROP / TRUNCATE`,数据库结构真相必须来自 Prisma migration、治理迁移清单和 strict 巡检;服务层只能做只读 readiness 检查并暴露迁移未完成错误。
- 只读 SQL 巡检不能成为第二套手工清单。`check-internal-master-data-foundation.sql` 的索引、约束和触发器护栏必须由测试反向对齐 `REQUIRED_DATABASE_GUARDS`:索引至少同步名称、relation、predicate hint 和 definition hint;约束至少同步名称、relation 和 expression hint;触发器至少同步名称和 relation。`scripts/sql` 下的 check/preflight 脚本还必须保持只读,授权脚本只允许外部 schema 的 SELECT/USAGE 授权。这样 SQL 巡检用于排查时不会比 JS 主巡检弱一层。
- 代码层巡检还会扫描 `src/server/services` 和 `src/server/repositories` 的主数据直写。仓储层、服务事务编排、外部同步、系统初始化、旧 CAD 兼容链维护等写入口必须逐个分类;未分类 service/repository 直接写地基表会失败。已分类文件也必须匹配批准模型清单,例如仓库绑定文件只能写库位/绑定关系,正式入库服务只能写正式入库链路相关模型,防止同一文件后续绕过统一规范化、结构化副本同步、软删除释放、权限和审计边界。巡检会识别 Prisma client 别名和 relation 嵌套写入,`client = tx ?? this.prisma`、`basicInfo/profile/skuItems/assetProfile/preSkuPackagingPlan` 这类写法也必须落入同一套分类和模型作用域;真相源矩阵里的 `writeBoundary` 也会反查这份批准清单,矩阵和代码边界不能再分叉。
- 代码层巡检还会反向检查所有文件级边界分类:脚本写入口批准表、原生 SQL 写入口批准表、原生 SQL 表作用域表、服务/仓储写入口批准表、模型作用域表、兼容快照读点分类表、结构化 payload 跳过表和 UI 缓存忽略表里的文件路径都必须真实存在。重构、拆文件或删除旧入口后,分类表也必须同步清理,不能留下“批准了一个不存在入口”的假安全感。
- 代码层巡检提供 `--mode compatibility-snapshot-read` 只读盘点模式,用来持续列出 `ProductSKU.attributes`、`PreSku.displaySummary/familySelection` 和 `IntakeNonIntegratedProduct.payload` 的直接读点。该模式会给每个读点标记用途分类和迁移优先级:合理过程快照、旧链展示、DTO 兼容输出、生命周期旧数据清理入口可以暂时保留;资料库 `IntakeReferenceLibraryItem.payload` 属于结构化扩展字段,已从兼容快照风险里拆出;正式 SKU 软删除生命周期身份已迁入结构化列,不能再由 attributes 主导;从旧快照推导 `brand / spuCode / seriesName` 或继续编辑正式 SKU attributes 的高优先级读点必须排入结构化承接。当前基线为 42 处,高优先级 0 处,未分类 0 处,未使用分类 0 处;非集成产品列表、分页、详情、附件和预览读取已统一走结构化覆盖入口,避免散落 `payload` hydrate 重新扩成第二真相。分类表只能保留当前真实命中的读点或结构化 payload 跳过项,已经结构化清零的旧风险必须从分类表删除。
- 代码层巡检新增 `--mode process-snapshot-read` 只读盘点模式,用来持续列出 `PreSku.variantSelection / bomPreview / installStepPreview / packagingSnapshot` 的直接读点。当前基线为 35 处,高优先级 0 处,未分类 0 处,未使用分类 0 处;允许用途只覆盖辅助装配恢复、草稿组只读物料/BOM/安装预览、产品资料只读物料视图、旧产品装配恢复、包装输入和正式入库物料生成。巡检会阻断在过程快照读点附近读取 `barcode / skuCode / formalSkuCode / category / ownerId / businessType` 等正式或权限字段,防止过程快照重新承担主数据真相。
- 代码层巡检新增 `--mode filesystem-master-data-source` 盘点模式,用来识别“文件系统读取 + Product/SKU/Panel/Hardware 等主数据写入”的组合。允许的旧上传/导入入口必须集中登记为 `legacy/import`,未登记入口一律视为反向造数风险;禁止以后通过扫描 `public/uploads`、临时文件目录或本地 JSON 来补主数据。
- 数据库物理护栏已纳入 `scripts/verify-master-data-foundation.js`:固定检查资产中心系列/族/规格款编码唯一池、规格款身份键、活动条码唯一池、系列兼容关系去重、草稿组编号、草稿组身份和摘要形态、准 SKU 序号、准 SKU 身份、过程快照形态、组件编码数组、预估重量、正式产品型号、正式产品名称/可见范围、正式 SKU 编码、活动正式 SKU 编码不得带软删除墓碑标记、正式 SKU 售价/成本/库存非负、正式 SKU 软删除生命周期结构化身份、正式 SKU 删除态墓碑资料完整性、正式 SKU attributes 不得保存软删除生命周期元数据或正式资料字段、正式 SKU 删除态同步到资产 profile 的触发器、产品协作者身份/角色、分享链接 token/访问次数/过期时间、准 SKU 到正式 SKU 一次性入库关系、非集成 SPU 品牌池唯一、`ProductSkuAssetProfile / PreSkuBasicInfo / IntakeNonIntegratedProductProfile` 的一对一唯一索引、正式 SKU 资产资料同步副本活动条码唯一池、正式 SKU 资产资料副本来源规格款身份约束、准 SKU 非空规格码/条码唯一索引、准 SKU 型号/版本号/属性码/图纸号规范化、来源枚举和空字符串禁用、资产 SKU 同步事件原因枚举/计数/完成时间约束、非集成 SKU 明细的身份唯一索引、非空 `skuCode / barcode` 局部唯一索引、主数据质量决策开放目标唯一和原因/确认人/解决状态约束、专利台账分组唯一池和专利关键身份约束、资料库条目身份/编码唯一池、资料库编码非空、资料库 itemKey 归一唯一和类型枚举约束、SPU 规则产品品类唯一池、SPU 规则产品品类归一唯一池、SPU 号段产品品类归一唯一池、SPU 号段前缀唯一池、人员邮箱/花名/业务类型、组织部门、员工目录身份、准 SKU 包装方案 JSON 形态和状态约束、活动库位物理位置唯一、品牌物料准备项规范化唯一和文件元数据一致性、包装成品状态/数量/尺寸约束、审批流编号/标题/访问密钥/参与人/业务角色/字段矩阵和字段变更审计约束、旧 CAD 兼容链板件/孔位/产品五金/装配步骤/装配版本结构约束、基础资料分类/基材选项规范化唯一池、五金匹配记忆、基材花色绑定、板件材质绑定、规格款/准 SKU 库位绑定、旧正式产品包装方案质量约束、系统配置 key、AI 服务商配置唯一激活、主题默认配置、产品资料节点权限覆盖、辅助装配 AI 生图记录、反馈和操作日志约束、软删除非集成产品时释放 SKU 唯一池的触发器、产品开发类型枚举约束、SKU 缺失字段枚举约束、SKU 质量状态一致性约束、核心编码/条码规范化 `CHECK`、`ProductSkuAssetProfile.model / specCode / barcode` 规范化 `CHECK`,以及 `SpuNumberCounter` 的品类非空、品类无首尾空格、前缀非空、计数非负约束;这些护栏缺失或失效时巡检必须失败,不能只靠“当前没有重复数据”判断安全。当前护栏基线为 290 项。
- SQL 版只读巡检 `scripts/sql/check-internal-master-data-foundation.sql` 也同步列出核心护栏,用于人工查库或外部审计时发现缺失索引、缺失约束或缺失触发器;脚本版护栏基线当前为 290 项。
- 专利台账巡检还会检查 `ProductPatentRecord.id` 是否残留草稿态临时随机 id;保存链路会先收敛成专利分组稳定 id,数据库巡检负责发现旁路写入或旧清洗遗漏。
- 资料库型前端本地存储已进入代码层 advisory,并按 `server-contract-required / server-backed-cache / legacy-cache / compatibility-cache` 分层治理到 0:当前 `server-contract-required` 为 0,`server-backed-cache` 为 0,`legacy-cache` 为 0,`compatibility-cache` 为 0。通用字母库组件、配件、封边、钢架资料库页面,以及非集成 BOM 配件/封边/包装材料/钢架匹配,均已改为读取 `IntakeReferenceLibraryItem`,不再从浏览器本地缓存主读。SPU 规则维护页已去掉本地缓存主路径,维护入口只写服务端资料库;SPU 预计展示只使用服务端同步后的运行时缓存或内置种子规则,不再读写浏览器缓存。内置 SPU seed 只允许在服务端完全没有 `SPU_RULE` 行时作为空库展示兜底;一旦服务端存在资料行,即便资料行无效或被过滤为空,也不得混入 seed 继续生成预计 SPU,避免默认数据成为第二套品类/号段真相。正式 SPU 占号必须走 `SpuNumberCounter`,不能再走本机缓存。专利台账已去掉浏览器本地缓存主路径,生产真相固定为 `ProductPatentRecord`。非集成旧入口也已从浏览器持久缓存收口到当前进程内运行时缓存,只服务测试或显式兼容场景,不能定义为长期多人协作或外部依赖的数据真相。
- 代码层巡检还会阻断未归类的本地存储用途:新增 `localStorage/sessionStorage` 直接读写或 `createJSONStorage(() => localStorage/sessionStorage)` 这类封装持久化时,必须明确是主数据风险、服务端真相的兼容缓存、旧缓存、测试兼容,或非主数据 UI/登录态缓存;未归类就不能通过 `db:verify-master-data:code`。
- 代码层巡检还会阻断应用层本地 JSON 文件主数据读写:`src` 下不能用 `readFileSync/writeFileSync` 把本地 `.json` 文件当资料库或产品资料真相;正式资料必须进入数据库或明确服务端契约,文件 JSON 只允许是导入载荷、迁移计划、备份、测试夹具或临时工具输入。
- 代码层巡检必须带防回退测试,不只看当前大仓样本是否为 0。当前 `verify-master-data-code-boundaries` 测试会用隔离 fixture 验证 Route/Page 直写、未分类本地存储、脚本直写、服务写入口、未分类原生 SQL 写入、已批准原生 SQL 文件越权写表、未分类原生 SQL 读取、已批准原生 SQL 文件越权读表、运行时 DDL 与 `$executeRawUnsafe`、兼容快照读点、未分类 JSON 字段、过期 JSON 字段分类和过期文件级边界分类都能被抓住;真相源测试还会验证 JSON 兼容快照分类漏进矩阵时必须失败;新增规则或重构脚本时必须保持这些 fixture 继续有效。
- 资料库 `IntakeReferenceLibraryItem.payload` 只允许作为扩展展示载荷,不能重新承担身份字段。服务端保存会用结构化 `itemKey / code / name / letter` 覆盖 payload 镜像字段,客户端字母库、配件、包装材料、钢架和 SPU 规则解码也固定读取 DTO 结构化列;代码层巡检会阻断 `readText(...)` 与 `readPayloadText(...)` 从 `payload/source/rulePayload` 读取 `id/code/name/letter/categoryCode/productCategory` 等身份字段的写法。
- 代码层巡检已继续收紧非集成产品查询边界:`intake-non-integrated-product.service.ts` 中直接读取 `payload` JSON 的筛选 helper 必须包在 `payloadJsonWhenProfileMissing(...)` 中,避免开发类型、负责人或待办查询重新和结构化 profile 并列读旧快照。
- 已新增 `IntakeReferenceLibraryItem` 和 `/api/intake/reference-library` 作为资料库型本地存储迁库底座;服务端保存会校验同一资料库内 `itemKey / code` 唯一,并由数据库唯一索引兜底,数据库还会按 `lower(btrim(itemKey))` 锁住语义重复键、限制资料库类型枚举,并要求 `itemKey` 非空无首尾空格。资料身份只允许来自显式 `itemKey` 或稳定 `code`,`id` 仅作为数据库行主键,不允许成为业务身份兜底。`SPU_RULE` 额外要求产品品类 `name` 按去空格和大小写归一后一对一,保存时会用结构化 `code / name` 规范化 payload 中的旧 `categoryCode / productCategory`,避免 JSON 快照反过来覆盖结构化真相。后续 UI 必须分批接入这个服务端契约,而不是继续扩展新的 `localStorage` 资料库;已接入的包装材料、配件、封边、钢架和 SPU 规则库在服务端保存失败时不能把本地缓存伪装成保存成功,其中通用字母库、配件、封边、钢架和 SPU 规则库已进一步移除本地缓存读写主路径。
- 已新增 `SpuNumberCounter` 作为正式 SPU 号段计数器;`SPU_RULE` 资料库只描述品类和前缀规则,不能继续承担多人并发正式占号真相。计数器首次初始化只允许读取 `IntakeReferenceLibraryItem.code` 作为前缀,payload 中的 `categoryCode` 仅是兼容展示副本,不能参与正式占号。新建非集成产品、编辑旧草稿首次生成正式 SPU 时,正式 SPU 分配必须和产品创建/更新、结构化 SKU 明细写入处在同一个后端保存事务里;`/api/intake/spu-reservations` 只能作为后端兼容入口,不允许作为新建或旧草稿编辑的前置占号步骤,前端已移除 `reserveIntakeSpuNumber` 调用入口,预计 SPU 展示不再读取浏览器缓存,也不能在服务端已有 `SPU_RULE` 行时回退内置 seed。
- `/api/intake/spu-reservations` 不再执行单独占号,直接返回阻断错误;保留路由只是为了给旧调用明确失败信息。`spuNumberReservationService` 已移除独立事务包装方法,只保留 `reserveInTransaction`,正式 SPU 分配必须从非集成产品保存链路进入同一个数据库事务。
- 正式 SPU 分配入口必须复用非集成产品主数据质量规则:产品头和 SKU 明细未补齐时不能落入正式号段;后端可以按规则生成 SKU 编码和条码建议值,但不能让缺型号、缺规格名等不完整 SKU 进入正式 SPU 分配结果。
- 外部契约除完整对账视图外,新增 `external.intake_products_ready_v1 / external.intake_sku_items_ready_v1`。外部稳定消费优先读 ready 视图;完整视图保留 `INCOMPLETE` 状态用于治理和对账,不能被外部系统当成可自动入库数据。
- 内部主数据巡检会把外部核心视图缺失、外部视图回读内部 JSON、负责人兼容列未优先读取结构化 profile、ready SKU 视图混入非 READY 明细、字段字典承诺列缺失、字段字典外多余列、稳定业务键缺失/重复,以及业务编码字段使用内部 `id` 兜底作为硬失败;外部只读契约不再只是独立 SQL 文档。字段字典硬检查复用 `scripts/sql/check-external-readonly-contract.sql` 里的 `expected_columns`,当前基线为 9 个 v1 视图、224 个承诺列。稳定业务键巡检直接检查外部系统实际读取的视图结果,当前覆盖 `materials_v1.material_code`、`asset_variants_v1.spec_code`、`product_skus_v1.sku_business_key / sku_code`、ready 视图业务键、`product_assets_v1` 素材 URL 以及 `product_bom_v1.item_code / item_name / panel material_code`。内部 `id` 兜底扫描不再只抓 `COALESCE(... id ...)`,还会检查直接 `id AS *_code`、`CASE ... id ... AS *_business_key` 等派生形态。
## 4. 历史清洗策略
1. 首次迁移从 `IntakeNonIntegratedProduct.payload.skuItems` 回填 `IntakeNonIntegratedSkuItem`。
2. 历史 SKU 明细优先保留原 `id` 作为 `skuItemId`;同一产品下缺失或重复 `id` 时按顺序生成稳定兜底 ID,避免迁移时丢行。
3. `skuCode / specName / model` 作为当前必填质量基线,缺任一项标记为 `INCOMPLETE` 并写入 `missingRequiredFields`。
4. `skuCode / barcode` 为空时允许先进入结构化表,作为待补资料;非空时必须接受唯一性约束和巡检。
5. 迁移前必须先运行 `scripts/sql/preflight-intake-non-integrated-sku-items.sql` 检查历史 `payload.skuItems` 里的非空 `skuCode / barcode` 重复;如果存在重复,先按业务归属清洗或改码,不允许迁移时静默选择一条。
6. 迁移本身也会做重复预检;发现重复时直接中止,并提示先运行预检 SQL。
7. 清洗顺序先处理迁移前 JSON 重复,再处理结构化表里的重复 SKU/条码,再补齐 `INCOMPLETE`,最后消除 payload 与结构化表同步差异。
8. 清洗完成前,外部只读视图和后续自动流转都必须识别 `dataQualityStatus`,不能把 `INCOMPLETE` 当作完整主数据。
9. 分阶段清洗允许记录 `MasterDataQualityDecision`,但它只是审计和追责线索;最终达到 hard-ready 仍要求 `INCOMPLETE` 归零。
9. 正式 SKU 首次迁移从 `ProductSKU.attributes.assetSkuSnapshot` 回填 `ProductSkuAssetProfile`;迁移前先运行 `scripts/sql/preflight-product-sku-asset-profile.sql` 暴露缺失快照、来源 ID 不一致和条码重复风险。
10. `assetSkuSnapshot` 兼容快照保留一段时间,但读取优先级已降级为巡检、迁移和历史排查证据;同步合并、条码冲突检测和后续新增外部字段必须从 `ProductSkuAssetProfile` 或正式结构化表取得,不允许常规业务再回读 `assetSkuSnapshot`。
11. 普通 SKU 属性编辑必须保留 `_` 前缀内部字段和 `assetSkuSnapshot` 兼容快照,避免旧接口把同步副本证据链清掉。
12. 非集成产品头首次迁移从 `payload` 和 `openingReference` 回填 `IntakeNonIntegratedProductProfile`;保存链路双写 profile,外部产品头视图只读 profile。
13. SPU 号段计数器首次迁移从 `SPU_RULE` 资料库规则和现有非集成产品 `spuBase` 的最大号回填;后续以 `SpuNumberCounter.lastUsedIndex` 为正式占号真相。新建产品不能先在前端占号再另行保存产品,避免保存失败后留下不可追溯的号段空洞。
14. `IntakeNonIntegratedProductProfile` 与 `payload/openingReference` 的差异必须进入巡检;差异不代表允许重新把 payload 当真相,而是提示双写链路、迁移回填或历史清洗有漂移。
15. 聚水潭同步、批量导入、脚本补数这类旁路写入必须有测试覆盖,确认 create/update/soft delete 都同步结构化 profile 和 SKU 明细;其中来源 SPU、来源 SKU/条码和规格码必须按内部编码池语义规范化,不能把外部源库原始大小写直接落成主数据身份。聚水潭真实同步还必须通过脚本安全门禁校验目标库身份和来源库身份,不能只凭 `--apply` 写入。
16. 专利台账从浏览器缓存迁入 `ProductPatentRecord`;后续以数据库 API 的返回为准,浏览器缓存不再作为迁移、测试或失败兜底真相。
17. 存量商品来源字段首次迁移从 `payload.metadata.source*` 回填到 `IntakeNonIntegratedProductProfile`;后续筛选、分类和同步对账都以 profile 为第一真相源。
18. 准 SKU 基础资料主读和冲突检测固定为 `PreSkuBasicInfo`;`displaySummary / familySelection` 只能作为旧页面展示兼容兜底或旧字段清理对象,不能参与冲突检测、正式入库主写、自动建议或后端业务判断。编码类字段必须先大写规范化,再做冲突检测和落库。
19. 正式 SKU DTO 读取顺序固定为结构化字段优先;兼容 JSON 只能以 `legacyAttributes` 名义输出或作为内部证据链保留,不能再命名为主数据字段。删除态 SKU 回收站合同同样遵守这个规则,只能输出过滤后的展示属性,不能泄露 `assetSkuSnapshot / _skuCadData / _originalSkuCode` 等内部证据。
20. 正式入库不得为规避冲突自动改写 `ProductSKU.skuCode`,也不得让接口临时参数覆盖 `PreSkuBasicInfo`;结构化规格码冲突必须作为阻断错误暴露,并回到草稿组基础资料层处理。
21. `ProductSKU.attributes.variantSelectionSummary / bomPreview` 不再参与正式入库重复阻断;需要组合对比时只能读取正式 SKU 关联的 `sourcePreSku`,缺少 `sourcePreSku` 的历史 SKU 由巡检或后续结构化签名迁移处理。
22. 普通 `updateSKU` 不再开放 `attributes` 写入;旧属性袋维护必须走明确的兼容链或迁移/清洗入口,不能隐藏在常规编辑保存里。
22. 对于巡检 sample 中已经定位但无法从结构化字段或 payload 中确定性回填的资料,例如缺品类、缺开发类型、SKU 编码/型号/规格名全空,不允许用默认值静默填充;必须由业务确认、标记无效,或通过 `scripts/apply-master-data-cleanup-plan.js` 的显式计划清洗。
23. 任何新增正式流转入口如果使用非集成 SKU 明细,必须调用后端非集成 SKU 质量规则;禁止在前端、模板服务或导出脚本中重新手写必填字段判断。
24. 巡检必须同时确认数据库物理护栏存在,尤其是资产中心唯一池、草稿组/准 SKU 过程身份、正式产品/SKU 编码池、一对一 profile、准 SKU 基础资料、准 SKU 非空规格码/条码唯一池、非空编码/条码唯一池、软删除触发器、产品开发类型枚举和 SKU 质量状态一致性约束;如果迁移未应用或护栏被误删,先修复迁移/索引/触发器,再谈业务清洗。
25. 仍以 `localStorage` 保存的资料库型模块必须按巡检 category 处理:`server-contract-required` 迁入 `IntakeReferenceLibraryItem` 或更专用的服务端契约;`server-backed-cache`、`legacy-cache` 和 `compatibility-cache` 必须继续保持为 0。测试、旧数据迁移或失败兜底只能使用明确的运行时缓存或服务端兼容接口,不能重新引入浏览器持久缓存作为主数据来源。
26. 审批流只承载协作过程:创建流程和更新字段时必须先经过服务层规范化,数据库约束兜底阻断空编号、空标题、空参与人、重复业务角色、非数组字段矩阵和非对象摘要;任何正式产品资料仍要写回结构化产品资料链,不能从审批摘要直接变成第二套主数据。审批产品 DTO 存在结构化 profile 时不得再从旧 `payload.openingReference` 补产品名、图片或说明;旧 payload 只能服务没有 profile 的历史兼容展示。
27. 人员和组织主数据是权限地基:`User.email / name`、`Department.name / externalId`、`EmployeeDirectory.dingtalkUserId / dingtalkUnionId / jobNumber / mobile` 都必须有数据库语义唯一池;花名册同步和后台用户创建必须先规范化再保存,不能依赖页面或人工约定。
28. 规划域只承载开品前推演:`PlanningTemplate.slots/rules`、`PlanningWorkspace.slotSelections/metadata` 和 `PlanningDraft.selectedAssets/previewNodes/summary/sourceSnapshot` 只能作为过程配置或追溯快照;写入链和数据库约束必须阻断空名称、游离可见范围、非对象/非数组 JSON 和模板外槽位。
29. 协作者、分享和上传链路不允许成为正式产品资料第二真相:协作者只表达权限关系,分享链接只表达访问入口和统计,上传属性只保存文件过程元数据;产品名称、型号、可见范围、SKU 名称和资产资料仍必须落在结构化正式产品链路。
30. 旧 CAD 兼容链清洗不能猜测业务含义:如果历史 `Panel / Hole / ProductHardware / AssemblyStep / AssemblyVersion` 触发约束风险,先定位来源 CAD 或业务记录,再做显式清洗;不能为了让迁移通过随手改尺寸、孔位、步骤类型或版本快照。
31. 基础资料分类、基材选项、五金匹配记忆、库位绑定和旧包装方案清洗同样不能猜值:如果出现重复规范化名称、负排序、空绑定引用、五金映射缺匹配键或旧包装 boxes 非数组,必须先定位来源模块和业务含义,再通过显式清洗计划修复。
清洗计划的标准流转必须留痕:先导出模板,例如 `npm run db:cleanup-master-data -- --template --output .master-data-cleanup/master-data-cleanup-template.json`;如果当前库尚未完成治理迁移,只能在本地调试时追加 `--allow-migration-dry-run`,不能把 dry-run 迁移当成已落库。业务方只允许在模板的 `fill` 区填写确认后的真实值,补齐 `confirmation` 和 `confirmedBy` 后,先跑 `npm run db:cleanup-master-data:validate -- --plan <计划文件>` 做静态校验。真正 `--apply` 前仍然必须满足迁移 fully ready、备份预检通过、命令显式携带目标库身份和备份 SHA-256、用户明确授权。`.master-data-cleanup/` 是本地清洗计划目录,可能包含真实业务资料和确认人,不提交 Git。
## 5. 后续治理顺序
1. 继续保持真相源和过程层分开。
2. 把正式 SKU 资产资料读取口径稳定收口到 `ProductSkuAssetProfile`。
3. 把非集成开品写入链路稳定收口到结构化 SKU 表。
4. 把非集成产品头资料稳定收口到 `IntakeNonIntegratedProductProfile`,继续清洗缺品类、缺开发类型等历史资料。
5. 把合同、专利、仓储等读产品头资料的链路继续改成结构化表优先;审批流已完成过程记录护栏补强,产品 DTO 已在有 profile 时停止读取旧 payload 补产品资料,但审批摘要仍只作为协作过程数据,不能成为正式产品资料主读来源。
6. 持续核查人员/组织链路:花名册定时同步、后台创建账号、手动匹配员工和自动业务类型划归都必须复用同一套结构化员工目录和部门路径,不允许再从临时文本或页面缓存判断内部人员。
7. 用巡检基线持续压低重复、缺字段和同步差异。
8. 再扩展对外契约层字段,而不是让外部系统直接理解内部 payload。
9. 把新增主数据读取 helper、DTO、Repository 查询纳入代码层边界巡检,确保结构化字段优先不是一次性人工约定,而是可重复执行的工程护栏。
10. 把服务/仓储层新增写入口纳入代码层边界巡检,新增直写核心表时必须说明该文件是唯一写入口、事务编排入口、同步入口或兼容维护入口,不能让“临时服务”悄悄变成第二套主数据写链;父模型 `create/update` 里的 relation 嵌套写入也必须登记子模型作用域,不能用嵌套写绕过结构化副本治理。原生 SQL 读写核心表同样必须被分类到文件和表名双作用域,真相源矩阵声明写入边界时,必须同步进入代码边界批准清单。
11. 持续压低兼容快照直接读点:`attributes / payload / displaySummary / familySelection` 的专项盘点必须稳定输出分类、优先级、阻断风险计数和未使用分类计数;高优先级、未分类读点或已经不命中的旧分类不得继续留在主链里,必须先结构化、迁移、明确降级到受控兼容入口,或从分类表删除。
12. 持续压低兼容快照写入面:`node scripts/verify-master-data-code-boundaries.js --mode product-sku-attributes-write` 必须保持阻断风险为 0;新增 `ProductSKU.attributes` 字段键时,要么登记为兼容/过程键,要么改写到结构化字段。
13. 持续压实过程快照用途矩阵:`node scripts/verify-master-data-code-boundaries.js --mode process-snapshot-read` 必须保持高优先级、未分类和未使用分类都为 0;新增 `PreSku.variantSelection / bomPreview / installStepPreview / packagingSnapshot` 读点时,必须说明它是 3D 恢复、草稿只读展示、包装输入还是正式入库物料生成,不能从中反推正式编码、条码、品类、人员或权限。
14. 持续压实文件系统边界:`node scripts/verify-master-data-code-boundaries.js --mode filesystem-master-data-source` 必须保持未登记风险为 0;旧上传/导入入口只能处理本次请求的显式文件,不允许新增扫描 `public/uploads` 或任意本地目录来反向创建/修补主数据的脚本。
14. 兼容快照和过程快照读点还要受债务预算约束:当前兼容快照读点预算为 total 42、high 0、medium 32、unknown 0;过程快照读点预算为 total 35、high 0、medium 31、unknown 0。新增读点如果突破预算,必须优先结构化或删除旧读点;确需保留时要同步更新预算、分类原因和治理说明,不能让 JSON/快照读点无声增长。
15. 持续防回退资料库型 `localStorage` 和本地 JSON 文件:包装材料、配件、封边、钢架、SPU 规则库和通用字母库组件已接入 `/api/intake/reference-library`;通用字母库、配件、封边、钢架和 SPU 规则库已从本地缓存主路径收口到服务端读取,包装材料 BOM 匹配也改为服务端读取,非集成旧入口已改为运行时缓存。后续新增资料库必须默认走服务端契约,不属于主数据的在 UI 和文档中明确为本机草稿缓存,并纳入代码层巡检白名单说明。
16. 持续保持真相源矩阵反向覆盖:新增数据库物理护栏时,必须同步把对应 Prisma relation 纳入 `scripts/master-data-truth-sources.js` 的 `primaryRelations`,并说明写入边界、兼容快照和禁止真相源;`requiredGuardNames` 只能声明本条目主关系范围内的护栏,不能只加约束不登记业务归属,也不能把别的业务对象护栏错挂到当前条目。新增或改名 `compatibility-snapshot` JSON 字段时,也必须同步映射到矩阵的 `compatibilitySnapshots`。
17. 持续保持治理迁移目录可验证:新增主数据结构迁移、外部视图迁移或数据库护栏迁移时,必须进入 `scripts/master-data-required-migrations.js`,声明目标 relation 和是否幂等;迁移 SQL 必须能对应到声明目标,执行后必须能让目标 relation 或 column 被数据库查到,清单顺序必须按路径升序,避免 dry-run 和 strict 用两套口径。禁止把结构补齐逻辑放回业务服务的运行时 DDL。
18. 持续保持 Prisma JSON 字段分类正反向一致:新增 JSON 字段必须同步说明用途,删除或重命名 JSON 字段必须清理 `schemaJsonFieldClassifications`,让结构化 JSON、兼容快照、过程快照和审计/缓存边界始终跟真实 schema 对齐。
19. 持续保持文件级分类表和真实仓库一致:拆分、重命名或删除写入口、兼容读点、过程快照读点、UI 缓存文件时,必须同步清理对应批准表、原生 SQL 表作用域表、模型作用域表、分类表或跳过表,让代码巡检报告里的每个边界声明都能落到真实文件。
## 6. 规范审查
结论:`passed with warnings`
这是自审,不是独立复审。
- 架构:资产中心、草稿过程层、正式产品结果层、人员组织权限层的关键唯一池已纳入数据库物理护栏,`IntakeNonIntegratedSkuItem` 继续结构化承接非集成 SKU 主数据,没有混入正式产品主链。
- 视觉:本次不涉及前端视觉。
- 根因:把非集成 SKU 主数据、审批流程过程数据、人员组织身份数据从“靠入口写对”推进到服务层规范化、数据库约束和可巡检状态。
- 验证:当前代码层定向脚本测试和 `npm run type-check` 通过;`npm run db:verify-master-data:audit` 与 `npm run db:foundation:audit` 会按预期失败并输出 not-hard-ready,因为真实库尚未完成结构修复、baseline resolve、治理迁移真实落库和历史质量债清洗。当前数据库物理护栏基线推进为 290 项,真相源矩阵覆盖 27 个对象并反向覆盖数据库护栏 relation,且会反查写入边界是否进入代码层批准清单;治理迁移目录共 50 项且由脚本自检路径、顺序和目标 relation,兼容快照读点阻断风险为 0,外部字段字典多余列和业务编码 ID 兜底风险为 0,资产中心活动资产族缺系列、活动规格款缺模型/缩略图、活动规格款条码重复组、旧 CAD 结构风险、基础资料/映射/绑定/旧包装风险、配置/权限/审计过程数据风险均应保持为 0。本轮新增证据:克隆库直接 `migrate deploy` 复现 `P3005`;`rehearse-prisma-structure-repair.js` 已证明非破坏性结构修复后 current schema diff 和 migration diff 为空,93 条 baseline resolve 在克隆库全量成功,再应用当前仍未就绪的 38 项治理护栏后,hard-ready 仍需清理 2 条历史 SKU 明细 `INCOMPLETE`、3 个非集成产品 profile 缺品类、3 个缺开发类型和 1 条 PreSku 生命周期错位。
- 警告:仍有 2 条历史草稿 SKU 为 `INCOMPLETE`,需要按业务归属补齐或标记无效;它们已经被结构化状态显式暴露,不再隐藏在 JSON 里。若业务确认分阶段清洗,原因必须进入 `MasterDataQualityDecision`,但真实数据库达到“十分稳”前仍必须完成迁移落库、strict 验收,并让 hard-ready 的质量债归零。

View File

@ -0,0 +1,274 @@
# 内部主数据巡检基线 2026-05-29
巡检时间:2026-05-29
巡检对象:本地开发库 `localhost:5432/furniture_3d`
巡检方式:只读查询,不改库。
真相源矩阵巡检:`npm run db:verify-master-data:truth`,读取 `scripts/master-data-truth-sources.js` 的机器可读清单,确认核心业务对象声明了第一真相源、兼容快照、禁止真相源、写入边界和对应数据库护栏,并检查这些主表、快照字段、写入边界文件和护栏仍存在;同时反向检查 `REQUIRED_DATABASE_GUARDS.relationName`,确保每个有数据库物理护栏的核心 relation 都被某个真相源条目覆盖,并检查每个 `requiredGuardNames` 的 relation 归属必须落在当前条目的 `primaryRelations` 内;真相源声明的写入边界还必须同步进入代码层主数据写入口批准表,避免矩阵承认一个代码巡检没有纳管的旁路入口。
代码层边界巡检:`npm run db:verify-master-data:code`,扫描源码主数据读取边界,并扫描 `scripts/` 与 `prisma/seed.ts` 中的主数据直写分类,不连接数据库;脚本支持 `--root <dir>` 测试入口,已用隔离 fixture 覆盖 Route/Page 直写、未分类本地存储、应用层本地 JSON 文件主数据读写、未分类脚本直写、未分类服务写入口、未分类原生 SQL 写主数据、已批准原生 SQL 文件越权写表、未分类原生 SQL 读主数据、已批准原生 SQL 文件越权读表、运行时 DDL 和 `$executeRawUnsafe`、兼容快照读点、过程快照反推正式字段、`ProductSKU.attributes` 保留字段写入、未分类 Prisma JSON 字段、过期 Prisma JSON 字段分类和过期文件级边界分类,防止巡检规则被改弱后仍误判通过。
兼容快照读点盘点:`node scripts/verify-master-data-code-boundaries.js --mode compatibility-snapshot-read`,只列出 `attributes / displaySummary / familySelection / payload` 的直接读点,用于后续继续把旧快照读点迁到结构化字段。
过程快照读点盘点:`node scripts/verify-master-data-code-boundaries.js --mode process-snapshot-read`,只列出 `PreSku.variantSelection / bomPreview / installStepPreview / packagingSnapshot` 的直接读点;这些读点必须明确是 3D 恢复、草稿只读展示、BOM/安装步骤展示、包装输入或正式入库物料生成,不能反推正式编码、条码、品类、人员或权限。
正式 SKU attributes 写入盘点:`node scripts/verify-master-data-code-boundaries.js --mode product-sku-attributes-write`,只列出 `ProductSKU.attributes` 写入对象中的未登记键或主数据保留键;阻断风险必须保持为 0。
完整巡检入口:`npm run db:verify-master-data:all`,先跑真相源矩阵和代码层边界,再跑只读迁移状态,最后跑数据库 dry-run 巡检。只读迁移状态现在是硬前置,任何结构迁移、幂等视图迁移或护栏迁移目标未真实存在时,`all` 都不能再被当成 ready。
迁移清单自检:`scripts/master-data-required-migrations.js` 是治理迁移 dry-run 和清洗脚本共用的唯一迁移目录;`npm run db:verify-master-data` 会先检查清单 path 存在、无重复、按迁移路径升序排列、时间戳可解析、`targetRelation / targetRelations` 能在 SQL 文件中对应到实际引用,并确认 strict 只把非 `alwaysApply` 的结构迁移列为真实落库阻断项。迁移目录也会做静态自检:新增 Prisma migration 目录必须符合 `<14位时间戳>_name`,现有 `20251213_performance_optimization` 和 `add_packaging_plan` 只作为历史非标准目录显式白名单保留,不能继续新增同类目录。迁移 SQL 在 dry-run 事务中执行后,还会重新检查声明的目标表/视图或 `columnCheck` 字段是否真实出现,防止 SQL 空跑、错目标或清单错挂被误判为已经补齐地基。
只读迁移状态:`npm run db:verify-master-data:migration-status` 只用 `to_regclass` 和 `information_schema.columns` 查询治理迁移声明的目标表/视图/字段是否已经真实存在,不执行迁移 SQL,也不启动 dry-run DDL;同时会检查 Prisma `_prisma_migrations` 历史表是否存在,并统计已完成、未完成和已回滚迁移。该入口适合生产/准生产先确认地基真实落库状态,结构治理迁移缺失、迁移历史缺失、存在未完成或已回滚迁移时都不能当成 strict ready。
历史清洗入口:`npm run db:cleanup-master-data -- --template` 输出待补模板和当前对账摘要;`npm run db:cleanup-master-data -- --plan <file>` dry-run;`npm run db:cleanup-master-data:validate -- --plan <file>` 做不连库、不执行迁移 SQL 的静态计划校验;只有额外加 `--apply` 且计划文件带确认字段和业务确认人 `confirmedBy` 才落库。静态校验会先检查确认字段、业务确认人、模板 evidence、未知字段、占位值、覆盖已有字段和补齐完整度,便于业务确认前先排除格式和伪资料问题。清洗脚本会防过期模板、防覆盖已有主数据、防占位值或默认值,`UNKNOWN / 待补齐 / 默认值` 这类伪资料不能被当作业务确认主数据;`--apply` 默认要求被处理项补到完整。清洗模板、dry-run 和真正落库前都必须没有任何治理迁移缺失:无论是结构迁移,还是幂等视图/护栏迁移,只要只读迁移状态不是 `fullyReady`,默认都会拒绝继续;只有本地调试模板或 dry-run 可显式加 `--allow-migration-dry-run`,`--apply` 不允许使用该参数。SKU 编码和条码清洗会先 `trim + uppercase`,再按数据库 `upper(btrim(...))` 唯一池语义检查冲突;落库时会把 `confirmedBy` 写回产品记录 `updatedBy`。清洗脚本在提交前会复用主数据巡检报告断言结构化字段与兼容快照没有漂移,若清洗导致 `payload / attributes / displaySummary / familySelection` 和结构化表分叉,事务会失败并回滚。
数据库物理护栏巡检:`npm run db:verify-master-data` 会在事务中 dry-run 补齐治理迁移后检查关键唯一索引、局部唯一索引和软删除触发器,并输出 `foundationHardReadyGate` 总门禁;护栏缺失、迁移未真实落库、历史主数据质量债、快照漂移、外部契约风险或 PreSku 生命周期错位都会进入 `foundationHardReadyGate.blockers`,命令以非 0 状态退出。需要留存完整本地证据时使用 `npm run db:verify-master-data:audit`,完整报告写入 `.master-data-audits/foundation-hard-ready-gate-current.json`,安全摘要写入 `.master-data-audits/foundation-hard-ready-gate-current.summary.json`,控制台只打印同一份安全摘要;完整报告包含业务样本,只能本地留证,不能提交 Git,也不能作为协作材料传播,日常汇报只看摘要文件或控制台摘要。只读 SQL 巡检文件也会被测试反向校验:`REQUIRED_DATABASE_GUARDS` 中的索引护栏必须在 SQL 对应区块中带上名称、relation、predicate hint 和 definition hint,约束护栏必须带名称、relation 和 expression hint,触发器必须带名称与 relation,避免 JS 主巡检和 SQL 辅助巡检形成两套护栏口径。`scripts/sql` 下的 check/preflight 脚本还会被静态测试限制为只读 SQL,授权脚本只允许 `GRANT SELECT/USAGE` 和默认 SELECT 授权。
严格落库巡检:`npm run db:verify-master-data:strict` / `npm run db:verify-master-data:all:strict` 用于部署后和生产验收。严格模式会先跑只读迁移状态,结构迁移、幂等视图迁移和护栏迁移任何一类目标未真实存在都会失败,避免把“dry-run 能补上”误判成“数据库已经稳固”。巡检报告会把 `migrationReplay.structureMigrations` 和 `migrationReplay.idempotentMigrations` 分开输出,并补充 `fullyReady`,方便部署或审计系统直接判断是否允许进入历史清洗。若只需要确认真实结构状态,优先跑只读迁移状态入口。当前本地 strict 和只读迁移状态仍因治理迁移未真实落库而失败,不能据此宣称真实数据库已经达到“十分稳”。
地基总就绪度:`npm run db:verify-master-data:readiness` 用于回答“现在能不能称为十分稳固”。它会连续执行 Prisma schema 校验、Prisma 官方迁移状态、当前库到 `schema.prisma` 的 diff、真相源矩阵、代码层边界、兼容快照读点、过程快照读点、正式 SKU attributes 写入、只读迁移状态和数据库 dry-run 巡检,并输出 `hardReady / blockerCount / blockers`。需要留存证据时使用 `npm run db:verify-master-data:readiness:audit`,报告会写入本地 `.master-data-audits/readiness-current.json`,即使巡检失败也会保留完整 blocker 证据。该入口故意把 Prisma 未应用 migration、当前库/schema drift、历史 `INCOMPLETE` SKU、产品头缺品类/开发类型、正式 SKU 缺 asset profile、准 SKU 缺基础资料也当成 hard blocker;所以它比普通 dry-run 更适合做地基收口门禁,而不是只证明脚本能跑通。业务确认的分阶段不完整会写入 `MasterDataQualityDecision`,但不会让 `INCOMPLETE` 从 hard blocker 中消失。
地基批次审计:`npm run db:foundation:audit` 用于把 `readiness`、`foundationHardReadyGate` 总门禁、结构变更前置预检、baseline/结构修复施工图和迁移 dry-run 计划放进同一个本地证据目录,并生成 `manifest.json` 汇总 blocker。它不写数据库,不生成备份,也不替代迁移授权;用途是让每次治理前后的证据可对比、可复核。`manifest.hardReady` 必须同时满足 `readiness.hardReady = true`、`foundationHardReadyGate.hardReady = true` 且没有 blockers;总门禁原始报告会标记为敏感文件,manifest 只暴露脱敏的 `foundationHardReadyGateSummary`。
最终完成清单:完整批次审计会在 `manifest.foundationCompletionChecklist` 中把“数据库结构与迁移历史、唯一真相源矩阵、后端写入边界、JSON/快照边界、文件系统边界、历史字段清洗、PreSku 生命周期、授权施工链”拆成逐项 `passed / blocked / missing`。需要把这份清单单独输出给协作或验收时,运行 `npm run db:foundation:completion-checklist`;该命令默认读取 `.master-data-audits/foundation-audit-current-goal/manifest.json`,只打印脱敏清单,并且只在所有项都 passed 时以 0 状态退出。它不是新的审计来源,只是防止把某个局部绿色结果误判为“数据地基已完成”。
批次审计需要 shadow diff 留证时,显式使用 `--use-docker-shadow`。该模式会临时启动 Docker PostgreSQL shadow 库,把 `SHADOW_DATABASE_URL` 传给 preflight 和迁移计划步骤,结束后清理容器,并在 manifest 中写入 `shadowDiffEvidence`。它只证明迁移链和当前库/当前 schema 的差异,不代表目标数据库已经迁移完成。批次审计也可以显式追加 `--include-migration-rehearsal`,把从备份恢复到 Docker 克隆库并执行 `migrate deploy` 的演练报告写入同一目录的 `migration-rehearsal.json`。
批次审计还可以显式追加 `--include-structure-repair-rehearsal`,把结构修复 SQL 的克隆库演练报告写入 `structure-repair-rehearsal.json`,并把本次 SQL 落盘到 `structure-repair.sql`。当前本地演练已证明:从备份恢复出的克隆库可通过非 DROP 的结构修复 SQL 对齐 `schema.prisma` 和 migrations 重放结果;manifest 中 `structureRepairRehearsalSummary.status = structure-repair-rehearsal-passed`,同时会记录 SQL 文件大小、SHA-256、结构对齐后的 baseline resolve 候选 migration 数、resolve 演练成功数和 resolve 后 `prisma migrate status` 状态。真实库仍必须先授权、备份和重新预检。
启用结构修复演练的批次审计还会保存 `authorized-structure-repair-apply-plan.json`,并在 `manifest.json` 写入脱敏 `authorizedStructureRepairApplyPlanSummary`。该计划会校验 preflight、演练报告和 `structure-repair.sql` 的大小/SHA-256 是否同源,也会重新扫描 SQL 文件本体是否含 DROP/TRUNCATE 类破坏语句;真实执行阶段还会比对当前 `DATABASE_URL` 和 preflight 的数据库身份,并要求报告在默认 60 分钟内。真实执行阶段必须额外提供 `--expected-database-identity` 和 `--expected-backup-sha256`,脚本会现场重算备份 SHA-256,并和 preflight 记录同时比对,防止拿错库或错备份施工。结构修复演练报告还会记录 `prisma/migrations` 文件指纹;真实执行时如果当前迁移目录和演练时不一致,脚本会要求重新演练。数据库身份包含用户名和 schema;后续 Prisma diff、baseline resolve、migrate status 都沿用已确认的 `DATABASE_URL`,不能再隐式读另一个环境。该入口默认只读;真实库执行必须分 `apply-structure` 和 `resolve-baseline` 两阶段显式确认。`apply-structure` 后必须重新跑 preflight,`resolve-baseline` 前必须证明真实库结构 diff 已归零,不能把克隆库通过直接当成生产库已修复。
结构变更前置预检中的 `backupToolingHint` 用于定位 PostgreSQL client 工具链:当 `pg_dump / pg_restore / psql` 不可用时,它会列出缺失命令、候选 bin 目录和 `POSTGRES_BIN_DIR` 建议。这个提示只解决“工具在哪里”,不降低“必须有可核验备份”的门槛。
如果 Docker 可用,`backupToolingHint.dockerFallback` 会额外说明默认 `postgres:16` 或 `POSTGRES_DOCKER_IMAGE` 镜像是否已经存在本机。Docker 兜底仍只是工具链提示,不能替代真实备份文件和 `pg_restore --list` 验证。
批次审计会单独保存 `truth-sources.json`,逐项列出核心对象的真相层级、主表、兼容快照、非真相源、写入边界和数据库护栏,并通过 `truthSourceEntryAudits` 给每个对象单独标出 `ok / issueCount / issues`。这个文件用于回答“每个业务对象的数据真相在哪里、哪个对象还不稳”,不能只看总数通过。
批次审计同时保存代码边界证据:`code-boundaries.json`、`compatibility-snapshot-read.json`、`process-snapshot-read.json` 和 `product-sku-attributes-write.json` 会和数据库 readiness 报告在同一目录下生成,便于同时判断结构化主数据、兼容 JSON、过程快照和正式 SKU `attributes` 写入面是否有新增债务。
批次审计还会保存 `baseline-repair-plan.json`,并在 `manifest.json` 写入脱敏 `baselineRepairPlanSummary`。该施工图会把证据归类为 `ready-for-migrate-deploy`、`ready-for-baseline-resolve`、`ready-for-authorized-structure-repair`、`manual-structure-repair-required` 或 `evidence-blocked`,用于阻断盲目批量 `resolve --applied`、在 P3005 已复现的库上直接 `migrate deploy`,以及运行时代码补 DDL。`ready-for-authorized-structure-repair` 只表示克隆库已证明结构修复和 baseline 路径可重复,真实库仍必须重新备份、预检、授权和逐步验收。
批次审计还会保存 `foundation-required-migration-apply-plan.json`,并在 `manifest.json` 写入脱敏 `foundationRequiredMigrationApplyPlanSummary`。该计划只判断“结构修复和 baseline resolve 之后,是否可以应用治理迁移/护栏”;当前库缺 `_prisma_migrations` 或仍有结构迁移未就绪时,必须显示为 `readyToApply = false`。真实应用入口是 `npm run db:foundation:apply-required -- --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`,不是 `migrate deploy`。
授权结构修复入口:`npm run db:foundation:apply-structure-repair` 默认只读生成计划。真实执行结构 SQL 使用 `--phase=apply-structure --apply --confirm=apply-prisma-structure-repair --expected-sha256 <repair-sql-sha256> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`,真实执行 baseline resolve 使用 `--phase=resolve-baseline --apply --confirm=apply-prisma-structure-repair --expected-sha256 <repair-sql-sha256> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`。两个阶段都要提供 fresh preflight、结构修复演练报告和同一个 SQL artifact;脚本会阻断 SHA-256 不一致、SQL 本体含 DROP/TRUNCATE、目标库身份不一致、备份文件缺失或 SHA-256 不一致、迁移 manifest 缺失或不一致、报告过期、pending migration 数量不一致、post-repair diff 未归零、缺 migration id 清单等情况。
历史清洗候选行默认不进入普通批次审计,避免业务资料扩散。需要同批留证时使用 `npm run db:foundation:audit:cleanup-template`,会额外生成 `cleanup-template.json`;它只用于业务确认和清洗前证据,不代表迁移已真实落库,也不能提交 Git。
同一批次的 `manifest.json` 会写入脱敏 `cleanupTemplateSummary`,只记录候选数量、空白待填字段统计和迁移状态计数;日常判断历史质量债规模时看 manifest,不直接打开或传播敏感清洗模板。
Prisma 迁移基线诊断:`npm run db:diagnose-prisma-migration-baseline` 只读检查 `DATABASE_URL` 指向的数据库、Prisma schema、当前库到 `schema.prisma` 的 diff、`prisma/migrations` 目录、`prisma migrate status` 和 `_prisma_migrations` 历史表。它用于判断当前库是不是历史 `db push` 或手工结构导致的“结构可能存在但迁移历史缺失”状态,并输出 baseline 所需的谨慎步骤。该脚本不会执行 `migrate deploy` 或 `migrate resolve`;如果配置了 `SHADOW_DATABASE_URL`,会额外运行两类 Prisma diff:`migrationDiff` 用 shadow 库重放 migrations 后比较当前库结构,`migrationSchemaDiff` 比较 migrations 重放结果和当前 `schema.prisma`。`SHADOW_DATABASE_URL` 必须和目标库不同,且数据库名必须包含 `shadow / test / tmp / temp` 这类明确标记。没有 shadow diff 证据、当前库到 schema diff 非空、migrations 到当前库 diff 非空,或 migrations 到 schema diff 非空时,不能 baseline resolve;如果需要基线化,必须先备份,再确认每个 migration 的结构结果已经真实存在,不能直接批量 resolve。
数据库地基变更预检:`npm run db:preflight-database-foundation-change` 是进入 `migrate deploy` 或 baseline resolve 前的只读门禁。它会复用基线诊断,同时要求 `DATABASE_BACKUP_FILE` 或 `DB_BACKUP_FILE` 指向真实存在且非空的备份文件;没有备份证据时直接失败,空文件也不能通过。预检还会报告 `pg_dump / psql` 是否可用,没有备份文件且缺少 `pg_dump` 时会额外提示备份工具链未就绪。当前库/schema diff 非空时,预检建议 `migrate-deploy-required`;pending migration 存在但结构 diff 为空时,只有在 shadow diff 证明 migrations 与当前库等价后,才可能成为 `baseline-resolve-candidate`。如果 `migrationSchemaDiff.status = 0`,只能证明迁移链已能重放到 schema;当前库是否可 baseline 仍以 `migrationDiff` 和当前库/schema diff 为准。
PostgreSQL 标准备份入口:`npm run db:backup:postgres` 在本机存在 `pg_dump` 时生成 `.database-backups/<db>-<timestamp>.dump`。该入口只读数据库,不执行迁移;密码通过 `PGPASSWORD` 传递,不出现在命令参数或报告里。生成后把该文件路径放入 `DATABASE_BACKUP_FILE` 或 `DB_BACKUP_FILE`,再运行数据库地基变更预检。本机缺少 PostgreSQL client 但 Docker 可用时,可显式追加 `--use-docker`,默认镜像为 `POSTGRES_DOCKER_IMAGE` 或 `postgres:16`;镜像不存在时默认拒绝隐式下载,只有显式追加 `--allow-docker-pull` 才允许拉取。Docker 兜底仍必须生成 custom dump 并通过容器内 `pg_restore --list` 校验;密码只通过环境变量传递,Docker 命令参数只出现 `--env PGPASSWORD` 变量名,不能出现 `PGPASSWORD=value`。
结构变更 preflight 也会承认同一套 Docker 校验能力:如果本机没有 `pg_restore`,但 Docker PostgreSQL client 镜像已准备好,会用 Docker `pg_restore --list` 校验 `DATABASE_BACKUP_FILE`。因此备份生成、备份验证和迁移门禁使用同一条工具链证据,避免“备份脚本认为可用、preflight 不承认”的断层。
克隆库迁移演练:`npm run db:foundation:rehearse-migration` 用于把 `DATABASE_BACKUP_FILE / DB_BACKUP_FILE` 恢复到临时 Docker PostgreSQL,再对克隆库运行 `prisma migrate deploy`,最后删除容器并写出 JSON。它只验证备份恢复出的非空库是否能直接迁移,不写真实目标库。当前本地备份演练复现 `P3005`,因此直接对目标库执行 `migrate deploy` 是已证伪路径;后续必须先形成 baseline/结构修复方案。
本地 `db push` 保护:`npm run prisma:push` 已改为 `scripts/prisma-db-push-local.js`,只允许本地开发库通过,拒绝 production 环境标记、缺失/非法 `DATABASE_URL`、非本机 host 和 `*_prod / *-production` 等疑似生产库名。生产/准生产只能走 `npx prisma migrate deploy`,再跑 `npm run db:verify-master-data:all:strict`。
本地存储 advisory:`npm run db:verify-master-data:code` 会列出仍以 `localStorage/sessionStorage` 保存资料库型数据的模块,并按 `server-contract-required / server-backed-cache / legacy-cache / compatibility-cache` 分层;非测试源码里的本地存储直接读写和 `createJSONStorage` 封装持久化用途必须被归类,未归类时命令失败。
迁移前预检:
- `scripts/sql/preflight-intake-non-integrated-sku-items.sql` 用于在结构化迁移前检查历史 JSON 里的重复 `skuCode / barcode`。
- `scripts/sql/preflight-product-sku-asset-profile.sql` 用于在正式 SKU asset profile 迁移前检查历史 `assetSkuSnapshot` 质量。
## 1. 核心结论
内部主链的主数据结构已具备可验证硬约束:
- 系列、资产族、规格款、正式型号、正式 SKU、草稿组编号都没有发现重复码。
- 资产族都已挂到系列,没有发现空系列归属。
- 资产规格款都已有模型 URL。
- 正式 SKU 资产资料已新增 `ProductSkuAssetProfile` 结构化副本;`assetSkuSnapshot` 降级为兼容快照和巡检证据,不再参与常规业务判断。
- 外部正式 SKU 视图已追加 `product_business_key / sku_business_key`,巡检会阻断正式 SKU 视图业务键缺失或 SKU 业务键重复。
- 外部正式产品素材和 BOM 明细视图已追加同一套业务键,巡检会阻断明细视图缺业务键,避免外部系统用内部行 ID 关联素材和 BOM。
- 准 SKU 的核心快照字段都存在。
- 准 SKU 正式字段的代码读取边界已有自动巡检,防止后续重新从 `displaySummary / familySelection` 主读。
- 核心业务对象已有机器可读真相源矩阵:当前覆盖 27 个对象,逐项绑定 Prisma 主表、兼容/过程快照、禁止当真相源的字段、写入边界和数据库护栏;新增的 `MasterDataQualityDecision` 只承载质量决策审计,不承载 SKU 真值。巡检会校验主表存在、`Model.field` 快照字段存在、写入边界文件存在、护栏名称存在,并反向确认数据库护栏 relation 全部被 `primaryRelations` 覆盖,同时阻断把其他 relation 的护栏错挂到当前真相源条目;矩阵里的写入边界也会反查 `verify-master-data-code-boundaries.js` 的服务/脚本写入口批准表。代码层标为 `compatibility-snapshot` 的 Prisma JSON 字段也必须出现在真相源矩阵中,当前 4 个字段全部已映射,未映射为 0。新增核心对象时不能只写文档,必须同步进入 `scripts/master-data-truth-sources.js`,并把真实写入口纳入代码边界分类。
- 治理迁移清单已有机器自检:当前共 50 项,其中 11 项是必须真实落库的结构治理迁移,39 项是可在巡检事务中幂等重放的视图/护栏迁移;当前只读真实落库状态仍有 10 项结构迁移和 28 项幂等迁移未就绪。清单本身的路径、顺序、目标 relation 和 SQL 引用都会在连库前验证,执行后还会复查目标 relation 或 column 是否出现,防止后续新增迁移漏登记、错顺序、错挂目标或 SQL 没有真正产出声明对象。只读迁移状态入口会在不执行 SQL 的情况下报告结构迁移和幂等迁移当前目标是否仍缺失,作为真实落库前置证据。治理迁移文件还会被静态测试反查:当前治理窗口内新增外部 v1 视图、约束、唯一索引或触发器时,必须登记到 `scripts/master-data-required-migrations.js`;`alwaysApply` 迁移如果包含 DML,必须显式分类,不能悄悄把幂等护栏迁移变成无说明的数据修复脚本。
- 正式 SKU 兼容 JSON 的代码读取边界已有自动巡检,`attributes.skuName / assetSkuSnapshot / variantSelectionSummary / bomPreview` 只能出现在批准的兼容适配点。
- 正式 SKU 兼容 JSON 的写入边界也已有自动巡检,`ProductSKU.attributes` 只能写登记过的兼容/过程键;颜色、材质、尺寸、条码、型号、规格码等正式资料必须写 `ProductSkuAssetProfile` 或结构化列,demo/test seed 也不能例外。
- 准 SKU 过程快照读点已有独立盘点:`PreSku.variantSelection / bomPreview / installStepPreview / packagingSnapshot` 当前 35 处全部分类,高优先级 0、未知 0、未分类 0、未使用分类 0。过程快照只能服务辅助装配恢复、草稿组只读物料/BOM/安装预览、包装输入和正式入库物料生成;巡检会阻断从这些快照附近读取 `barcode / skuCode / formalSkuCode / category / ownerId / businessType` 等正式或权限字段。
- 核心编码/条码查询边界已有自动巡检,非测试源码不能再用 `where: { code }`、`where: { skuCode }`、`where: { modelNumber }`、`where: { barcode }` 这类缩写或同名变量直查;仓储层和服务旁路必须显式归一到 `trim + uppercase`。
- 非集成开品 SKU 已新增 `IntakeNonIntegratedSkuItem` 结构化承接表,不再只依赖 `payload.skuItems` 读取 SKU 主数据。
- 非集成产品头资料已收紧为 `IntakeNonIntegratedProductProfile` 优先且 profile 空值不再回退旧 `payload` 补齐;页面和接口会真实暴露缺品类、缺开发类型等主数据质量问题。
- 非集成开品 SKU 的重复 SKU、重复条码、资料完整度和 payload 同步状态已经纳入巡检基线。
- Prisma JSON 字段分类已经纳入代码层巡检,当前 80 个 JSON 字段全部有分类,未分类数为 0,分类表中过期字段数为 0。
- 当前数据库物理护栏巡检已扩展到 290 项:一对一 profile、正式 SKU 资产资料同步副本活动条码唯一、正式 SKU 资产资料副本来源规格款身份约束、活动正式 SKU 编码不得带软删除墓碑标记、正式 SKU 软删除生命周期结构化身份、正式 SKU attributes 不得保存软删除生命周期元数据、正式产品名称/可见范围、产品协作者身份/角色、分享链接 token/访问次数/过期时间、准 SKU 基础资料、准 SKU 型号/版本号/属性码/图纸号规范化、来源枚举和空字符串禁用、资产 SKU 同步事件原因枚举/计数/完成时间约束、非集成 SKU 明细身份、非空 SKU/条码局部唯一索引、主数据质量决策开放目标唯一和原因/确认人/解决状态约束、专利台账分组唯一和关键身份约束、核心编码/条码规范化 check 约束、`ProductSkuAssetProfile.model / specCode / barcode` 规范化 check 约束、软删除触发器、资料库条目身份唯一、资料库 itemKey 归一唯一、同库非空编码唯一、资料库编码非空、SPU 规则产品品类唯一、SPU 规则产品品类归一唯一、SPU 正式号段计数器品类唯一、品类归一唯一、号段前缀唯一、人员邮箱/花名/业务类型、组织部门、员工目录身份、规划模板/基材/库位/包装成品/材质/五金/通用组件编码唯一、准 SKU 包装方案 JSON 形态和状态约束、活动库位物理位置唯一、品牌物料准备项规范化唯一和文件元数据一致性、包装成品状态/数量/尺寸约束、审批流编号/标题/访问密钥/参与人/业务角色/字段矩阵和字段变更审计约束、旧 CAD 兼容链板件/孔位/产品五金/装配步骤/装配版本结构约束、基础资料分类/基材选项规范化唯一池、五金匹配记忆、基材花色绑定、板件材质绑定、规格款/准 SKU 库位绑定、旧正式产品包装方案质量约束,以及系统配置/服务商配置唯一激活/主题默认配置/节点权限覆盖/AI 生图记录/反馈/操作日志约束都必须存在。
- 规划域新增护栏覆盖 `PlanningTemplate / PlanningWorkspace / PlanningDraft`:模板槽位必须是非空数组,模板规则和工作台元数据必须是对象,工作台可见范围只能是固定枚举,草稿选中资产、预览节点、摘要、警告和来源快照必须保持明确 JSON 形态。
- 审批流可见范围已收口到结构化 `businessRoles / participantUserIds`;代码层边界巡检会阻断从 `fieldMatrix` 过程矩阵反推流程访问权限,`fieldMatrix` 只保留字段级责任判断用途。审批产品 DTO 也已收紧为存在结构化 profile 时只读取 profile 和产品结构化列,旧 `payload.openingReference` 只允许在没有 profile 的历史记录上做兼容展示,不再补产品名、图片或说明。
- 代码层 advisory 当前为 0;非主数据 UI 状态、登录态和会话偏好缓存已列入明确忽略清单,不再和资料库型主数据风险混在一起。当前未归类本地存储数为 0。
- 应用层本地 JSON 文件主数据读写已纳入硬护栏:`src` 下不得用同步或异步 `readFile/writeFile`、`require/import` 读取或写入本地 `.json` 文件作为业务资料真相;JSON 文件只能作为导入载荷、迁移计划、备份、测试夹具或明确的临时工具输入,且必须留在工具、脚本或测试边界内。
- 脚本层主数据直写已纳入分类巡检:当前扫描脚本文件 43 个,发现 109 处已批准的 demo/test/维护脚本直写,未分类直写为 0;扫描范围已覆盖产品资料、资产中心、基础资料、旧 CAD 结构、仓库、人员组织、配置、审批、分享、反馈、审计、分类字典、规划、包装和同步事件等地基表。后续新增生产修复脚本如果直接写这些表,必须先改成专用服务、迁移或显式清洗计划,不能静默绕过主写入口。已批准写库脚本还会被安全门禁清单复查;聚水潭存量商品同步这类外部来源写入口必须在真实写库时同时绑定确认词、目标 PostgreSQL 身份和来源 MySQL 身份。
- 原生 SQL 主数据写入已纳入分类巡检:当前应用代码和脚本中发现 6 处 `INSERT INTO / UPDATE / DELETE FROM` 写核心主数据表,全部已分类,未分类为 0;允许点仅限显式历史清洗计划和 SPU 号段事务内计数器写入,并且必须同时匹配批准文件和批准表名。迁移 SQL 继续由迁移清单和 strict 巡检管理,不和业务旁路写入口混在一起。
- 原生 SQL 主数据读取也已纳入分类巡检:当前应用代码和脚本中发现 112 处 `FROM / JOIN` 读取核心主数据表,全部已分类,未分类为 0;允许点限于内部巡检、显式历史清洗、节点权限只读 schema readiness/规则读取和 SPU 号段事务读取,并且必须同时匹配批准文件和批准表名。业务服务默认应通过 Prisma delegate、Repository 或结构化 helper 读取,不能绕过真相源边界。
- 运行时 DDL 已纳入代码层硬护栏:应用代码和普通脚本中不得用 `CREATE TABLE / CREATE INDEX / ALTER TABLE / DROP / TRUNCATE` 补建结构,也不得使用 `$executeRawUnsafe`;数据库结构真相只能来自 Prisma migration、治理迁移清单和 strict 巡检。当前运行时 DDL 基线为 0。
- 服务/仓储层主数据写入口巡检已覆盖 Prisma client 别名和 relation 嵌套写入:当前 `src/server` 写入口基线为 286 处,未分类为 0;`client = tx ?? this.prisma`、`assetProfile: { upsert }`、`profile: { upsert/create }`、`skuItems: { createMany/deleteMany }`、`basicInfo: { upsert }`、`preSkuPackagingPlan.upsert`、分类字典写入、装配步骤/版本写入和同步事件写入都会进入同一套写入口分类,不能再靠父模型写入绕过子模型结构化边界。
- 兼容快照直接读点已有独立盘点模式,当前基线为 42 处;这些不等同于违规,但必须被视为继续结构化的待办清单,不能让 `attributes / displaySummary / familySelection / payload` 扩回主数据真相源。资料库 `IntakeReferenceLibraryItem.payload` 已按结构化扩展字段单独归类,不再混入 `IntakeNonIntegratedProduct.payload` 兼容快照风险。正式 SKU 软删除生命周期身份已迁入结构化列,剩余 attributes 读点只允许旧数据回退和清理;回收站删除态 SKU 合同已从 `attributes` 收口为 `legacyAttributes`,并过滤 `_originalSkuCode / _deletedCodeAt / _deletedByProductId / _productDeletedAt / assetSkuSnapshot / _skuCadData` 等内部证据,避免旧生命周期元数据和兼容快照继续向外输出。正式入库新建 `ProductSKU.attributes` 时不再写入整包 `PreSku.displaySummary`,避免草稿展示快照进入正式 SKU 属性袋后再次成为第二主数据源。非集成产品列表、分页、详情、附件和预览读取已统一收口到结构化覆盖入口,`payload-hydration` 兼容读点从分散链路收敛到集中 helper,且水合结果会用持久化结构化列覆盖 `id / spu / productCode / brand / designer / operator / purchaser`,避免旧 payload 反向覆盖身份和人员字段。
- 兼容快照读点分类表也已纳入反向校验:分类表里的文件必须在当前扫描结果中真实命中兼容快照读点,或被列为结构化 payload 跳过读点;当前未使用的兼容快照分类数为 0。旧高优先级风险清零后,对应分类也必须清掉,不能在台账里留下已经不存在的风险入口。
- 文件级边界分类也已纳入反向校验:脚本写入口批准表、原生 SQL 写入口批准表、原生 SQL 表作用域表、服务/仓储写入口批准表、模型作用域表、兼容快照读点分类表、结构化 payload 跳过表和 UI 缓存忽略表的文件路径都必须真实存在;当前过期文件级分类数为 0。
- 外部只读字段契约已并入数据库巡检:`external` 9 个 v1 视图、224 个字段字典承诺列必须存在,且实际视图不能多出字段字典未登记列;`npm run db:verify-master-data` 会复用 `scripts/sql/check-external-readonly-contract.sql` 的字段清单,避免 SQL 独立巡检和 JS 主巡检出现两套口径。巡检还会阻断外部视图重新依赖内部 JSON 源、外部稳定业务键缺失/重复,或在 `*_code / *_business_key` 这类业务编码字段里用内部行 `id` 兜底。稳定业务键当前直接检查 `materials_v1.material_code`、`asset_variants_v1.spec_code`、正式 SKU、ready 开品/SKU、素材 URL 和 BOM 物料编码等外部实际消费字段;内部 `id` 兜底扫描同时覆盖 `COALESCE`、直接别名和 `CASE` 派生。
- 已新增 `IntakeReferenceLibraryItem` 服务端承接底座和 `/api/intake/reference-library` API,数据库物理护栏已覆盖资料库条目身份唯一、itemKey 归一唯一、类型枚举、itemKey 非空、同库非空编码唯一、SPU 规则产品品类唯一和 `SpuNumberCounter_productCategory_key`;代码巡检同步阻断用数据库 `id`、前端临时 `id`、`itemKey: item.id`、`code || item.id` 或 `` `item:${item.id}` `` 兜底资料库身份的回退写法,并阻断草稿组编号退回纯 `Date.now()` 生成。
- 正式 SKU 资产 profile 同步链路已把型号、规格码、条码在服务层统一 `trim + uppercase`,包括人工锁定字段和 `sourceVariants` 快照,防止旧兼容快照把小写条码回灌到结构化副本。
- 非集成 SKU 保存链路已把 `skuCode / barcode` 在 payload 和结构化 `IntakeNonIntegratedSkuItem` 写入前统一 `trim + uppercase`,和清洗脚本、数据库表达式唯一索引保持同一语义。
- 聚水潭存量商品同步入口已同步规范化来源 SPU、来源 SKU/条码和规格码;profile、payload、reviewSheet 和结构化 SKU 明细不会因外部源库大小写差异产生第二套身份。同步查找、更新和排除判断已经收紧为结构化 profile 或系统生成的 `jst-stock-*` 业务 ID,不再用 `payload.metadata.source*` 参与来源身份判断。同步脚本默认 dry-run;真实写库必须带 `--confirm=sync-jst-sku-stock`、目标库身份和来源库身份,避免把外部 MySQL 或内部 PostgreSQL 配错后仍然批量落库。
- 包装/轻仓库承接已补齐服务层和数据库双护栏:包装箱编号、名称、尺寸、数量、成本/重量边界会在保存和正式入库前校验,数据库同步约束包装方案 JSON 形态、活动库位物理位置唯一、品牌物料类型和文件元数据、包装成品状态/数量/尺寸。
真正剩下的内部脏点,不再是“巡检发现但没有承接层”,而是结构化承接后的历史资料清洗余量:当前仍有 2 条非集成草稿 SKU 明细处于 `INCOMPLETE`。
## 2. 巡检结果
| 检查项 | 结果 |
| --- | ---: |
| 系列重复码 | 0 |
| 资产族重复码 | 0 |
| 规格款重复码 | 0 |
| 规格款重复条码 | 0 |
| 正式产品型号重复 | 0 |
| 正式 SKU 重复码 | 0 |
| 正式 SKU 缺少名称 | 0 |
| 外部正式 SKU 视图业务键缺失/重复风险 | 0 |
| 外部正式素材/BOM 明细业务键缺失风险 | 0 |
| 草稿组重复码 | 0 |
| 准 SKU 同组重复序号 | 0 |
| 资产族空系列归属 | 0 |
| 规格款空模型 URL | 0 |
| 正式 SKU 缺少资产 profile | 0 |
| 正式 SKU profile 与兼容快照差异 | 0 |
| 正式 SKU profile 来源 ID 不一致 | 0 |
| 正式产品身份/可见范围风险 | 0 |
| 产品协作者角色/身份风险 | 0 |
| 分享链接身份/计数风险 | 0 |
| 准 SKU 缺少真相快照 | 0 |
| 非集成 SKU 重复码 | 0 |
| 非集成 SKU 重复条码 | 0 |
| 非集成不完整 SKU | 2 |
| 非集成 SKU payload 同步差异 | 0 |
| 代码层未批准 JSON 主读违规 | 0 |
| 应用层本地 JSON 文件主数据读写违规 | 0 |
| 数据库物理护栏缺失/失效 | 0 |
| 数据库物理护栏通过数 | 290 |
| 治理迁移清单项 | 50 |
| 需真实落库结构治理迁移 | 11 |
| 幂等视图/护栏迁移 | 39 |
| 只读迁移状态待落库结构迁移 | 10 |
| 只读迁移状态待重放幂等迁移 | 28 |
| 真相源矩阵条目 | 27 |
| 数据库护栏 relation 反向覆盖缺失 | 0 |
| 真相源护栏归属错挂 | 0 |
| 兼容快照 JSON 字段矩阵映射缺失 | 0 |
| 旧 CAD 板件身份/尺寸风险 | 0 |
| 旧 CAD 板件同产品/SKU 编号重复 | 0 |
| 旧 CAD 孔位几何风险 | 0 |
| 旧 CAD 产品五金结构风险 | 0 |
| 旧 CAD 装配步骤结构风险 | 0 |
| 旧 CAD 装配版本快照风险 | 0 |
| 基础资料/基材选项身份风险 | 0 |
| 基础资料/基材选项规范化重名风险 | 0 |
| 五金匹配记忆身份风险 | 0 |
| 基材花色/板件材质/库位绑定风险 | 0 |
| 旧正式产品包装方案质量风险 | 0 |
| 配置/权限/审计过程数据风险 | 0 |
| 资料库服务端承接条目 | 0 |
| SPU 正式号段计数器 | 已建表并纳入唯一护栏 |
| 原生 SQL 主数据写入口 | 6 |
| 未分类原生 SQL 主数据写入口 | 0 |
| 原生 SQL 主数据读取入口 | 112 |
| 未分类原生 SQL 主数据读取入口 | 0 |
| 运行时 DDL / unsafe raw SQL 入口 | 0 |
| 未分类运行时 DDL / unsafe raw SQL 入口 | 0 |
| 服务端主数据写入口 | 286 |
| 未分类服务端主数据写入口 | 0 |
| 资料库型本地存储 advisory | 0 |
| 已批准兼容测试命中 | 9 |
| 兼容快照直接读点盘点 | 42 |
| 兼容快照阻断风险读点 | 0 |
| 未使用兼容快照分类 | 0 |
| 结构化 payload 跳过读点 | 8 |
| 过程快照直接读点盘点 | 35 |
| 过程快照阻断风险读点 | 0 |
| 未使用过程快照分类 | 0 |
| 外部只读字段契约视图 | 9 |
| 外部只读字段契约列 | 224 |
| 外部只读字段契约缺失列 | 0 |
| 外部只读字段契约未登记多余列 | 0 |
| 其中 server-contract-required | 0 |
| 其中 server-backed-cache | 0 |
| 其中 legacy-cache | 0 |
| 其中 compatibility-cache | 0 |
| 未归类本地存储 | 0 |
| 外部视图 JSON 源依赖风险 | 0 |
| 外部视图业务编码 ID 兜底风险 | 0 |
| 外部 ready 视图稳定业务键缺失/重复风险 | 0 |
| Prisma JSON 字段总数 | 80 |
| 未分类 Prisma JSON 字段 | 0 |
| 过期 Prisma JSON 字段分类 | 0 |
| 过期文件级边界分类 | 0 |
## 3. 结构化承接和历史清洗状态
当前口径:
- `IntakeNonIntegratedProduct.payload.skuItems` 是历史过程快照,不再作为非集成 SKU 主数据的唯一读取来源。
- `ProductSkuAssetProfile` 是正式 SKU 资产资料同步副本的结构化承接表,正式入库和资产同步会双写该表;`assetSkuSnapshot` 只用于兼容与排查。
- `IntakeNonIntegratedSkuItem` 承接 SKU 明细的结构化字段,包括 `skuCode / specName / model / barcode / size / color / material / manufacturer` 等。
- `external.intake_products_v1` 和 `external.intake_sku_items_v1` 均已改读结构化表;外部系统不能再通过这两个视图间接依赖 `payload / payload.skuItems`。
- 非集成产品开发类型、负责人、待办和存量来源筛选均以 `IntakeNonIntegratedProductProfile` 为主读;旧 `payload` 查询只允许在 profile 缺失的历史记录上兜底。
- `external.intake_products_ready_v1 / external.intake_sku_items_ready_v1` 提供外部稳定消费口径;完整视图继续保留 `INCOMPLETE` 资料用于治理对账。
- `external.intake_products_v1 / external.intake_sku_items_v1` 追加 `product_business_key / sku_business_key`;ready 视图要求业务键非空,巡检会阻断 ready 业务键缺失或重复,技术 ID 只保留为内部追踪字段。
- 非空 `skuCode` 和非空 `barcode` 已进入结构化唯一性治理;巡检继续保留重复风险检查。
- `dataQualityStatus = READY` 表示当前明细达到主数据可流转基线。
- `dataQualityStatus = INCOMPLETE` 表示历史或草稿资料仍需清洗,不能自动当作完整商品资料。
- payload 与结构化表同步差异为 0,说明本次结构化承接没有发现 SKU 明细丢行或关键字段偏移。
- `scripts/verify-master-data-foundation.js` 已把结构化主数据与兼容快照的漂移提升为阻断项:非集成 SKU 明细、正式 SKU 资产 profile、准 SKU 基础资料、非集成产品 profile、存量来源字段只要和旧 `payload / attributes / displaySummary / familySelection` 快照出现差异,巡检会失败;`payload / attributes` 只能作为历史证据和兼容副本,不能成为第二真相。
- `scripts/verify-master-data-code-boundaries.js` 会把已批准的历史兼容点列入 `approvedCompatibility`,当前基线为 9 条,均为测试样本或反回退测试;只要出现未批准主读点就以非 0 状态退出,避免靠人工 `rg` 记忆维护边界。
- `scripts/verify-master-data-code-boundaries.js` 还会阻断非集成产品查询里未受 `payloadJsonWhenProfileMissing(...)` 保护的 `payload` JSON 筛选 helper,防止旧快照重新和 `IntakeNonIntegratedProductProfile` 并列成为开发类型、负责人或待办真相源。
- `scripts/verify-master-data-code-boundaries.js` 还会阻断 `src/app` Route/Page 直接写核心主数据和关键地基表,确保产品资料、资产中心、草稿组、非集成资料、配置、权限、反馈、审计和 AI 生图记录都只能走服务层或仓储层规范化入口。
- `scripts/verify-master-data-code-boundaries.js` 还会阻断未分类的脚本层主数据直写;demo/test/维护脚本必须明确分类,生产修复和历史清洗必须走迁移、专用服务或显式清洗计划。当前直写分类扫描已经覆盖基础资料、旧 CAD 结构、人员组织、配置、审批、分享、反馈和审计等地基表。
- `scripts/verify-master-data-code-boundaries.js` 还会阻断未分类的原生 SQL 主数据写入;应用代码和脚本中出现 `INSERT INTO / UPDATE / DELETE FROM` 写核心表时,必须说明它是显式清洗计划、事务内计数器、迁移工具还是其他受控入口,并且只能写入该入口批准的表名。否则即使没有调用 `prisma.model.create/update`,也不能通过代码层边界巡检。
- `scripts/verify-master-data-code-boundaries.js` 还会阻断未分类的原生 SQL 主数据读取;应用代码和脚本中出现 `FROM / JOIN` 读取核心表时,也必须说明它是内部巡检、显式清洗、schema readiness、事务内计数器读取或其他受控入口,并且只能读取该入口批准的表名。读旁路和写旁路一样会影响真相源一致性。
- `scripts/verify-master-data-code-boundaries.js` 还会阻断运行时 DDL 和 `$executeRawUnsafe`:普通服务不能再用调用时 `CREATE TABLE IF NOT EXISTS`、`CREATE INDEX IF NOT EXISTS` 或 `ALTER TABLE ADD CONSTRAINT` 补结构,节点权限表这类结构必须由迁移提供;服务层最多做只读 schema readiness 检查并给出明确错误。
- `scripts/verify-master-data-code-boundaries.js` 还会阻断未分类的服务/仓储层主数据直写;当前 `src/server` 写入口基线为 286 处,未分类为 0。新增 service/repository 文件如果直接写 `Product / ProductSKU / PreSku / AssetVariant / User / EmployeeDirectory / ApprovalWorkflow / Warehouse / Planning` 等地基表,必须先说明它是仓储写入口、服务事务编排、同步入口、初始化入口还是兼容维护入口;已分类文件也只能写入批准模型,避免同一文件里悄悄新增旁路模型。巡检会识别 `client = tx ?? this.prisma` 这类 Prisma client 别名,也会识别 `basicInfo/profile/skuItems/assetProfile` relation 嵌套写入,防止子表结构化副本绕过写入口登记。真相源矩阵声明的写入边界必须存在于同一份批准清单中,而且至少有一个声明入口批准写入对应主表,避免“文档登记了入口、代码巡检没纳管”的假稳定。
- `scripts/verify-master-data-code-boundaries.js` 的边界规则已有隔离 fixture 防回退测试;新增或重构规则时,测试会确认坏样本确实能被抓住,而不是只验证当前大仓“没有报错”。Prisma JSON 字段分类同时做正反向校验:schema 新增 JSON 字段必须补分类,分类表残留已删除或改名的字段也会失败。原生 SQL 已覆盖“批准文件越权写未批准表”和“批准文件越权读未批准表”的坏样本,运行时 DDL 已覆盖 `$executeRawUnsafe + CREATE TABLE` 坏样本;文件级边界分类同样做存在性反查,分类表残留已删除或改名的文件时巡检会失败。
- `scripts/verify-master-data-code-boundaries.js --mode compatibility-snapshot-read` 会输出兼容快照直接读点盘点,并按用途打上分类和迁移优先级,帮助后续逐项把仍可迁走的 `attributes / displaySummary / familySelection / payload` 读点替换为结构化字段。当前基线为 42 处:高优先级 0 处、中优先级 32 处、低优先级 10 处,未知优先级 0 处,未分类为 0;按规则分组为 `pre-sku-compatibility-snapshot-direct-read` 11 处、`product-sku-attributes-direct-read` 22 处、`non-integrated-payload-direct-read` 9 处。报告固定输出 `compatibilitySnapshotReadByCategory`、`compatibilitySnapshotReadByRuleId`、`compatibilitySnapshotReadByFile`、`compatibilitySnapshotReadByPriority`、`compatibilitySnapshotReadSummary`、`compatibilitySnapshotReadBlockingRisks`、`compatibilitySnapshotReadBlockingRiskCount`、`unusedCompatibilitySnapshotReadClassifications` 和 `unusedCompatibilitySnapshotReadClassificationCount`,其中高优先级、未分类读点或未使用分类都会使专项模式以非 0 状态退出;`skippedStructuredPayloadReads` 单独列出已被服务端结构化契约兜底的资料库 payload 扩展字段读点,当前为 8 处。
- `scripts/verify-master-data-code-boundaries.js --mode process-snapshot-read` 会输出准 SKU 过程快照直接读点盘点,并按用途打上分类和优先级。当前基线为 35 处:高优先级 0 处、中优先级 31 处、低优先级 4 处,未知优先级 0 处,未分类为 0;按用途覆盖辅助装配工作区恢复、草稿组只读物料摘要、草稿详情 BOM/安装预览、产品资料只读物料视图、旧产品装配恢复、草稿 DTO 输出、来源规格款 ID 解析和正式入库物料生成。报告固定输出 `processSnapshotReadByCategory`、`processSnapshotReadByRuleId`、`processSnapshotReadByFile`、`processSnapshotReadByPriority`、`processSnapshotReadSummary`、`processSnapshotReadBlockingRisks`、`processSnapshotReadBlockingRiskCount`、`unusedProcessSnapshotReadClassifications` 和 `unusedProcessSnapshotReadClassificationCount`;高优先级、未分类读点或未使用分类都会使专项模式以非 0 状态退出。
- 草稿组基础资料服务不再从 `PreSku.variantSelection` 过程快照兜底生成型号、规格码、条码、版本号或规格名称;自动建议只来自资产中心结构化快照和安全默认版本号,避免过程快照在基础资料补齐链路里变成第二真相。
- 草稿组基础资料服务已取消从 `PreSku.displaySummary / familySelection` 自动回填结构化基础资料;当前服务层剩余的 `displaySummary` 读点只用于保存基础资料时清理旧基础资料字段,不参与自动建议、冲突检测或结构化落库。
- 产品 SKU 普通编辑链路不再提交或接收 `ProductSKU.attributes`;旧属性袋只保留在 DTO 的 `legacyAttributes` 展示、CAD 兼容载荷和受控兼容维护入口里。删除态回收站 DTO 同步使用 `legacyAttributes`,避免前端或外部调用把回收站 `attributes` 误当成正式属性袋。软删除生命周期身份已迁到 `ProductSKU.originalSkuCode / deletedCodeAt / deletedByProductId / productDeletedAt`,迁移会清理旧 JSON 生命周期元数据,数据库约束禁止后续再写回 attributes。
- 产品级恢复 SKU 时只读取结构化 `ProductSKU.deletedByProductId` 判断是否属于本次产品级联删除;旧 `attributes._deletedByProductId` 只作为清理对象,不再参与恢复归属判断。旧 CAD 兼容字段 `_skuCadData / _cadExcludedHoles / _cadExcludedHolesCount` 已收拢到 `sku-cad-data` helper,页面层不再手写 JSON 属性拆解。
- 高优先级快照读点已清零。集成草稿转产品资料的 `brand / spuCode / seriesName` 推导已从旧 `displaySummary / familySelection` 收口到 `PreSkuBasicInfo.formalSkuCode` 和草稿组结构化系列字段;旧 SKU 删除/恢复所需的 `attributes` 生命周期证据已集中到 `sku-soft-delete.helper`;旧 SKU 编辑弹窗不再直接读取 `attributes` 作为正式编辑主数据;权限判断也已从旧 payload 人员字段收口到结构化 profile 和明确列。
- 资产 CAD 导入、资产中心仓储、正式产品/SKU 仓储、基础物料、库位和规划模板查询已同步执行核心身份归一;小写或带首尾空格的手填编码不会先绕过服务查重再依赖数据库约束报错。
- `scripts/verify-master-data-foundation.js` 会检查 290 个数据库物理护栏,确认结构化主数据不是只有读写代码约定,而是有数据库层唯一性、规范化 check、表达式唯一索引、JSON 形态约束、主数据质量决策审计约束、审批流过程数据约束、人员组织身份约束、正式产品分享/上传/协作者权限约束、旧 CAD 结构约束、基础资料/基材选项/匹配记忆/绑定关系/旧包装方案约束、配置/权限/审计过程数据约束和软删除释放机制兜底。
- `SpuNumberCounter_productCategory_key / productCategory_normalized_key` 与 `IntakeReferenceLibraryItem_spu_rule_name_non_empty_key / spu_rule_name_normalized_key` 已纳入数据库物理护栏,SPU 正式号段按产品品类一对一锁定计数,且不能用首尾空格或大小写差异制造第二个号池;`SPU_RULE` 的结构化 `code/name` 只描述规则来源和兼容展示,前端缓存不能再作为多人正式占号真相。
- `/api/intake/spu-reservations` 已阻断单独占号,`spuNumberReservationService` 也只保留事务内分配入口;正式 SPU 号段只能在非集成产品保存事务里推进,避免 API 或服务旁路造成号段游标和产品资料落库不一致。
- 资料库保存入口要求 `code` 非空,并会把配件、包装材料和字母库兼容 `payload` 中的身份字段同步为结构化 `code/name/letter`;payload 只允许承载扩展资料,不能用旧 `payload.item.code/name/letter` 覆盖数据库列。
- 当前 advisory 为 0;代码层仍保留本地存储归类硬护栏,任何新增资料库型 `localStorage/sessionStorage` 若未归类或未接入服务端契约,巡检会阻断。
- `IntakeReferenceLibraryItem` 当前可能为 0 条是预期状态:本轮先建立服务端承接和约束,并已让包装材料、配件、封边、钢架、SPU 规则库和通用字母库组件优先接入服务端保存;历史本地资料不会被静默上传为团队真相。
历史清洗策略:
1. 先保持 payload 原快照,避免破坏历史过程证据。
2. 以结构化表为后续巡检、外部视图和自动流转判断依据。
3. 迁移前先跑预检 SQL;如果历史 JSON 已有重复 SKU 或重复条码,必须先清洗,不让迁移静默选边。
4. 对 `INCOMPLETE` 行补齐 `sku_code / spec_name / model` 后再切换为 `READY`。
5. 若发现重复 SKU 或重复条码,先合并或废弃历史脏行,再允许进入后续流转。
6. 清洗必须使用显式计划文件,不允许直接写临时 SQL 猜值;脚本会同步结构化表和 payload 快照,并在事务提交前复用主数据巡检漂移断言;清洗后仍要跑 `npm run db:verify-master-data:all` 验证差异归零。
7. 清洗计划里的空字符串不会写入结构化主数据;`productDevelopmentKind` 只能填写 `product / series`,不能用临时中文备注或默认值冒充正式开发类型。
8. 清洗计划会比对模板 `current` 和当前数据库值,过期模板直接失败;只允许补当前仍为空的主数据字段,不能覆盖已存在资料。`--apply` 后仍未补齐的计划项必须显式写明 `allowIncomplete` 和原因,并写入 `MasterDataQualityDecision`;但这只是审计记录,`INCOMPLETE` 仍然不能进入外部 ready 视图或正式流转。
9. 非集成 SKU 清洗里的 `skuCode / barcode` 写入前统一 `trim + uppercase`,冲突检测统一使用 `upper(btrim(...))`;脚本层语义必须和数据库表达式唯一索引一致,不能把数据库约束当成第一道校验。
10. 正式 SKU 软删除编码释放已补充单测护栏,重复释放删除态记录时只重建单层 `__deleted__` 墓碑编码,并使用结构化 `originalSkuCode` 作为恢复依据;旧 `_originalSkuCode` 只作为历史回填和清理来源。恢复出的原始 SKU 编码会统一 `trim + uppercase`,释放删除态记录按大小写不敏感匹配。
11. 正式 SKU 资产 profile 的条码唯一池已绑定 SKU 生命周期:`skuDeletedAt IS NULL` 才参与活动条码唯一约束,删除态 SKU 不再占用活动条码池;恢复时若条码已被其他活动 SKU 使用则阻断。
12. SKU 墓碑编码构造/解析/事务内释放统一走 `sku-soft-delete.helper`;旧 CAD Product 导入、正式入库和上传链都已接入删除态编码释放,避免兼容入口绕过 SKU 编码池治理。
## 4. 剩余不完整记录
当前脱敏摘要只记录数量:仍有 2 条活动非集成 SKU 明细处于 `INCOMPLETE`。具体产品名、SPU 和行级 ID 只保存在本地 `.master-data-audits/` 敏感报告中,不能写入协作文档或提交 Git。
这些记录缺的是:
- `sku_code`
- `spec_name`
- `model`
## 5. 地基判断
这说明当前内部主链不是“全乱”,而是:
- 正式主链已有数据库唯一约束、结构化资产资料副本和巡检项覆盖。
- 非集成 SKU 主数据已有结构化承接和巡检基线。
- 真正需要继续收口的是非集成开品里的历史草稿资料完整性。
- 后续治理重心应该放在“非集成录入链路必填、自动生成、冲突检测、历史脏数据清洗、payload 同步差异归零保持”。
## 6. 规范审查
结论:`passed with warnings`
这是自审,不是独立复审。
- 架构:巡检基线已改为围绕 `IntakeNonIntegratedSkuItem` 结构化承接判断,不改变写入链路。
- 视觉:本次不涉及前端视觉。
- 根因:把非集成 SKU 从“payload 里发现问题”推进到“结构化表承接后剩余历史质量状态”。
- 验证:已用 `npm run db:verify-master-data` 复跑迁移 dry-run,确认结构化 SKU 总数 12、`READY` 10、`INCOMPLETE` 2、重复 SKU/条码 0、payload 同步差异 0。
- 警告:这是本地开发库基线,不等同于生产库结果;治理清单里的结构迁移必须纳入真实落库验收。生产迁移前仍要先跑预检 SQL、完整 dry-run、真实迁移和 strict 验收,最后还要让 hard-ready 质量债归零。

View File

@ -0,0 +1,99 @@
# 外部只读契约巡检说明
创建时间:2026-05-29
关联脚本:`scripts/sql/check-external-readonly-contract.sql`
本地基线:`docs/架构设计/数据库质量巡检基线-20260529.md`
## 1. 目的
外部系统接入前,不能只确认“数据库能连上”。还要确认它只能看到稳定的 `external` 契约层,不能读到内部业务原表、整段 `payload Json`、权限字段或素材内部路径。
这个巡检脚本用于部署后检查:
- `external.*_v1` 视图是否齐全。
- `external.intake_*_ready_v1` 是否存在,外部稳定消费是否能避开不完整草稿资料。
- 关键字段是否齐全。
- 正式 SKU、非集成产品和非集成 SKU 是否都暴露稳定业务键,避免外部系统误用内部技术 ID 做同步身份。
- `external_reader` 是否被误授权到 `public` schema。
- `external_reader` 在 `external` schema 下是否只有 `SELECT` 权限。
- 对外字段里是否混入 `payload`、权限、内部路径、密钥类字段名。
- `external` 视图定义内部是否仍依赖 `payload / attributes / businessData / displaySummary / familySelection` 等 JSON 快照源。
- 正式 SKU、资产规格款、非集成 SKU 的关键字段是否存在明显空值。
- 正式 SKU 和非集成 ready 视图的业务键是否缺失或重复。
- 正式产品素材和 BOM 明细视图是否携带可关联的稳定业务键。
- 素材 URL 是否仍有不适合长期对外同步的 `data:` 形态。
- 非集成 SKU 明细是否已经达到 `READY`,还是仍有 `INCOMPLETE` 草稿数据。
- 内部主数据巡检还会检查非集成产品 profile 与 `payload/openingReference` 是否漂移;外部视图只读 profile,不能因为 payload 有旧值就让外部系统绕回内部 JSON。
## 2. 运行方式
先执行迁移,创建 `external` schema 和第一版视图。然后在目标数据库中运行:
```bash
npx prisma db execute --file scripts/sql/check-external-readonly-contract.sql
```
如果要在生产库执行,先确认当前部署流程已经完成数据库迁移,并且使用的是只读巡检脚本,不要在排查时顺手改业务数据。
## 3. 结果怎么看
下面这些检查结果应该为空:
- `missing_external_view`
- `missing_external_column`
- `external_contract_private_column_risk`
- `external_view_json_source_dependency_risk`
- `external_reader_public_schema_grant_risk`
- `external_reader_non_select_grant_risk`
- `product_assets_data_url_risk`
- `product_sku_business_key_duplicate_risk`
- `intake_product_business_key_duplicate_risk`
- `intake_sku_business_key_duplicate_risk`
- `product_assets_business_key_missing_risk`
- `product_bom_business_key_missing_risk`
下面这些检查结果是计数项,不一定要求马上为 0,但要进入数据质量治理台账:
- `product_skus_blank_key_fields`
- `asset_variants_blank_key_fields`
- `intake_sku_items_blank_key_fields`
- `intake_sku_items_quality_status_count`
- `intake_ready_business_key_missing_risk`
如果关键编码、名称、型号大量为空,说明外部系统虽然能读数据,但读到的是不完整资料,不能直接进入稳定对接阶段。
## 4. 当前边界
这个脚本只做巡检,不创建用户、不授权、不修数据。
发现问题后的处理原则:
- 缺视图或缺字段:先检查迁移是否漏跑,不要让外部系统临时改读业务表。
- 授权越界:撤回 `public` schema 或非 `SELECT` 权限,再重新执行授权脚本。
- 私有字段风险:从视图里移除字段,必要时新增白名单字段。
- 视图 JSON 源依赖风险:先判断该字段是否应该结构化;不能只改外部列名来绕过巡检。
- 业务键缺失或重复:回到对应真相源修复 `Product.modelNumber / ProductSKU.skuCode / IntakeNonIntegratedProduct.brand+spu / IntakeNonIntegratedSkuItem.skuCode`,不能让外部系统临时用技术 ID 兜底;如果素材或 BOM 明细缺业务键,优先修视图关联链,不要让外部系统自行跨表补。
- 关键字段为空:回到资产中心、产品资料或非集成录入链路修源数据。
- `data:` 素材:逐步迁移到文件 URL 或受控下载 API。
- `INCOMPLETE` 非集成 SKU:外部系统不能自动入库,先由产品资料链路补齐或确认废弃;如果业务确认分阶段补齐,原因只写入 `MasterDataQualityDecision` 做审计,不改变 ready 视图过滤规则。
- 正式产品视图行数为 0:先判断目标库是否确实尚未产生正式 SKU,不能直接当作视图异常。
当前 `external.intake_products_v1` 必须读取 `IntakeNonIntegratedProductProfile`,`external.intake_sku_items_v1` 必须读取 `IntakeNonIntegratedSkuItem`。如果巡检再次出现 `payload` 或 `jsonb_array_elements` 依赖,优先修外部视图或补结构化承接,不允许让外部系统改读内部 JSON。
内部代码层还要同步跑 `node scripts/verify-master-data-code-boundaries.js --mode compatibility-snapshot-read`。外部视图不依赖 JSON 只能说明“对外出口没有绕回快照”,而专项盘点用于确认内部 DTO、Repository、页面 helper 没有重新把 `attributes / payload / displaySummary / familySelection` 当成主读;高优先级或未分类读点会以阻断风险计数暴露。
`external.intake_products_v1 / external.intake_sku_items_v1` 是完整对账视图,允许暴露 `INCOMPLETE` 状态用于治理;外部系统正式同步优先读取 `external.intake_products_ready_v1 / external.intake_sku_items_ready_v1`,避免把缺品类、缺开发类型或缺 SKU 主字段的数据当作可用商品资料。
`MasterDataQualityDecision` 不对外作为商品资料视图开放;它只是内部审计谁确认了暂不完整、缺哪些字段、为什么允许分阶段清洗。外部系统仍只能把 `READY` 当自动消费前提。
`npm run db:verify-master-data:all` 已把核心外部契约风险纳入硬失败:外部核心视图缺失、外部视图依赖内部 JSON、非集成产品负责人兼容列未优先读取结构化 profile、正式 SKU 视图业务键缺失或重复、正式产品素材/BOM 明细视图业务键缺失、ready SKU 视图混入非 READY 明细、ready 视图业务键缺失或重复时,命令必须失败。独立 SQL 巡检仍保留给人工查库和外部审计使用。
## 5. 规范审查
结论:`passed`
这是自审,不是独立复审。
- 架构:巡检脚本强化了 `external` 契约层,不反向依赖内部原表。
- 视觉:本次不涉及前端视觉。
- 根因:把“外部只读是否安全”变成可运行检查,不靠口头约定。

View File

@ -0,0 +1,303 @@
# 外部只读视图字段字典 v1
创建时间:2026-05-29
关联迁移:
- `prisma/migrations/20260529213000_add_external_readonly_contract/migration.sql`
- `prisma/migrations/20260530093000_add_intake_non_integrated_sku_items/migration.sql`
- `prisma/migrations/20260530143000_tighten_external_readonly_view_sources/migration.sql`
- `prisma/migrations/20260530150000_add_intake_non_integrated_product_profile/migration.sql`
- `prisma/migrations/20260531103000_tighten_external_intake_views_to_structured_sources/migration.sql`
关联巡检:`scripts/sql/check-external-readonly-contract.sql`
## 1. 使用原则
- 外部系统只读取 `external` schema 下的视图。
- 外部系统不读取 `public` schema 下的业务原表。
- `v1` 视图只承诺字段稳定,不承诺内部来源表不变。
- 素材字段统一输出 URL,不开放服务器文件系统路径。
- `payload Json` 只做白名单投影,不整体开放。
## 2. 视图清单
| 视图 | 用途 | 主要来源 |
| --- | --- | --- |
| `external.product_skus_v1` | 正式产品 SKU 主数据 | `Product`、`ProductSKU`、`ProductSkuAssetProfile`、`ProductCategory`、`WarehouseLocation` |
| `external.product_assets_v1` | 正式 SKU 图片/模型/缩略图 URL | `ProductSKU.images`、`ProductSKU.modelUrl`、`ProductSkuAssetProfile.imageUrl`、`Product.thumbnailUrl` |
| `external.product_bom_v1` | 正式产品板件和五金摘要 | `Panel`、`PanelMaterial`、`ProductHardware`、`Hardware` |
| `external.intake_products_v1` | 非集成开品主信息白名单 | `IntakeNonIntegratedProduct`、`IntakeNonIntegratedProductProfile` |
| `external.intake_sku_items_v1` | 非集成 SKU 明细白名单 | `IntakeNonIntegratedSkuItem`、`IntakeNonIntegratedProduct` |
| `external.intake_products_ready_v1` | 非集成开品主信息稳定消费口径 | `external.intake_products_v1` |
| `external.intake_sku_items_ready_v1` | 非集成 SKU 明细稳定消费口径 | `external.intake_sku_items_v1` |
| `external.asset_variants_v1` | 资产中心规格款主数据 | `AssetVariant`、`AssetFamily`、`AssetSeries`、`Material` |
| `external.materials_v1` | 花色/材质基础资料 | `Material`、`MaterialCategory` |
## 3. 正式 SKU:`external.product_skus_v1`
核心用途:给外部系统读取正式商品 SKU。
关键字段:
| 字段 | 含义 |
| --- | --- |
| `product_id` | 正式产品 ID,内部追踪用技术键,不作为外部业务身份 |
| `product_model_number` | 正式产品型号/SPU |
| `product_business_key` | 外部稳定产品业务键,规则为正式产品型号/SPU 的规范化大写值 |
| `product_name` | 产品名称 |
| `product_category_name` | 产品分类 |
| `sku_id` | SKU ID,内部追踪用技术键,不作为外部业务身份 |
| `sku_code` | SKU 编码 |
| `sku_business_key` | 外部稳定 SKU 业务键,规则为 SKU 编码的规范化大写值 |
| `sku_name` | SKU 名称,优先来自 `ProductSKU.name` |
| `price` | 售价 |
| `cost_price` | 成本价 |
| `stock` | 库存 |
| `record_status` | 外部统一状态:`ACTIVE` / `INACTIVE` |
| `model_url` | SKU 或产品模型 URL |
| `image_url` | 主图 URL |
| `image_urls` | SKU 图片 URL 数组 |
| `source_pre_sku_id` | 来源准 SKU ID |
| `source_asset_variant_id` | 主来源规格款 ID |
| `source_asset_variant_ids` | 全部来源规格款 ID |
| `asset_family_code` | 来源资产族编码 |
| `spec_code` | 来源规格款编码 |
| `barcode` | 条码 |
| `size_json` | 尺寸 JSON |
| `color` | 颜色/花色 |
| `material` | 材质 |
| `source_status` | 来源资产状态 |
| `synced_at` | 资产资料同步时间 |
注意:
- 该视图过滤已软删除的 `Product` 和 `ProductSKU`。
- 外部系统读取正式商品资料时优先使用该视图。
- `product_id / sku_id` 只用于内部追踪和问题定位;外部系统同步、去重和幂等更新必须优先使用 `product_business_key / sku_business_key`。
- 资产资料字段来自 `ProductSkuAssetProfile`;`assetSkuSnapshot` 只保留为内部兼容快照,不作为该视图主读来源。
## 4. 正式素材:`external.product_assets_v1`
核心用途:给外部系统读取 SKU 图片、模型、缩略图 URL。
关键字段:
| 字段 | 含义 |
| --- | --- |
| `asset_type` | `SKU_IMAGE` / `SKU_MODEL` / `SKU_THUMBNAIL` |
| `product_id` | 产品 ID,内部追踪用技术键,不作为外部业务身份 |
| `sku_id` | SKU ID,内部追踪用技术键,不作为外部业务身份 |
| `product_business_key` | 外部稳定产品业务键,规则为正式产品型号/SPU 的规范化大写值 |
| `sku_business_key` | 外部稳定 SKU 业务键,规则为 SKU 编码的规范化大写值 |
| `asset_url` | 可访问素材 URL |
| `file_name` | 文件名,当前为空,后续可补 |
| `mime_type` | MIME 类型,当前为空,后续可补 |
| `sort_order` | 排序 |
注意:
- 该视图只输出 URL,不输出磁盘路径。
- 外部系统关联素材时必须优先使用 `product_business_key / sku_business_key`,`product_id / sku_id` 仅用于内部追踪。
- 后续如需要签名 URL,应在 API 层做,不直接改原表。
## 5. 正式 BOM 摘要:`external.product_bom_v1`
核心用途:给外部系统读取正式产品板件和五金摘要。
关键字段:
| 字段 | 含义 |
| --- | --- |
| `item_type` | `PANEL` / `HARDWARE` |
| `product_id` | 产品 ID,内部追踪用技术键,不作为外部业务身份 |
| `sku_id` | SKU ID,可为空,内部追踪用技术键,不作为外部业务身份 |
| `product_business_key` | 外部稳定产品业务键,规则为正式产品型号/SPU 的规范化大写值 |
| `sku_business_key` | 外部稳定 SKU 业务键;当 BOM 行不绑定 SKU 时可为空 |
| `item_id` | 物料行 ID |
| `item_code` | 物料编码 |
| `item_name` | 物料名称 |
| `quantity` | 数量,第一版统一为 1 |
| `unit` | 单位,第一版统一为 `piece` |
| `material_code` | 板件材质编码 |
| `material_name` | 板件材质名称 |
| `dimensions` | 尺寸 JSON |
| `unit_cost` | 单价,第一版为空 |
| `total_cost` | 总价,第一版为空 |
注意:
- 第一版重点是结构稳定,成本口径后续单独治理。
- 外部系统关联 BOM 时必须优先使用 `product_business_key / sku_business_key`,`product_id / sku_id` 仅用于内部追踪。
- 孔位数据暂不进入普通 BOM 视图,避免把制造/3D 数据混进商品资料。
## 6. 非集成开品主信息:`external.intake_products_v1`
核心用途:给外部系统读取非集成开品主信息。
关键字段:
| 字段 | 含义 |
| --- | --- |
| `intake_product_id` | 非集成产品记录 ID,内部追踪用技术键,不作为外部业务身份 |
| `spu` | 产品编码 |
| `spu_base` | 基础编码 |
| `brand` | 品牌 |
| `product_business_key` | 外部稳定产品业务键,规则为 `品牌:SPU` 的规范化大写组合 |
| `product_name` | 产品名称 |
| `product_image_url` | 产品主图 URL |
| `product_category` | 产品品类 |
| `sales_category` | 销售分类 |
| `product_year` | 年份 |
| `product_status` | 产品阶段/状态 |
| `designer` | 设计负责人 |
| `operator` | 运营负责人 |
| `purchaser` | 采购负责人 |
| `product_planning` | 商品企划负责人 |
| `visual_planning` | 空间/视觉负责人 |
| `structure_designer` | 结构设计负责人 |
| `purchase_owner` | 采购负责人结构化字段 |
| `product_development_kind` | 开发类型 |
| `goods_type` | 商品类型 |
| `material_type` | 材料类型 |
| `production_type` | 生产类型 |
注意:
- 该视图过滤已软删除记录。
- 产品名称、图片、品类、年份、状态、开发类型、货品类型、材质类型、生产类型和结构化负责人字段来自 `IntakeNonIntegratedProductProfile`,不再直接读取 `payload`。
- `designer / operator / purchaser` 是兼容列,优先输出 profile 中的结构设计、商品企划、采购负责人;旧产品行列只作为兼容兜底。
- `intake_product_id` 只用于内部追踪和问题定位;外部系统同步、去重和幂等更新必须优先使用 `product_business_key`。
- 不开放 `visibility`、`departmentId`、`teamId`、审批状态、内部确认状态。
- `external.intake_products_ready_v1` 使用同一字段结构,但只保留产品名称、品类、开发类型和 `product_business_key` 都已补齐的记录。外部系统做稳定同步时优先读取 ready 视图;`intake_products_v1` 用于治理对账和人工补齐。
## 7. 非集成 SKU 明细:`external.intake_sku_items_v1`
核心用途:给外部系统读取非集成开品中的 SKU 明细。
主数据来源:
- 第一来源是 `IntakeNonIntegratedSkuItem` 结构化表。
- `IntakeNonIntegratedProduct.payload.skuItems` 只保留为历史过程快照和同步校验来源,不再作为外部只读视图的直接主数据源。
- 结构化表通过 `productId` 关联非集成开品主记录,通过 `skuItemId` 保留原 SKU 明细身份。
关键字段:
| 字段 | 含义 |
| --- | --- |
| `intake_product_id` | 非集成产品记录 ID,内部追踪用技术键,不作为外部业务身份 |
| `spu` | 产品编码 |
| `brand` | 品牌 |
| `product_business_key` | 外部稳定产品业务键,规则为 `品牌:SPU` 的规范化大写组合 |
| `intake_sku_item_id` | SKU 明细 ID,内部追踪用技术键,不作为外部业务身份 |
| `sku_business_key` | 外部稳定 SKU 业务键,规则为 `品牌:SPU:SKU编码` 的规范化大写组合 |
| `sku_sequence` | SKU 序号 |
| `batch_no` | 批次号 |
| `image_url` | SKU 图片 |
| `image_name` | SKU 图片文件名/显示名 |
| `model` | 型号 |
| `sku_code` | SKU 编码 |
| `spec_name` | 规格名称 |
| `barcode` | 条码 |
| `size` | 尺寸 |
| `color` | 颜色/花色 |
| `material` | 材质 |
| `version_no` | 版本号 |
| `attribute_code` | 属性编码 |
| `drawing_id` | 图纸 ID |
| `install_method` | 安装方式 |
| `execute_standard` | 执行标准 |
| `manufacturer` | 供应商/厂家 |
| `manufacturer_code` | 供应商/厂家编码 |
| `sales_status` | 销售状态 |
| `model_3d_url` | 3D 模型 URL |
| `source` | 来源说明 |
| `source_table` | 来源表或来源系统 |
| `source_sku_id` | 来源 SKU ID |
| `missing_required_fields` | 缺失的关键字段数组 |
| `data_quality_status` | 数据质量状态:`READY` / `INCOMPLETE` |
| `content_locked` | 内容是否锁定 |
| `sort_order` | 展示顺序 |
注意:
- 数字字段做了安全转换,脏数据不会导致视图报错。
- `payload.skuItems` 之外的流程字段不开放;结构化表之外的内部流程字段也不开放。
- `intake_product_id / intake_sku_item_id` 只用于内部追踪和问题定位;外部系统同步、去重和幂等更新必须优先使用 `product_business_key / sku_business_key`。
- 非空 `sku_code` 和非空 `barcode` 由内部结构化表做唯一性治理,外部系统仍应把重复巡检结果作为接入前置风险信号。
- `data_quality_status = INCOMPLETE` 的 SKU 明细不能当作完整产品资料交给外部系统自动入库。
- `missing_required_fields` 当前至少覆盖 `sku_code / spec_name / model`,后续如提高必填基线,需要同步更新字段字典和巡检脚本。
- 内部正式流转入口也按同一质量规则拦截,例如产品定厂合同生成会拒绝目标批次内的 `INCOMPLETE` SKU;外部系统接入时必须把 `data_quality_status = READY` 作为自动入库或自动合同流转的前置条件。
- `external.intake_sku_items_ready_v1` 使用同一字段结构,但只保留 `data_quality_status = READY` 且 `sku_business_key` 已补齐的 SKU 明细,并要求所属产品同时存在于 `external.intake_products_ready_v1`。外部系统做自动同步时优先读取 ready 视图;完整视图只用于治理对账和人工补齐。
## 8. 资产规格款:`external.asset_variants_v1`
核心用途:给外部系统读取资产中心规格款。
关键字段:
| 字段 | 含义 |
| --- | --- |
| `asset_variant_id` | 规格款 ID |
| `spec_code` | 规格款编码 |
| `spec_name` | 规格款名称 |
| `asset_type` | 资产类型 |
| `asset_status` | 资产状态 |
| `asset_family_code` | 资产族编码 |
| `asset_series_code` | 系列编码 |
| `product_category_name` | 产品分类 |
| `dimensions` | 尺寸 JSON |
| `model_url` | 模型 URL |
| `thumbnail_url` | 缩略图 URL |
| `barcode` | 条码 |
| `supplier_name` | 供应商 |
| `supplier_code` | 供应商编码 |
| `version_no` | 版本号 |
| `finish_code` | 花色编码 |
| `finish_name` | 花色名称 |
| `material_label` | 材质显示名 |
| `weight_kg` | 重量 |
| `cost_price` | 成本 |
| `estimated_cost_price` | 预估成本 |
注意:
- 过滤已软删除规格款。
- 第一版不过滤 `asset_status`,外部系统可按 `asset_status` 判断;如后续只开放可用资产,可改为只输出 `AVAILABLE`。
## 9. 花色/材质:`external.materials_v1`
核心用途:给外部系统读取花色/材质基础资料。
关键字段:
| 字段 | 含义 |
| --- | --- |
| `material_id` | 材质 ID |
| `material_code` | 花色/材质编码 |
| `material_name` | 名称 |
| `category_name` | 分类 |
| `thumbnail_url` | 缩略图 |
| `texture_url` | 纹理贴图 |
| `normal_map_url` | 法线贴图 |
| `base_color` | 基础颜色 |
| `metalness` | 金属度 |
| `roughness` | 粗糙度 |
| `opacity` | 透明度 |
| `record_status` | `ACTIVE` / `INACTIVE` |
## 10. 版本策略
- `v1` 字段上线后,不做破坏性改名。
- 新增字段可以加在 `v1`。
- 删除字段、改变含义、改变主键口径时,新增 `v2`。
- 外部系统迁移完成前,保留旧版本视图。
- 每次调整 `external.*_v1` 字段后,同步更新字段字典和巡检脚本的必需字段清单。
## 11. 规范审查
结论:`passed`
这是自审,不是独立复审。
- 架构:字段字典对应 `external` 只读视图,不鼓励外部系统读业务原表。
- 视觉:本次不涉及前端视觉。
- 根因:从数据契约层隔离内部混乱,不再靠口头约定避免误读。

View File

@ -0,0 +1,315 @@
# 数据库主数据治理与外部只读契约
创建时间:2026-05-29
适用范围:`furniture-3d-system` 主业务数据库、产品资料链路、外部系统只读接入。
配套文档:
- `docs/架构设计/外部只读视图字段字典-v1.md`
- `docs/架构设计/外部只读契约巡检说明.md`
- `docs/架构设计/数据库质量巡检基线-20260529.md`
- `docs/架构设计/内部主数据地基治理方案.md`
- `docs/架构设计/内部主数据巡检基线-20260529.md`
- `scripts/sql/grant-external-readonly.sql`
- `scripts/sql/preflight-intake-non-integrated-sku-items.sql`
- `scripts/sql/check-external-readonly-contract.sql`
- `scripts/sql/check-internal-master-data-foundation.sql`
- `scripts/verify-master-data-foundation.js`
## 1. 结论
多个外部系统会依赖本系统数据库时,不能直接开放业务原表。本系统必须先把内部主数据真相源梳理清楚,再通过稳定的外部契约层对外开放。
固定原则:
- PostgreSQL 是本系统主业务数据库。
- 数据库保存业务数据、文件 URL、对象 key、元数据;图片、模型、附件等素材本体不直接存入数据库。
- 外部系统只能读专用只读契约层,不能直接读 `ProductSKU`、`IntakeNonIntegratedProduct.payload` 等业务原表。
- 外部系统同步、去重和幂等更新必须优先使用契约层里的稳定业务键,不能把内部 `id / product_id / sku_id / intake_product_id / intake_sku_item_id` 当业务身份。
- 内部业务表允许随系统演进调整;外部契约必须版本化、稳定、可追溯。
- 对外开放字段必须白名单化,默认不开放内部流程、权限、草稿、审批、临时 UI 状态。
## 2. 当前数据库与素材现状
### 2.1 主业务库
当前 Prisma datasource 使用 `postgresql`。项目运行时通过 `@prisma/adapter-pg` 和 `pg` 连接池访问 PostgreSQL。
`mysql2` 只用于 JST/聚水潭等外部库读取同步,例如 SKU 库存、供应商和仓库目录读取。它不是本项目主业务库。外部 MySQL 同步写入本系统前必须先确认来源库身份和目标 PostgreSQL 身份,不能把 MySQL 当作第二套内部主数据真相。
### 2.2 素材存放
数据和素材是分开存放的。
当前主要形态:
- 业务表中保存 `modelUrl`、`thumbnailUrl`、`images`、`fileUrl`、`storageKey` 等引用。
- 非集成产品附件默认存放在 `.data/non-integrated-assets`,通过 `/api/intake/non-integrated/assets/...` 受权限控制读取。
- 旧附件可能仍在 `public/non-integrated-test-assets`。
- 资产缩略图、AI 生图等服务可通过 S3/MinIO 工具链上传,并在数据库保存公开 URL。
外部系统不应拿服务器文件系统权限。正确方式是读取数据库契约层里的 URL,再通过受控 HTTP 地址访问素材。
## 3. 主链数据真相源
### 3.1 资产中心
资产中心负责资产源数据。
| 数据对象 | 当前主表 | 真相源定位 | 对外开放建议 |
| --- | --- | --- | --- |
| 系列 | `AssetSeries` | 资产系列主数据 | 可开放编码、名称、分类、启用状态 |
| 资产族 | `AssetFamily` | 同系列下的资产族主数据 | 可开放编码、名称、类型、系列归属、状态 |
| 规格款 | `AssetVariant` | 具体可调用资产规格 | 可开放编码、名称、条码、尺寸、颜色、材质、图片、模型、重量、成本 |
| 花色/材质 | `Material` | 板材花色和材质配置 | 可开放编码、名称、贴图/缩略图、PBR 参数中的必要字段 |
| 五金件 | `Hardware` | 标准五金主数据 | 可开放编码、名称、分类、规格、模型、缩略图、尺寸 |
注意:
- `AssetVariant` 是资产中心里最接近后续 SKU 资料来源的结构化表。
- `AssetVariant.businessData`、`assemblyBasis`、`compatibilityRules` 等 JSON 字段不能整体对外开放,需要按字段白名单投影。
- 已删除、草稿、审核中资产不能默认对外开放,必须按状态过滤。
### 3.2 辅助装配与草稿组
辅助装配结果进入草稿组。草稿组是正式入库前的承接层,不是最终正式产品真相。
| 数据对象 | 当前主表 | 真相源定位 | 对外开放建议 |
| --- | --- | --- | --- |
| 草稿组 | `DraftGroup` | 一次辅助装配结果集合 | 默认不作为外部主数据,可按内部管理需要开放只读摘要 |
| 准 SKU | `PreSku` | 入库前的准 SKU 快照 | 默认不直接对外开放;正式入库后以 `ProductSKU` 为准 |
| 准 SKU 包装 | `PreSkuPackagingPlan` | 入库前包装草案 | 默认不直接对外开放;正式化后再进入正式产品/轻仓库契约 |
注意:
- `PreSku.familySelection`、`variantSelection`、`displaySummary`、`bomPreview`、`packagingSnapshot` 都是 JSON 快照,适合内部承接,不适合原样对外。
- 外部系统如果需要看“待入库草稿”,应单独设计 `external.draft_pre_skus_v1`,并明确它不是正式商品资料。
### 3.3 正式产品资料
正式产品资料以 `Product` 和 `ProductSKU` 为核心。
| 数据对象 | 当前主表 | 真相源定位 | 对外开放建议 |
| --- | --- | --- | --- |
| 正式 SPU/型号 | `Product` | 正式产品型号主数据 | 可开放型号、名称、分类、缩略图、模型、状态 |
| 正式 SKU | `ProductSKU` | 正式产品 SKU 主数据 | 必须作为外部产品资料的核心出口;`skuCode / name` 为结构化主字段 |
| 正式 SKU 资产资料副本 | `ProductSkuAssetProfile` | 正式 SKU 对资产中心资料的结构化同步副本 | 开放型号、规格码、条码、尺寸、颜色、材质、图片、来源状态 |
| SKU 板件 | `Panel` | SKU/产品结构件 | 需要时开放结构件编号、名称、尺寸、材质摘要 |
| SKU 孔位 | `Hole` | SKU/产品孔位结构 | 仅向需要 3D/制造数据的系统开放,不作为普通商品资料 |
| SKU 五金 | `ProductHardware` | SKU/产品五金用量和位置 | 可按 BOM 需求开放编码、数量、安装关系摘要 |
| 包装方案 | `PackagingPlan` / `PackagedItem` | 正式包装与库存承接 | 需单独按轻仓库口径整理 |
`ProductSkuAssetProfile` 保存正式 SKU 的资产资料同步副本,包括资产来源 ID、型号、规格码、条码、尺寸、颜色、材质、图片、来源状态等。`ProductSKU.attributes.assetSkuSnapshot` 仅作为兼容快照保留,不能再作为正式商品资料的主读真相。
代码层边界由 `npm run db:verify-master-data:code` 和 `node scripts/verify-master-data-code-boundaries.js --mode compatibility-snapshot-read` 共同兜底:正式 SKU 的 `attributes`、准 SKU 的 `displaySummary / familySelection`、非集成产品的 `payload` 只能留在已分类的兼容、迁移或过程入口里;高优先级或未分类读点会进入阻断风险,不能作为对外契约或内部主读的依据。
普通正式 SKU 的创建、CAD 导入、产品上传和编辑链路必须写入 `ProductSKU.name`。`attributes.skuName` 只允许作为兼容输入或历史兜底;对外视图、DTO 和轻仓库概览优先读取 `ProductSKU.name`。
外部正式产品契约统一携带稳定业务键:
- `external.product_skus_v1.product_business_key` 来自 `Product.modelNumber` 的规范化大写值。
- `external.product_skus_v1.sku_business_key` 来自 `ProductSKU.skuCode` 的规范化大写值。
- `external.product_assets_v1` 和 `external.product_bom_v1` 也必须携带同一套业务键,避免外部系统为了关联素材或 BOM 被迫使用内部技术 ID。
### 3.4 非集成开品资料
非集成开品数据集中在 `IntakeNonIntegratedProduct`。
| 字段 | 定位 |
| --- | --- |
| `id` | 内部产品记录 ID |
| `spu` / `spuBase` | 产品编码和基础编码 |
| `brand` | 品牌 |
| `designer` / `operator` / `purchaser` | 人员字段 |
| `visibility` / `departmentId` / `teamId` | 权限范围 |
| `payload` | 大型业务资料快照 |
`payload` 包含 SKU、附件、BOM、空间布局、审批状态、文档工作区、权限确认、临时 UI 状态等大量内容。它是当前最大风险点,不能直接开放给外部系统。
当前治理口径:
- `IntakeNonIntegratedSkuItem` 是非集成 SKU 明细的结构化承接表。
- `payload.skuItems` 作为历史过程快照保留,用于同步差异巡检,不作为对外主数据来源。
- `IntakeNonIntegratedProductProfile` 承接产品名称、主图、品类、销售分类、年份、状态、开发类型、货品类型、材质类型、生产类型、主要负责人等产品头主数据。
- `external.intake_products_v1` 读取 `IntakeNonIntegratedProductProfile`,不再直接从 `payload` / `openingReference` 读取产品头主数据。
- `external.intake_products_v1 / external.intake_sku_items_v1` 显式输出稳定业务键:`product_business_key = 品牌:SPU`,`sku_business_key = 品牌:SPU:SKU编码`;ready 视图会要求业务键非空。
- 非空 `skuCode / barcode` 由数据库唯一索引兜底。
- 软删除非集成产品时,后端和数据库触发器都会清理结构化 SKU 明细,避免已删除记录占用编码池。
非集成对外只能做白名单投影,例如:
- 产品基础:`spu`、品牌、产品名称、品类、年份、负责人、状态。
- SKU 明细:图片、型号、SKU 编码、规格名称、条码、尺寸、颜色、材质、版本号、供应商、成本等。
- BOM 摘要:只开放已确认的物料编码、名称、规格、数量、单位、成本字段。
- 素材:只开放可访问 URL、文件名、类型、大小,不开放内部路径和权限状态。
## 4. 当前主要风险
### 风险 1:结构化表和 JSON payload 并存
正式产品链较结构化,非集成开品链大量依赖 `payload Json`。这不是马上不可用,但对外开放时风险很高。
治理要求:
- 外部系统不得直接读取整段 `payload`。
- 必须先沉淀白名单字段。
- 高频外部依赖字段应逐步从 `payload` 治理为结构化列或稳定视图字段。
### 风险 2:同一业务概念存在多个位置
图片、型号、规格码、条码、尺寸、颜色、材质可能同时出现在资产中心、草稿准 SKU、正式 SKU 快照、非集成 payload 中。
治理要求:
- 资产源数据以 `AssetVariant` 为准。
- 草稿准 SKU 基础资料以 `PreSkuBasicInfo` 为准,`displaySummary` 只是展示兼容快照。
- 正式产品对外以 `ProductSKU` 的结构化字段和 `ProductSkuAssetProfile` 副本为准。
- 非集成开品对外以 `IntakeNonIntegratedProductProfile` 和 `IntakeNonIntegratedSkuItem` 的白名单投影结果为准。
- 页面展示字段不能反过来成为外部系统真相源。
- 内部兼容快照读点必须持续通过 `compatibility-snapshot-read` 专项盘点,确保新增 DTO、Repository 或页面 helper 不会绕开结构化字段重新读取 JSON 快照。
### 风险 3:内部流程字段泄露
`payload` 内含审批、确认、上传、文档工作区、权限相关状态。直接开放会把内部流程暴露给外部系统。
治理要求:
- 外部契约层默认排除权限、审批、草稿、删除、锁定原因、内部人员 ID 等字段。
- 需要开放状态时,只开放业务状态摘要,例如 `source_status`、`product_status`、`sku_status`。
### 风险 4:素材 URL 混合来源
当前素材来源可能是 S3/MinIO URL、内部 API URL、旧 public 路径或历史 data URL。
治理要求:
- 外部契约层输出统一的 `asset_url` 字段。
- 内部路径、`storageKey` 默认不对外开放;如外部系统确需同步文件,可单独开放 `asset_key`,并配合签名 URL 或下载 API。
- 历史 data URL 需要逐步迁移成文件 URL。
### 风险 5:外部系统直接绑定内部表结构
如果直接给外部系统 `SELECT` 原始表,一旦内部迁移字段、改 JSON 结构或拆表,外部系统会一起故障。
治理要求:
- 新建独立 `external` schema。
- 所有外部读取只走 `external.*_v1` 视图或后端只读 API。
- 重大变更新增 `v2`,保留 `v1` 兼容窗口。
## 5. 外部只读契约层建议
推荐 PostgreSQL 中新增独立 schema:
```sql
CREATE SCHEMA IF NOT EXISTS external;
```
第一批建议视图:
| 视图 | 用途 |
| --- | --- |
| `external.product_skus_v1` | 正式产品 SKU 主数据 |
| `external.product_assets_v1` | SKU 图片、模型、资产来源、素材引用 |
| `external.product_bom_v1` | 正式 SKU 的板材、组件、五金摘要 |
| `external.intake_products_v1` | 非集成开品主信息白名单 |
| `external.intake_sku_items_v1` | 非集成 SKU 明细白名单 |
| `external.materials_v1` | 花色/材质基础资料 |
| `external.asset_variants_v1` | 可被正式产品引用的资产规格款 |
契约层允许保留内部技术 ID 作为追踪字段,但外部业务关联必须优先使用 `product_business_key / sku_business_key` 等稳定业务键。独立 SQL 巡检和 `npm run db:verify-master-data:all` 都会检查核心外部视图的业务键缺失或重复风险。
只读账号授权原则:
```sql
GRANT USAGE ON SCHEMA external TO external_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA external TO external_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA external
GRANT SELECT ON TABLES TO external_reader;
```
禁止:
- 给外部账号授权 `public` schema 全表读取。
- 给外部账号授权业务原表。
- 给外部账号写权限。
- 把数据库超级用户、应用用户连接串给外部系统。
## 6. 治理优先级
### P0:先定契约,不先大改表
先完成数据字典和视图草案。不要在真相源没定前重构所有表。
产出:
- 核心表职责清单。
- 对外字段白名单。
- 第一版 `external` 视图 SQL 草案。
- 只读账号权限方案。
### P1:正式产品链结构化稳定
把外部系统最需要的数据优先从 `Product / ProductSKU / AssetVariant / Material / Hardware` 组合成稳定视图。
重点字段:
- SPU/型号、SKU 编码、规格名称、规格码、条码。
- 图片、3D 模型。
- 尺寸、颜色、材质。
- 重量、成本、供应商。
- 来源资产 ID、来源状态、同步时间。
### P2:非集成 payload 白名单投影
只对外投影确认后的产品资料和 SKU 明细。不要把 `payload` 的流程数据整体暴露。
重点字段:
- `openingReference` 中的产品基础资料。
- `skuItems` 中的 SKU 明细。
- 已确认 BOM 摘要。
- 附件 URL 摘要。
### P3:逐步治理高频字段
如果某些字段长期被多个系统依赖,就不应该永远藏在 JSON 里。后续应逐步结构化。
候选字段:
- 产品正式编码。
- SKU 条码。
- 规格码和版本号。
- 主图 URL。
- 材质、颜色、尺寸。
- 供应商编码。
- 成本、重量。
## 7. 当前不建议做的事
- 不建议马上把所有 JSON 字段拆表,成本高且容易影响现有业务。
- 不建议直接让外部系统读 `IntakeNonIntegratedProduct.payload`。
- 不建议把素材二进制塞进 PostgreSQL。
- 不建议通过共享应用数据库账号解决只读需求。
- 不建议先开放再补契约文档。
## 8. 下一步执行清单
1. 输出第一版外部字段字典。
2. 写并迁移 `external.product_skus_v1 / product_assets_v1 / product_bom_v1 / intake_products_v1 / intake_sku_items_v1` 等 SQL 视图。
3. 在测试库验证视图字段、稳定业务键和样例数据。
4. 建只读账号权限脚本。
5. 明确素材访问方式:公开 URL、登录态 API、签名 URL 三选一。
6. 和外部系统确认字段命名、增量同步方式、删除/失效状态口径。
7. 再决定是否把高频 JSON 字段结构化。
8. 部署后运行 `scripts/sql/check-external-readonly-contract.sql`,确认视图、字段、授权和关键数据质量。
## 9. 规范审查
结论:`passed`
这是自审,不是独立复审。
- 架构:文档明确了主数据真相源、外部契约层和只读账号边界,没有破坏现有分层。
- 视觉:本次不涉及前端视觉。
- 根因:没有用“直接开放表权限”做表面处理,而是先治理内部数据库地基和对外契约。

View File

@ -0,0 +1,545 @@
# 数据库混乱点治理台账
创建时间:2026-05-29
关联文档:`docs/架构设计/数据库主数据治理与外部只读契约.md`
配套巡检:`scripts/sql/check-external-readonly-contract.sql`、`scripts/sql/check-internal-master-data-foundation.sql`
内部基线:`docs/架构设计/内部主数据巡检基线-20260529.md`
## 1. 总体判断
当前数据库是中度混乱,不是不可用,也不是必须立刻推倒重来。
这里的“中度混乱”指:
- 主业务库类型清楚,PostgreSQL 是唯一主业务库。
- 核心主链能跑通:资产中心 -> 辅助装配 -> 草稿组 -> 产品资料 -> 轻仓库。
- 但多个历史阶段的数据模型并存,同一业务概念可能存在多个位置。
- 有些高价值字段仍在 JSON 快照中,不适合长期作为外部系统依赖。
- 素材引用存在多种形态,需要统一输出口径。
治理目标不是马上大拆表,而是先把真相源、字段归属和外部契约层定住,再逐步把高频字段结构化。
内部地基巡检必须先跑 `check-internal-master-data-foundation.sql`,再看外部契约层。外部只读是结果,不能代替内部治理。
## 2. 混乱等级
| 等级 | 含义 | 当前是否存在 | 处理原则 |
| --- | --- | --- | --- |
| L1 轻微 | 命名不一致、少量历史字段 | 存在 | 文档约束,后续顺手收口 |
| L2 中度 | 多条业务链并存、JSON 快照承载关键字段 | 存在 | 建契约层,逐步结构化 |
| L3 严重 | 主数据真相不清,业务无法判断该读哪张表 | 局部存在 | 先定真相源,再改代码 |
| L4 危险 | 外部系统直接依赖内部原表,导致无法演进 | 暂未正式开放前可避免 | 禁止直接开放原表 |
当前整体处于 L2,局部字段处于 L3。只要外部系统接入前先完成契约层,并让真实数据库迁移通过 strict 验收,就能避免滑向 L4。
截至当前治理轮次,混乱已经不再主要表现为“没人知道该读哪里”,而是集中在两类可验证剩余项:一是治理清单里的结构迁移需要真实落库验收,二是 2 条历史 SKU 明细仍需业务确认后显式清洗。分阶段清洗原因已新增 `MasterDataQualityDecision` 作为数据库审计表,但它不改变 `INCOMPLETE` 的 hard blocker 语义。治理迁移目录已经纳入自检,新增或调整迁移必须登记在 `scripts/master-data-required-migrations.js`,并通过 path、顺序、目标 relation、SQL 引用和执行后目标存在性校验;当前治理窗口内新增外部 v1 视图、约束、唯一索引或触发器时,测试会反查是否进入该目录,`alwaysApply` 中出现 DML 也必须显式分类。迁移链已新增 shadow 重放对齐证据:`migrationSchemaDiff.status = 0` 代表 migrations 能从空库重放到当前 `schema.prisma`,但当前目标库仍缺 `_prisma_migrations`,不能直接 `prisma migrate deploy`。readiness 现在会把当前库/schema diff 的前几条 DDL、pending Prisma migration 名单、缺迁移历史的正确施工顺序,以及历史质量债样本直接写入 blocker evidence,避免只看到数量却无法施工;同时输出 `nextActionPlan`,按结构/迁移历史、治理护栏、历史字段清洗、准 SKU 生命周期错位四段排好后续动作。批次审计 `manifest.json` 也会写入脱敏 `readinessSummary`,方便不打开敏感子报告也能看到下一步顺序。本轮已用 Docker 生成并校验本地备份 `.database-backups/furniture_3d-20260531T232931Z.dump`,克隆库复现 `P3005`,并通过 `foundation-audit-current-goal/structure-repair-rehearsal.json` 演练证明:非破坏性结构修复 SQL 可让 current schema diff 和 migration diff 归零,86 条 baseline resolve 可在克隆库全量成功;同批 `baseline-repair-plan.json` 已输出 `ready-for-authorized-structure-repair`,并记录 SQL artifact 大小 17304 字节、SHA-256 `f569af9e2cf8c26e3e0e007217b9187e3d30a20aaa3dac3c5cd1db843ade6f28`。新增 `authorized-structure-repair-apply-plan.json` 和 `scripts/apply-prisma-structure-repair.js` 后,真实结构施工不再靠手工复制 `psql`/`migrate resolve`,而是先校验 preflight、演练报告、SQL 大小/SHA-256、SQL 本体安全、目标库身份、备份文件 SHA-256、报告新鲜度和 baseline migration 数,再分 `apply-structure` 与 `resolve-baseline` 两阶段执行。新增 `foundation-required-migration-apply-plan.json` 明确把治理迁移应用从 `migrate deploy` 中拆开,且进一步分成 `bootstrap-repair` 与 `finalize`:前者只处理缺失目标结构和已登记 `repairSql` 的真实回填,不允许靠“批准缺失”绕过 hard-ready;后者才应用最终治理视图/约束/触发器,并要求结构迁移、数据检查和迁移历史全部 ready。当前真实库因 `_prisma_migrations` 不存在且仍有结构迁移未就绪,`readyToApply = false`;只有结构修复和 baseline resolve 完成后,才允许用 `apply-master-data-foundation-migrations` 的确认口令事务性执行上述两阶段。但真实库执行前仍必须重新生成备份、preflight、结构修复计划并取得明确授权,并且真实执行命令必须显式带上目标库身份和备份 SHA-256。当前克隆库结构修复演练已通过,说明结构缺口和迁移历史缺口有可重复修复路径,但这仍不等于真实库已经 hard ready。
## 3. 治理台账
### DB-001:正式产品链和非集成开品链并存
现象:
- `Product / ProductSKU` 是正式产品结构化链。
- `IntakeNonIntegratedProduct` 是非集成开品链,核心资料大量放在 `payload Json`。
- `/intake` 页面同时承接草稿组、正式入库和非集成开品资料。
根因:
- 系统从旧 Product-SKU 结构、新平台资产链、非集成开品流程逐步演进而来。
- 新旧业务不是同一时间设计完成,导致数据形态并存。
风险:
- 外部系统不知道正式产品该读 `ProductSKU`,还是读 `IntakeNonIntegratedProduct.payload`。
- 内部页面字段容易被误认为数据库真相。
治理动作:
- 明确正式商品对外以 `ProductSKU` 为核心,SKU 名称读 `ProductSKU.name`,资产资料读 `ProductSkuAssetProfile`。
- 已补齐普通 SKU 创建、CAD 导入、产品上传、编辑、DTO 和轻仓库概览链路的 `ProductSKU.name` 读写,避免继续把 `attributes.skuName` 当主数据。
- 非集成开品只作为开发过程和产品资料整理链,必须通过白名单视图对外。
- 非集成产品头字段已新增 `IntakeNonIntegratedProductProfile` 结构化承接,`external.intake_products_v1` 改为读取 profile,不再直接从 `payload` 取产品头主数据;负责人字段优先输出 profile,旧产品列只作为兼容兜底。
- 非集成 SKU 明细已新增 `IntakeNonIntegratedSkuItem` 结构化承接,`external.intake_sku_items_v1` 改为读取结构化明细表,不再从 `payload.skuItems` 拆字段给外部系统。
- 审批流产品标题、主图和检索已优先读取 `IntakeNonIntegratedProductProfile`,避免业务协作页面继续把 `payload.openingReference` 当主数据真相。
- 审批流 DTO 的产品型号/名称/主图已固定为结构化 `IntakeNonIntegratedProduct.spu` 和 `IntakeNonIntegratedProductProfile` 优先;旧 `payload.openingReference` 只保留为开品说明、临时代码和历史参考图兜底。
- 审批流访问权限已收口为只读结构化 `ApprovalWorkflow.businessRoles / participantUserIds`;`fieldMatrix` 继续作为字段责任矩阵,但不能在业务角色为空时反推流程可见范围。
- 非集成产品列表和详情已用结构化 `profile/skuItems` 覆盖 `payload` 还原值;开发类型、负责人和待办筛选也已改为 profile 优先,旧 `payload` 只有在 profile 缺失时作为历史兜底;`payload` 继续承载工作区草稿和未结构化过程资料,但不再主导产品头和 SKU 主字段。
- 外部非集成产品和 SKU 视图已追加 `product_business_key / sku_business_key`,外部系统同步时不得把 `intake_product_id / intake_sku_item_id` 这种内部技术行 ID 当业务身份。
- 存量商品来源字段已从 `payload.metadata.source*` 结构化到 `IntakeNonIntegratedProductProfile.source*`,列表筛选、分类下拉和同步任务优先读写 profile。
- 已新增 `external.intake_products_ready_v1 / external.intake_sku_items_ready_v1`,外部稳定读取优先走 ready 视图;完整视图只用于治理对账和展示资料质量状态。
- 产品定厂合同生成已接入后端非集成产品头和 SKU 完整性规则,产品名称、品类、开发类型或目标批次 SKU 主数据未补齐时拒绝生成,避免不完整资料被包装成正式合同资料。
- SPU 正式号段新增 `SpuNumberCounter` 结构化计数器,`SPU_RULE` 资料库不再适合作为多人并发占号真相;新建非集成产品、编辑旧草稿首次生成正式 SPU 时,正式占号已经收进后端保存事务里,前端本地缓存只能做预计值,前端 `reserveIntakeSpuNumber` 调用入口已移除。计数器产品品类、SPU 规则产品品类和前缀都必须在服务层与数据库层锁住同一套归一唯一语义,不能用空格或大小写差异制造第二号池。
- `/intake` 页面不等于一张“产品资料表”,对外必须拆成多个视图。
优先级:P0
目标状态:
- `external.product_skus_v1` 表达正式 SKU。
- `external.intake_products_v1` 表达非集成开品主信息。
- `external.intake_sku_items_v1` 表达非集成 SKU 明细。
### DB-002:关键字段散落在结构化列和 JSON 快照中
现象:
- `ProductSKU.skuCode` 是结构化列。
- `ProductSKU.name` 是结构化名称列,`attributes.skuName` 只作为兼容字段。
- `ProductSkuAssetProfile` 保存型号、规格码、条码、尺寸、颜色、材质、图片等结构化同步副本。
- `PreSku.variantSelection / displaySummary / bomPreview` 是入库前快照。
- `IntakeNonIntegratedProduct.payload.skuItems` 保存大量 SKU 明细。
- 历史专利台账曾保存在浏览器 `localStorage`,已迁入 `ProductPatentRecord`;当前浏览器缓存主路径已移除,只保留当前会话内存态用于测试或 API 失败后的临时显示,服务端保存会阻断缺来源产品、缺分组键或缺专利身份的记录,并会把前端草稿态的 `patent-${Date.now()}...` 临时 id 收敛为 `product-patent-group-${patentGroupKey}` 稳定 id。
根因:
- 草稿组和非集成开品需要快速承接复杂页面状态。
- 初期为了功能闭环,把大量变化快的字段放进 JSON。
风险:
- 外部系统如果直接解析 JSON,会被内部结构变化影响。
- 同一字段可能在多个 JSON 中出现,容易读错阶段。
治理动作:
- 外部契约层只输出稳定字段,不暴露整段 JSON。
- 外部只读字段字典的承诺列必须同步进入 `scripts/sql/check-external-readonly-contract.sql`,并由 `npm run db:verify-master-data` 复用该字段清单做硬失败检查;当前覆盖 9 个 v1 视图、224 个承诺列,避免“文档说有、视图被误删也通过”的外部依赖风险。
- 外部视图实际列必须和字段字典完全一致;缺承诺列、多出未登记列、重新依赖内部 JSON,或在 `*_code / *_business_key` 业务字段中用内部行 `id` 兜底,都会被巡检阻断。
- 高频字段逐步迁移为结构化列或稳定投影视图字段。
- 所有 JSON 字段标注“快照 / 过程 / 正式副本”定位。
- 普通 SKU 属性编辑必须保留 `assetSkuSnapshot` 兼容快照,正式读取则以 `ProductSkuAssetProfile` 为准,做到“兼容证据保留、主读不回退”。
- 正式 SKU 资产同步、锁定字段合并和入库前条码冲突检测都以 `ProductSkuAssetProfile` 为准;旧 `attributes.assetSkuSnapshot` 只在 profile 缺失时作为兼容兜底。
- 正式 SKU 资产同步和准 SKU 基础资料条码冲突检测已不再回读旧 `attributes.assetSkuSnapshot`;该快照仅保留为兼容副本、迁移证据和巡检对账来源。
- 正式 SKU 条码重复风险进入内部巡检,检查活动 `ProductSkuAssetProfile.barcode` 是否重复;暂不直接加无条件唯一索引,避免软删除 SKU 的旧 profile 阻塞条码复用。
- 正式 SKU DTO 已把旧 JSON 输出口径降级为 `legacyAttributes`,并显式输出结构化 `assetProfile` 和统一展示标签 `displayTags`;`attributes` 这个字段名只能保留在写入输入、回收站恢复证据和旧兼容页面内,不能再作为正式主读合同。
- `ProductSKU.attributes` 写入键已纳入代码巡检,当前只允许登记过的兼容/过程键;`barcode / model / specCode / color / material / size` 等正式资料字段如果回写 attributes,会作为阻断风险失败。demo/test seed 里的颜色、材质、尺寸也已改写到 `ProductSkuAssetProfile`,避免示例数据继续示范错误结构。
- `ProductSKU.attributes.skuName` 已从允许写入键中移除;正式 SKU 名称只允许写 `ProductSKU.name`。写入口会阻断 `skuName / barcode / model / specCode / color / material / size` 等正式资料字段进入旧属性袋,巡检也会捕获对象简写 `{ skuName }`。
- 外部正式 SKU 视图已追加 `product_business_key / sku_business_key`,分别对应正式产品型号和正式 SKU 编码的规范化值;外部系统不得把 `product_id / sku_id` 当同步身份。
- 外部正式产品素材和 BOM 明细视图也已追加 `product_business_key / sku_business_key`;明细行可以保留技术 ID 用于内部追踪,但外部关联必须走稳定业务键。
- 轻仓库 SKU 名称读取顺序固定为 `ProductSKU.name -> ProductSkuAssetProfile.specName -> skuCode`,不再从 `attributes.skuName` 推断展示名称。
- 公开分享详情也按结构化优先输出 `name / assetProfile / legacyAttributes`;页面展示规格、颜色、材质时优先读 `ProductSkuAssetProfile`,旧 attributes 只作为公开标签兜底。
- 正式入库重复/相似度判断不再回读 `ProductSKU.attributes.variantSelectionSummary / bomPreview`;有 `sourcePreSku` 的正式 SKU 才参与组合签名对比,历史旧快照不再作为阻断依据。
- `PreSku.variantSelection / bomPreview / installStepPreview / packagingSnapshot` 已纳入过程快照读点专项盘点;这些读点只能服务辅助装配恢复、草稿组只读展示、BOM/安装步骤/包装输入和正式入库物料生成,不能从中反推正式编码、条码、品类、人员或权限。草稿组基础资料自动建议也不再从 `variantSelection` 兜底读取型号、规格码、条码、版本号或规格名称。准 SKU 正式化生命周期错位已经纳入样本输出,后续定位必须看 `preSkuFormalizationLifecycleMismatchSamples` 的草稿组编号、准 SKU 序号、关联正式 SKU 和错位原因,不允许凭 count 手工改状态;通用主数据清洗计划只展示候选,不接受填写 action decision,避免把状态回滚、补正式 SKU 关联或作废决策混进字段清洗。新增只读专用计划 `scripts/plan-pre-sku-formalization-repair.js` / `npm run db:foundation:plan-pre-sku-formalization-repair`,只生成允许决策和护栏,不支持 apply。
- 旧产品详情和旧 SKU 详情继续保留 `attributes` 作为编辑输入和 CAD 兼容证据链,但可见规格标签已统一走 `assetProfile -> legacyAttributes`,不再直接遍历整段 JSON。
- 准 SKU 基础资料以 `PreSkuBasicInfo` 为结构化承接;非集成产品头资料以 `IntakeNonIntegratedProductProfile` 为结构化承接。
- 草稿组/产品资料入口读取准 SKU 编码、型号、规格、条码等基础资料时,以 DTO 的 `basicInfo` 为主读;`displaySummary / familySelection` 只允许作为旧草稿快照兜底。
- 产品资料入口和客户端交互态已统一复用 `src/lib/open-product/platform-view.ts` 的 `readPreSkuTextField`,避免同一套 `basicInfo` 优先规则散落在多个页面文件里各自演进。
- 草稿组 DTO 输出已在 mapper 层净化:当结构化 `basicInfo` 存在时,`displaySummary` 不再对外输出型号、规格码、条码、颜色、材质等正式基础资料字段;数据库里的兼容快照只保留为历史证据和旧链兜底。
- 准 SKU 基础资料冲突检测已只查 `PreSkuBasicInfo`;旧 `displaySummary` JSON path 查询方法已删除,避免后续把展示快照重新当成业务键查询来源。
- `prisma/schema.prisma` 里的 80 个 `Json` 字段已纳入代码层分类巡检,未分类字段数为 0,过期分类字段数为 0。新增 JSON 字段必须先说明是结构化 JSON、兼容快照、过程快照、几何数据、审计日志、集成缓存、UI 配置或系统配置;字段删除或改名后也必须同步清理分类表,否则 `npm run db:verify-master-data:code` 会失败。
- `IntakeNonIntegratedProductProfile` 与 `payload/openingReference` 的对账已纳入巡检;如果出现差异,处理方向是修复写入链路或清洗历史数据,而不是把读链路退回 payload。
- 存量商品的 `sourceSpu / sourceSkuId / sourceCategory / sourceItemType / sourceSalesStatus / sourceEnabled` 以 `IntakeNonIntegratedProductProfile` 为结构化真相源,`payload.metadata` 只作为兼容快照。聚水潭同步入口会把来源 SPU、来源 SKU/条码和规格码统一规范为大写身份;查找、更新和排除判断只按结构化 profile 或系统生成的 `jst-stock-*` 业务 ID 识别来源,不再用 `payload.metadata.source*` 参与身份判断,避免同一存量商品在 profile、payload、reviewSheet 和结构化 SKU 明细里出现大小写不一致或第二套来源身份。
- 专利台账以 `ProductPatentRecord` 为结构化主数据;不得再把 `localStorage` 或本地 JSON 文件当作多人协作、生产资料保存位置或测试真相源;同一专利分组只能有一条台账记录,关键身份字段由服务和数据库双层兜底,临时随机 id 只允许停留在未保存草稿态,保存前必须归一成专利分组稳定 id。
- 内部主数据巡检已从“只报数量”扩展为“数量 + sample 明细”:`INCOMPLETE` SKU 和非集成产品头缺字段会输出产品 ID、SPU、SKU item、当前值和缺失字段,避免历史脏数据停留在不可定位状态。
- 非集成 SKU 必填字段规则已抽成后端共享规则,结构化写入、巡检口径和正式流转入口要复用同一套判断;新增导出、合同、同步任务时不能各自手写一套字段检查。
- 历史清洗新增显式计划脚本:`scripts/apply-master-data-cleanup-plan.js` 默认 dry-run 回滚,模板模式会输出当前 2 条不完整 SKU 和 5 条产品头缺字段样本;真正落库必须显式 `--apply`,且计划文件包含确认字段和业务确认人 `confirmedBy`,避免临时 SQL 和猜值。脚本只接受非空补充值,`productDevelopmentKind` 只允许 `product / series`,会拒绝未知字段、控制字符、占位值或默认值、过期模板、覆盖已有主数据、只补可选字段,以及 `--apply` 后仍未补齐且没有显式分阶段原因的计划项;`UNKNOWN / 待补齐 / 默认值` 这类伪资料不能被当成业务确认主数据。SKU 清洗里的 `skuCode / barcode` 会先按 `trim + uppercase` 规范化,再用 `upper(btrim(...))` 语义做冲突检测,和数据库表达式唯一索引保持一致;产品头 profile 清洗同样会校验模板导出的当前值和 SPU 上下文,当前值或上下文变化时拒绝落库。落库时会把 `confirmedBy` 写回产品记录 `updatedBy`,保留人工确认痕迹。清洗脚本在事务提交前会复用主数据巡检漂移断言,防止清洗动作本身把结构化表和兼容快照写叉;如果清洗事务中还需要执行任何治理迁移,包括幂等视图/护栏迁移,`--apply` 会先回滚并拒绝,迁移和历史清洗必须拆开验收。
- 清洗计划新增静态校验入口:`npm run db:cleanup-master-data:validate -- --plan <file>` 不连接数据库、不执行迁移 SQL,只校验计划文件的确认字段、`confirmedBy`、模板 evidence、未知字段、占位值、覆盖已有字段和补齐完整度;它用于业务确认后、数据库 dry-run 前先把计划格式和伪资料问题拦住。
- 主数据巡检已纳入数据库物理护栏检查:资产系列/资产族/规格款编码唯一池、规格款身份键、活动条码唯一池、系列兼容关系去重、草稿组编号、草稿组身份和摘要形态、准 SKU 序号、准 SKU 身份、过程快照形态、组件编码数组、预估重量、正式产品型号、正式产品名称/可见范围、产品协作者身份/角色、分享链接 token/访问次数/过期时间、正式 SKU 编码、活动正式 SKU 编码不得带软删除墓碑标记、正式 SKU 软删除生命周期结构化身份、正式 SKU 删除态墓碑资料完整性、正式 SKU attributes 不得保存软删除生命周期元数据或正式资料字段、正式 SKU 删除态同步到资产 profile 的触发器、准 SKU 到正式 SKU 一次性入库关系、非集成 SPU 品牌池唯一、一对一 profile、正式 SKU 资产资料同步副本活动条码唯一池、正式 SKU 资产资料副本来源规格款身份约束、准 SKU 基础资料、准 SKU 非空规格码/条码唯一池、准 SKU 型号/版本号/属性码/图纸号规范化、来源枚举和空字符串禁用、资产 SKU 同步事件原因枚举/计数/完成时间约束、非集成 SKU 明细身份、非空 SKU/条码局部唯一索引、主数据质量决策开放目标唯一和原因/确认人/解决状态约束、专利台账分组唯一和关键身份约束、资料库条目身份/编码唯一池、资料库编码非空、资料库 itemKey 归一唯一和类型枚举约束、SPU 规则产品品类唯一池、SPU 规则产品品类归一唯一池、SPU 号段品类归一唯一池、SPU 号段前缀唯一池、人员邮箱/花名/业务类型、组织部门、员工目录身份、包装方案 JSON 形态和状态约束、活动库位物理位置唯一、品牌物料准备项规范化唯一和文件元数据一致性、包装成品状态/数量/尺寸约束、审批流编号/标题/访问密钥/参与人/业务角色/字段矩阵和字段变更审计约束、旧 CAD 兼容链板件/孔位/产品五金/装配步骤/装配版本结构约束、基础资料分类/基材选项规范化唯一池、五金匹配记忆、基材花色绑定、板件材质绑定、规格款/准 SKU 库位绑定、旧正式产品包装方案质量约束、系统配置/服务商配置唯一激活/主题默认配置/节点权限覆盖/AI 生图记录/反馈/操作日志约束、软删除释放唯一池触发器、产品开发类型枚举约束、SKU 缺失字段枚举约束、SKU 质量状态一致性约束、`ProductSkuAssetProfile.model / specCode / barcode` 规范化约束,以及 `SpuNumberCounter` 品类非空、品类无首尾空格、前缀非空、计数非负约束都必须存在;护栏缺失时巡检失败,不允许只看当前样本没有重复就认为安全。当前护栏基线为 287 项。
- 编码/条码唯一池已升级为数据库语义唯一:关键 partial unique 索引必须以 `upper(btrim(...))` 作为索引键,巡检会检查索引定义而不是只检查索引名字和 predicate。核心编码字段还必须有规范化 `CHECK`,阻止旁路写入 `abc`、` ABC ` 这类页面上看起来不同、业务上其实相同的编码。
- 专利台账巡检除检查分组唯一和关键身份外,还会检查 `ProductPatentRecord.id` 是否残留 `patent-时间戳-随机串` 这类草稿态临时 id;一旦落入数据库,`npm run db:verify-master-data` 必须失败。
- 外部 ready 视图巡检会阻断缺失或重复的 `product_business_key / sku_business_key`;技术 ID 可以保留为追踪字段,但不能成为外部系统幂等同步和去重的锚点。
- 服务层写入口已同步按数据库语义唯一池收口:规划模板、基材、库位、包装成品来源编码、材质、五金、通用组件、正式产品型号、正式 SKU、SKU CAD 导入、传统产品上传、正式入库链路和非集成 SKU 保存链路都必须在进仓储层前完成 `trim + uppercase`,空编码和可预判重复由服务层直接阻断。
- 仓储层核心身份查询已补齐兜底规范化:资产系列、资产族、规格款、规格款条码、基材、正式产品型号、正式 SKU、正式 SKU 资产条码、材质、五金、通用组件、库位和规划模板的 `findByCode / findBySkuCode / findByModelNumber / codeExists` 等入口,必须先按 `trim + uppercase` 归一;资产 CAD 导入在查询已有资产族前也会先归一手填编码,避免小写或首尾空格导致“查不到已有记录、创建时才撞数据库约束”。
- 正式 SKU 资产 profile 的快照同步链路已补齐身份字段规范化:来源资产、旧 profile 和人工锁定字段中的型号、规格码、条码进入 `ProductSkuAssetProfile` 前都先 `trim + uppercase`,数据库约束只做最后兜底,不再作为第一道校验。
- 包装/轻仓库承接已从“只靠页面填对”提升到写入口和数据库双护栏:包装箱保存会校验箱号、名称、尺寸、数量、成本/重量非负和固定状态;正式入库前会再次阻断旧脏箱规;数据库约束会兜底 `PreSkuPackagingPlan.boxes/costSummary/processDraft` 的 JSON 形态、活动库位物理位置唯一、品牌物料类型枚举、品牌物料文件元数据成套出现,以及 `PackagedItem` 名称、状态、数量、尺寸和载荷形态。
- 审批流已补齐写入口和数据库双护栏:审批编号进入服务层先 `trim + uppercase`,标题、产品 ID、访问密钥、参与人列表、字段矩阵、摘要和字段变更来源都在服务层规范化;数据库进一步约束编号规范化、标题非空、参与人非空去重、业务角色不重复、字段矩阵只能是数组、摘要和元数据只能是对象、字段变更必须有字段 key 和来源。
- 人员和组织主数据已补齐写入口和数据库双护栏:用户邮箱统一小写去空格、花名去空格、业务类型去重且拒绝游离值;花名册同步会规范化部门外部 ID、员工工号、手机号、钉钉 ID、邮箱和外部部门 ID 集合;数据库进一步用表达式唯一索引锁住用户邮箱/花名、部门名称/外部 ID、员工钉钉 ID/unionId/工号/手机号,避免人员匹配和自动业务类型划归出现多候选。
- 规划域已补齐写入口和数据库双护栏:`PlanningTemplate` 承载模板结构,`PlanningWorkspace` 承载过程筛选,`PlanningDraft` 承载候选快照;服务层会拒绝模板外槽位、重复槽位 key、游离可见范围和非对象过程 JSON,数据库进一步约束模板/工作台/草稿的身份字段和 JSON 形态。
- 正式入库、新建正式 SPU、旧 CAD 导入和回收站恢复链路已统一收紧为“编码冲突阻断”:不允许后端用临时 SPU、页面临时 SKU、`${modelNumber}-序号` 或自动后缀绕开结构化编码池;需要改码时必须回到产品资料或草稿组基础资料层显式处理。
- 正式 Product 型号池采用永久身份口径:`Product.modelNumber` 软删除后仍保留全表唯一占用,避免外部系统或历史资料引用同一 SPU 号指向不同实体;这和 SKU 删除后释放规格码池是两个不同层级的业务规则。
- 正式 SKU 软删除编码释放逻辑已收紧:删除态记录再次释放时优先基于结构化 `originalSkuCode` 或可恢复原始编码生成单层墓碑编码,不再把已带 `__deleted__` 的旧编码继续套娃追加,避免删除态编码池长期膨胀和排查不可读。
- 正式 SKU 软删除恢复链路已按数据库语义唯一池继续收紧:从结构化 `originalSkuCode` 或墓碑编码恢复原始编码时统一 `trim + uppercase`,释放删除态唯一池时按大小写不敏感查找旧记录,恢复 SKU 时活动条码也按规范化条码检查冲突,避免旧小写/带空格记录绕过活动编码和条码唯一池。
- 正式 SKU 资产 profile 已新增 `skuDeletedAt` 生命周期字段,非空活动条码唯一池只约束 `sourceStatus = ACTIVE` 且 `skuDeletedAt IS NULL` 的记录;软删除/恢复 SKU 和产品级联删除/恢复 SKU 都会同步维护该字段,数据库触发器 `ProductSKU_sync_asset_profile_deleted_at` 负责兜底同步 `ProductSKU.deletedAt`,避免删除态 SKU 继续占住条码池。
- 正式 SKU 软删除墓碑资料已新增数据库护栏:删除态必须有 `deletedCodeAt`,随产品删除的 `deletedByProductId / productDeletedAt` 必须成对出现,防止直接 SQL 或未来仓储改动制造无法追溯的条码释放状态。
- 产品恢复链路已区分“随产品级联删除的 SKU”和“用户原本单独删除的 SKU”:级联删除会写 `_deletedByProductId`,恢复时只恢复该产品删除带下去的 SKU,防止误激活旧回收站 SKU。
- 正式入库挂接已有 SPU 时会阻断已删除 Product;正式入库和旧 CAD Product 导入前会在同一事务释放遗留删除态 SKU 编码,再只检查活动 SKU 编码冲突,传统产品上传也会在创建前释放删除态编码,避免回收站旧记录卡死规格码入库。
- SKU 墓碑编码规则已统一到 `sku-soft-delete.helper`;产品级联删除、单 SKU 删除、恢复、正式入库、旧 CAD 导入和上传链不能再各自发明后缀规则。
- 资料库型 `localStorage` 已作为 advisory 分层暴露并清零:当前没有 `server-contract-required` 点,也没有 `server-backed-cache` 点,也没有 `legacy-cache` 点,也没有 `compatibility-cache` 点。专利台账、SPU 规则维护页和非集成旧本地入口均已去掉浏览器本地缓存主路径;测试或显式兼容场景只允许使用当前进程内运行时缓存,不能再把浏览器持久缓存当成资料真相。SPU 预计展示只使用服务端同步后的运行时缓存或内置种子规则;内置种子规则只允许服务端 `SPU_RULE` 完全空库时兜底展示,服务端已有资料行时不得混入 seed。通用字母库、配件、封边、包装材料和钢架 BOM 匹配已改为读取 `IntakeReferenceLibraryItem`,不再从浏览器本地缓存主读。
- 代码巡检已增加“未归类本地存储”硬护栏:非测试源码里的 `localStorage/sessionStorage` 直接读写和 `createJSONStorage` 封装持久化用途必须被归类为主数据风险、兼容缓存、旧缓存,或明确列入非主数据 UI/登录态缓存;未归类时命令失败,避免新的本地主数据真相源漏进系统。
- 代码巡检已增加“应用层本地 JSON 文件主数据源”硬护栏:`src` 下不得用同步或异步 `readFile/writeFile`、`require/import` 读写或导入本地 `.json` 文件作为产品、资料库、权限或外部同步真相;导入载荷、迁移计划、备份和测试夹具必须留在明确的工具/测试边界内。
- 代码巡检已增加非集成产品 `payload` 查询护栏:开发类型、负责人、待办和列表筛选不得直接把 `payload` JSON 当作并列真相源;历史兜底必须通过 `payloadJsonWhenProfileMissing(...)` 明确绑定到 profile 缺失场景。
- 代码巡检已增加关键地基表写入边界护栏:`src/app` 下的 Route/Page 不得直接写 `SystemConfig / ProviderConfig / ThemeConfig / IntakeNodePermissionOverride / AssistedAssemblyAiRender / Feedback / ActivityLog`,必须进入 service/repository 的规范化写入口;回收站产品/SKU 日志写入已改走统一 `logActivity`。
- 代码巡检已继续扩展到核心主数据写入边界:`src/app` 下的 Route/Page 不得直接写 `Product / ProductSKU / ProductSkuAssetProfile / DraftGroup / PreSku / PreSkuBasicInfo / AssetFamily / AssetVariant / IntakeNonIntegratedProduct / IntakeNonIntegratedProductProfile / IntakeNonIntegratedSkuItem / ProductPatentRecord / SpuNumberCounter / IntakeReferenceLibraryItem`,防止页面接口绕开服务层的编码规范化、快照同步、质量状态和冲突检测;`src/server` 写入口会同时识别 Prisma client 别名和 relation 嵌套写入,子模型结构化副本不能藏在父模型 `create/update` 里绕过分类;资料库条目身份也禁止恢复 `id` 兜底 `itemKey`、直接 `itemKey: item.id`、`code || item.id` 或 `` `item:${item.id}` `` 的写法,有稳定编码的资料库必须用 `code` 作为条目身份,缺显式编码的资料库必须使用业务规格派生的稳定 key,避免浏览器临时 id 或数据库主键变成业务身份;草稿组编号禁止恢复纯 `Date.now()` 生成,服务层必须使用可读前缀和足够熵,数据库唯一索引只做最后兜底。
- 文件系统输入也纳入代码巡检:`filesystem-master-data-source` 会识别读取 `public/uploads`、上传临时文件、ZIP 解包目录等文件来源后又写 `Product / ProductSKU / Panel / ProductHardware / Hardware` 的组合。只有登记过的旧上传/导入入口可保留,新增未登记入口会阻断,防止把素材目录或本地文件扫描当成第二主数据源。
- 代码巡检已把脚本层主数据直写纳入分类:`scripts/` 和 `prisma/seed.ts` 中的 demo/test/维护脚本可以保留,但必须在巡检里明示分类;当前扫描范围已从产品/资产核心表扩到基础资料、旧 CAD 结构、仓库、人员组织、配置、审批、分享、反馈和审计等地基表。任何新的未分类脚本直写这些表都会失败,避免临时补数脚本绕开服务层、迁移和清洗计划。
- 代码巡检已把原生 SQL 主数据写入纳入分类:应用代码或脚本中只要出现 `INSERT INTO / UPDATE / DELETE FROM` 写核心主数据表,就必须说明是显式历史清洗、事务内计数器或其他受控入口,并且必须同时匹配批准文件和批准表名;当前 6 处均已分类,未分类为 0。迁移 SQL 仍由迁移清单和 strict 巡检负责,不和业务旁路写入口混为一谈。
- 代码巡检已把原生 SQL 主数据读取纳入分类:应用代码或脚本中只要出现 `FROM / JOIN` 读取核心主数据表,就必须说明是内部巡检、显式历史清洗、schema readiness、事务内计数器读取或其他受控入口,并且必须同时匹配批准文件和批准表名;当前 112 处均已分类,未分类为 0。业务服务默认应通过 Prisma delegate、Repository 或结构化 helper 读取,不允许悄悄扩散 raw SQL 主读。
- 代码巡检已把运行时 DDL 和 `$executeRawUnsafe` 纳入硬护栏:应用代码和普通脚本不得在服务调用时补建表、索引、约束或函数,数据库结构真相只能来自迁移和 strict 巡检。`IntakeNodePermissionOverride` 节点权限服务已改为只读检查迁移是否完成,不再运行时 `CREATE TABLE / ALTER TABLE`。
- 只读 SQL 主数据巡检已和 JS 护栏清单做语义级反向校验:索引护栏必须同步名称、relation、predicate hint 和 definition hint,约束护栏必须同步名称、relation 和 expression hint,触发器必须同步名称和 relation。`scripts/sql` 下的 check/preflight 脚本还会被静态测试限制为只读 SQL,授权脚本只允许外部 schema 的 SELECT/USAGE 授权。测试已抓出并修正 `IntakeReferenceLibraryItem_libraryType_code_non_empty_key` predicate hint 少逗号的问题,避免 SQL 辅助巡检弱于 `REQUIRED_DATABASE_GUARDS`。
- 代码巡检已增加核心身份查询防回退规则:非测试源码里不能再出现 `where: { code }`、`where: { skuCode }`、`where: { modelNumber }`、`where: { barcode }` 这类缩写查询,也不能把同名变量直接作为核心编码查询值;必须显式调用 `normalizeCoreIdentity` 或同口径归一后的变量。
- 代码巡检已补齐隔离 fixture 防回退测试:临时仓库里故意放入 Route/Page 直写主数据、页面本地存储、未分类脚本直写、未分类服务写入口、未分类原生 SQL 写入、已批准原生 SQL 文件越权写表、未分类原生 SQL 读取、已批准原生 SQL 文件越权读表、运行时 DDL 与 `$executeRawUnsafe`、兼容快照读取和未分类 Prisma JSON 字段,测试必须确认这些坏样本会被拦截;后续不能只靠当前大仓样本为 0 来证明规则有效。
- 代码巡检已补齐 Prisma JSON 字段分类反向校验:分类清单如果残留 schema 已删除或改名的字段,会和新增未分类 JSON 字段一样阻断巡检,避免 JSON 快照边界台账逐步失真。
- 代码巡检已补齐文件级边界分类反向校验:脚本写入口、原生 SQL 写入口、原生 SQL 表作用域、服务/仓储写入口、兼容快照读点、结构化 payload 跳过和 UI 缓存忽略这些分类表里的文件路径必须真实存在;文件删除或改名后不清理分类,会被视为地基台账失真并阻断巡检。
- 代码巡检已补齐兼容快照分类命中反向校验:`compatibilitySnapshotReadClassifications` 里的文件必须仍然命中实际兼容快照读点,或明确属于结构化 payload 跳过读点;清零的旧风险分类必须同步删除,不能在台账里保留已经不存在的入口。
- 真相源矩阵已进入机器可读巡检:`scripts/master-data-truth-sources.js` 当前覆盖 26 个核心对象,每个对象必须声明主表、兼容快照、禁止真相源、写入边界和数据库护栏;新增 `MasterDataQualityDecision` 只作为质量审计对象,不作为 SKU 真值来源。`npm run db:verify-master-data:truth` 会检查主表、`Model.field` 快照字段、写入边界文件和护栏仍存在,并反向确认数据库护栏 relation 全部被真相源条目覆盖,同时确认条目声明的必需护栏没有跨 relation 错挂;矩阵声明的写入边界还必须进入代码层主数据写入口批准表,避免“文档说清楚了、代码和迁移没跟上”的新混乱。代码层 JSON 字段分类中 `compatibility-snapshot` 的字段必须同步出现在矩阵的 `compatibilitySnapshots` 中,当前 4 个兼容快照 JSON 字段未映射数为 0,避免“JSON 分类表”和“真相源矩阵”逐步分叉。
- 数据库治理迁移新增只读状态入口:`npm run db:verify-master-data:migration-status` 只查询声明的目标表/视图/字段是否已存在,不执行迁移 SQL,也不在事务中 dry-run DDL。`npm run db:verify-master-data:all` 和 strict 入口已把该状态作为硬前置:结构迁移、幂等视图迁移、护栏迁移任何目标缺失都不能宣称 ready。这不是清洗脚本猜值问题,而是数据库结构地基仍需按授权真实迁移。
- 已新增 `IntakeReferenceLibraryItem` 和 `/api/intake/reference-library` 作为资料库型本地存储迁库底座;同一资料库内 `itemKey` 和非空 `code` 均由服务端校验和数据库唯一索引兜底,数据库还会按 `lower(btrim(itemKey))` 阻断大小写/空格语义重复键,并固定资料库类型枚举和 `itemKey` 非空规则;服务端只允许显式 `itemKey` 或稳定 `code` 推导资料身份,`id` 只作为数据库行主键,不能兜底业务身份;`SPU_RULE` 还要求产品品类 `name` 按去空格和大小写归一后一对一,并以结构化 `code / name` 反向规范化 payload 中的旧字段。包装材料、配件、封边、钢架、SPU 规则库和通用字母库组件已接入该服务端契约,其中通用字母库、配件、封边、钢架和 SPU 规则库已移除浏览器本地缓存主路径,非集成 BOM 配件/封边/包装材料/钢架选项直接读取服务端资料库;SPU 规则库在服务端有行时不再用内置 seed 补空,避免默认资料遮住真实资料质量问题。其他仍带本地缓存的资料库只能作为非集成 BOM 或旧数据迁移/测试兼容缓存。正式 SPU 占号必须走 `SpuNumberCounter`,计数器初始化只信结构化 `code` 作为前缀,新建产品和旧草稿编辑必须在后端保存事务内完成占号和落库,不能再走本机缓存或前端先占号再保存。未接入服务端契约或明确降级前,任何新本地资料库都不能被定义为系统级主数据真相。
- `/api/intake/spu-reservations` 单独占号入口已关闭写入能力,`spuNumberReservationService` 也只保留 `reserveInTransaction` 事务内分配入口;正式 SPU 只能在非集成产品保存事务内分配并落库,避免先占号后保存失败造成不可追溯号段空洞。
- 正式 SPU 分配入口已接入非集成产品主数据质量规则;产品头或 SKU 明细缺关键字段时阻断,避免正式号段被不完整资料占用。
优先级:P0
目标状态:
- 外部系统只看到稳定字段,如 `sku_code`、`barcode`、`spec_code`、`color`、`material`。
- JSON 只作为内部快照,不成为外部契约。
### DB-003:图片和素材 URL 形态不统一
现象:
- 正式产品有 `Product.thumbnailUrl`、`ProductSKU.images`、`ProductSKU.modelUrl`。
- 资产规格款有 `AssetVariant.thumbnailUrl`、`AssetVariant.modelUrl`。
- 非集成附件有 `dataUrl`、`fileUrl`、`storageKey`。
- 素材可能来自 S3/MinIO、内部 API、旧 public 目录或历史 data URL。
根因:
- 不同阶段的上传逻辑、预览图生成逻辑、历史测试资产目录并存。
风险:
- 外部系统读到 URL 后不一定能访问。
- 直接暴露 `storageKey` 或内部路径会泄露存储结构。
- 历史 data URL 不适合长期对外同步。
治理动作:
- 外部契约统一输出 `asset_url`、`asset_type`、`file_name`、`mime_type`。
- 默认不输出内部磁盘路径。
- `storageKey` 只在需要文件同步时通过单独字段和下载接口开放。
- 历史 data URL 逐步迁移为文件 URL。
优先级:P1
目标状态:
- 外部系统拿 URL 即可读取,不需要知道文件在哪台服务器或哪个目录。
### DB-004:资产源数据和正式 SKU 副本容易混用
现象:
- 资产源数据在 `AssetVariant`。
- 正式 SKU 中通过 `ProductSkuAssetProfile` 保存资产资料结构化同步副本。
- `ProductSKU.attributes.assetSkuSnapshot` 仍保留兼容快照,但不再是主读来源。
- 资产更新后可能触发正式 SKU 副本同步。
根因:
- 正式产品需要保留当时入库资料,也需要跟资产中心保持一致。
- 后续允许人工锁定字段,导致源数据和副本可能有差异。
风险:
- 外部系统如果直接跨读资产中心,可能读到和正式 SKU 不一致的数据。
- 被人工锁定字段不应被自动覆盖,也不应被外部误判为脏数据。
治理动作:
- 正式产品对外默认读 `ProductSKU` + `ProductSkuAssetProfile` 的结构化副本。
- 资产中心对外只表达资产源数据,不表达正式商品结果。
- 在外部视图中保留 `source_asset_variant_id`、`source_status`、`synced_at`。
- 如字段被锁定,外部只读视图可输出 `is_locked` 或只输出最终值,具体按外部系统需要决定。
优先级:P1
目标状态:
- 外部系统读正式 SKU 时不用再临时跨模块查资产中心。
### DB-005:草稿组/准 SKU 快照容易被误当正式数据
现象:
- `DraftGroup` 和 `PreSku` 承接辅助装配结果。
- `PreSku` 中有 BOM、重量、组件、包装等预览字段。
- 正式入库后才生成 `ProductSKU`。
根因:
- 草稿组是从辅助装配到产品资料的过渡层,本身信息看起来已经很完整。
风险:
- 外部系统如果读取草稿数据,会把未确认资料当成正式商品。
- 同一个准 SKU 入库前后可能有状态变化。
治理动作:
- 默认不把草稿组作为外部正式资料开放。
- 如果确实要开放待入库数据,单独建 `external.draft_pre_skus_v1`,并明确 `record_stage = DRAFT`。
- 正式商品只以 `ProductSKU` 为准。
优先级:P1
目标状态:
- 草稿资料和正式资料边界清楚。
### DB-006:BOM 和成本来源需要统一口径
现象:
- `PreSku.bomPreview` 内有入库前 BOM 预览。
- 正式入库时会根据 `bomPreview` 和包装成本计算 `ProductSKU.costPrice`。
- 非集成开品有 `bomDetails`、`pricingRows`、`factoryRows` 等多组成本相关数据。
- 产品资料页的参考图预估成本工具内有 `REFERENCE_ESTIMATOR_*` 临时估算单价和 `reference-image-cost-catalog` 小样本单价。
根因:
- 不同业务阶段都需要成本,但字段来源和确认程度不同。
风险:
- 外部系统可能读到预估成本、核价成本、正式成本中的某一种,却不知道口径。
- 成本字段一旦对外,需要非常稳定。
- 参考图预估成本如果被正式 BOM、包装方案、轻仓库或外部只读链路复用,会把页面辅助判断误升级成正式成本真相源。
治理动作:
- 外部视图字段命名区分 `estimated_cost`、`confirmed_cost`、`sku_cost_price`。
- 正式 SKU 成本以 `ProductSKU.costPrice` 为基础。
- 正式 BOM 结构已进入 `scripts/master-data-truth-sources.js` 的 `formal-bom-structure` 真相源条目:`Panel / PanelMaterial / ProductHardware / Hardware` 承接已确认的正式板件、材质绑定和五金结构;`PreSku.bomPreview` 只能作为正式入库前的过程生成依据,不能反向成为已确认 BOM 真相。
- `ProductSKU_price_cost_stock_non_negative` 作为数据库护栏进入治理迁移清单,阻断正式 SKU 出现负售价、负成本或负库存。
- 非集成成本必须标明来源:BOM 预估、核价、定厂、人工录入。
- 参考图预估成本被固定为“人工判断辅助”:只允许在 `RawMaterialLibraryPanel`、`reference-image-estimate` 接口和专用小样本目录内使用;`verify-master-data-code-boundaries` 已增加 `reference-estimator-cost-boundary` 护栏,阻断它流入正式成本/BOM/包装/外部同步链路。
优先级:P2
目标状态:
- 成本字段有明确计算口径和来源状态。
### DB-007:产品编码、SKU 编码、规格码、条码规则需要数据库级约束承接
现象:
- 编码规则已经讨论过:系列编码、资产族编码、规格款编码、条码等由后端生成和校验。
- 当前已有 `Product.modelNumber`、`ProductSKU.skuCode`、`AssetVariant.code`、`AssetVariant.barcode` 等字段。
- 非集成 SKU 里也有 `skuCode`、`barcode`、`versionNo` 等字段。
根因:
- 编码规则在业务演进中多次调整,需要统一落到后端和数据库唯一约束。
风险:
- 人工录入或不同页面生成编码时出现冲突。
- 外部系统依赖编码后,编码修正成本很高。
治理动作:
- 后端作为编码生成和冲突检测唯一入口。
- 数据库保留关键唯一约束,例如正式 SKU 编码、资产规格款编码。
- 外部视图输出编码时标明来源:正式编码、临时编码、历史编码。
- 非集成产品里的编码在正式入库前不得当作最终商品编码。
优先级:P0
目标状态:
- 外部系统拿到的编码都是已确认、无冲突、可追溯编码。
### DB-008:权限字段和业务字段混在同一记录中
现象:
- `IntakeNonIntegratedProduct` 同时保存业务字段和 `visibility / departmentId / teamId / createdBy / updatedBy`。
- `payload` 内还有文档确认、审批、上传状态等内部流程数据。
根因:
- 页面权限和业务资料在同一个工作区里共同演进。
风险:
- 外部系统直接读原表时可能看到内部组织、流程、人员 ID。
- 权限字段被误当业务字段使用。
治理动作:
- 外部契约层默认不暴露内部权限字段。
- 如外部需要负责人,只输出业务负责人姓名或编码,不输出内部权限判断字段。
- 数据库只读账号不授权业务原表。
优先级:P0
目标状态:
- 权限体系只服务内部系统,对外只输出必要业务归属。
### DB-009:历史兼容链和新平台主链共存
现象:
- 旧 CAD/Product 兼容链仍存在。
- 新平台主链是资产中心 -> 辅助装配 -> 草稿组 -> 产品资料 -> 轻仓库。
- 两条链都可能产生板件、孔位、五金、包装相关数据。
根因:
- 系统需要保留历史数据和旧功能,同时推进新平台。
风险:
- 外部系统不知道哪个入口产生的数据才是正式资料。
- 旧链字段可能被误纳入新平台外部契约。
治理动作:
- 外部契约按“正式 Product/SKU 结果”输出,不按页面来源区分。
- 需要保留来源字段,例如 `source_type`、`source_pre_sku_id`、`source_asset_variant_id`。
- 旧链仅作为来源之一,不作为对外契约结构。
- 旧链自身也不能继续只靠页面写对:`Panel` 的板件编号、尺寸和爆炸方向,`Hole` 的孔位几何、孔轴、类型和置信度,`ProductHardware` 的匹配状态、位姿和关联文本,`AssemblyStep` 的步骤顺序、动画和数组引用,以及 `AssemblyVersion` 的快照形态都已进入服务层规范化、数据库约束和巡检。
- `Panel` 在同一产品/SKU 范围内按 `upper(btrim(code))` 加唯一索引,避免旧 CAD 导入或手工维护产生看似不同、实际相同的板件编号。
- 基础资料分类、基材字典、五金匹配记忆、库位绑定和旧包装方案也不能只靠页面填对:名称类选项必须按 `lower(btrim(name))` 语义唯一,排序必须非负,五金匹配记忆每行只能表达一个匹配键,库位/材质绑定不能有空引用,旧 `PackagingPlan.boxes` 必须保持数组快照。
优先级:P2
目标状态:
- 外部系统只关心稳定结果,不被内部新旧链路影响。
### DB-010:删除、失效、草稿、审核状态口径不统一
现象:
- 多表有 `deletedAt`、`status`、`isActive`。
- 资产有 `DRAFT / ACTIVE / ARCHIVED` 等状态。
- 草稿组和准 SKU 有自己的状态。
- 非集成产品通过 `deletedAt` 软删除。
根因:
- 不同模块各自定义状态,缺少外部统一状态映射。
风险:
- 外部系统可能读到已删除或未审核数据。
- 不同表的状态含义不一致,外部难以处理。
治理动作:
- 外部视图统一输出 `record_status`。
- 只读视图默认过滤内部草稿、软删除、未激活数据。
- 如必须开放失效数据,明确输出 `source_status` 和 `invalid_reason`。
优先级:P1
目标状态:
- 外部系统通过一个统一状态字段判断可用性。
## 4. 整理后的真相源规则
| 业务概念 | 第一真相源 | 可读副本/快照 | 不应作为真相源 |
| --- | --- | --- | --- |
| 资产系列 | `AssetSeries` | 外部视图 | 页面筛选状态 |
| 资产族 | `AssetFamily` | 外部视图 | `PreSku.familySelection` |
| 规格款 | `AssetVariant` | `ProductSkuAssetProfile` | 页面卡片字段 / `assetSkuSnapshot` 兼容快照 |
| 正式产品 SPU | `Product` | 外部视图 | 草稿组名称 |
| 正式 SKU | `ProductSKU` | 外部视图 | 非集成临时 SKU |
| 产品协作者权限 | `ProductCollaborator` | 协作者列表 DTO | 用户业务类型 / 审批参与人 |
| 公开分享入口 | `ShareLink` | 公开分享 DTO | 页面 URL 参数 / 访问统计展示 |
| SKU 图片 | `ProductSKU.images` / `ProductSkuAssetProfile.imageUrl` | 外部统一 `asset_url` | 本地文件路径 |
| 非集成产品 | `IntakeNonIntegratedProduct` + `IntakeNonIntegratedProductProfile` | 外部投影视图 | 整段 `payload` |
| 草稿准 SKU | `PreSku` | 草稿专用视图 | 正式产品视图 |
| 产品规划过程 | `PlanningTemplate / PlanningWorkspace / PlanningDraft` | 候选快照 / 预览节点 | 资产中心源数据 / 正式产品资料 |
| 板件结构 | `Panel` | BOM/结构视图 | `displaySummary` |
| 孔位结构 | `Hole` | 3D/制造专用视图 | 孔位高亮 UI 状态 |
| 五金用量 | `ProductHardware` / `Hardware` | BOM 视图 | 预览统计文本 |
| 包装结果 | `PackagingPlan` / `PackagedItem` | 包装视图 | `PreSku.packagingSnapshot` |
| 正式 SKU 成本 | `ProductSKU.costPrice` | `external.product_skus_v1.sku_cost_price` | 参考图预估成本 / `PreSku.displaySummary` 汇总 |
## 5. 治理路线
### 第一阶段:先挡住外部风险
目标:不要让外部系统绑定内部原表。
动作:
- 建立 `external` schema 设计。
- 输出第一版字段字典。
- 给只读账号只授权 `external`。
- 第一批视图只覆盖最稳定字段。
### 第二阶段:统一产品资料输出
目标:正式产品和非集成开品都能通过清晰契约读。
动作:
- 做 `external.product_skus_v1`。
- 做 `external.intake_products_v1`。
- 做 `external.intake_sku_items_v1`。
- 对素材 URL 做统一归一。
### 第三阶段:治理 JSON 高频字段
目标:把长期被依赖的字段从 JSON 依赖中解耦。
动作:
- 统计外部系统使用字段。
- 将高频字段逐步结构化或固化在物化视图/同步表。
- 保留 JSON 作为内部快照,不再作为外部依赖。
- 外部 view 定义必须纳入巡检,不能只检查对外列名;`payload / attributes / businessData` 等内部源字段依赖、字段字典外多余列、以及业务编码字段内部 `id` 兜底都要能被脚本报告出来。
### 第四阶段:长期数据质量校验
目标:防止混乱重新长出来。
动作:
- 加唯一性/冲突检测。
- 加空值和状态巡检 SQL。
- 缺品类、缺开发类型、SKU 编码/型号/规格名全空这类无法确定性推导的数据,不允许用默认值批量填充;必须业务确认、标记无效,或走显式清洗脚本。
- 加代码层写入口巡检:脚本层、Route/Page 层、Service/Repository 层直接写核心表都必须被分类,未分类即失败,避免临时补数、临时服务或页面接口绕过主数据真相源。
- 加兼容快照读点分类:`attributes / payload / displaySummary / familySelection` 读点必须说明是过程快照、兼容展示、生命周期证据还是待结构化迁移;未分类或高优先级读点进入阻断风险,专项盘点固定输出按分类、规则、文件和优先级汇总,结构化资料库 payload 扩展字段跳过项也要单独列明。
- 加过程快照读点分类:`PreSku.variantSelection / bomPreview / installStepPreview / packagingSnapshot` 读点必须说明是 3D 恢复、草稿只读展示、BOM/安装预览、包装输入还是正式入库物料生成;未分类、高优先级读点或从过程快照反推正式字段/权限字段,都进入阻断风险。
- 加 `ProductSKU.attributes` 写入键巡检:脚本、seed、服务和仓储层只允许写登记过的兼容键,主数据字段必须改写到结构化列或 `ProductSkuAssetProfile`。
- 加 Prisma JSON 字段分类正反向巡检:新增 JSON 字段必须说明用途,删除或重命名 JSON 字段必须清理分类表,避免快照/结构化 JSON 边界文档和真实 schema 分叉。
- 加文件级分类表存在性巡检:任何白名单、批准表或兼容分类表的路径都必须还能在仓库里找到,避免重构后留下无效批准记录。
- 加兼容快照分类使用情况巡检:分类表只能描述当前真实存在的读点或受控跳过项,已经结构化或移除的旧读点必须从分类表删除。
- 加外部契约视图测试;实际列必须等于字段字典列,且业务编码字段不得使用内部行 `id` 兜底。
- 加治理迁移清单自检和执行后目标校验,防止新增迁移只落在文件夹里却没有进入 dry-run、strict 和清洗脚本共用目录,也防止迁移 SQL 没有真正生成声明对象。
- 每次改 `Product / ProductSKU / IntakeNonIntegratedProduct / AssetVariant` 时同步数据字典。
## 6. 不建议立即做的大动作
- 不立即拆掉 `IntakeNonIntegratedProduct.payload`。
- 不立即重构所有历史 Product/SKU 表。
- 不把草稿组改成正式产品表。
- 不把素材二进制迁入 PostgreSQL。
- 不为外部系统单独复制一整套业务表。
- 不为了视图漂亮而改变现有业务写入链路。
## 7. 近期可执行任务
| 顺序 | 任务 | 产出 | 风险 |
| --- | --- | --- | --- |
| 1 | 梳理外部系统需要字段 | 字段需求表 | 低 |
| 2 | 定义外部字段字典 | `external` 字段说明 | 低 |
| 3 | 写首批只读视图 SQL 草案 | `external.*_v1` | 中 |
| 4 | 在测试库跑样例数据 | 验证报告 | 中 |
| 5 | 建只读账号脚本 | 权限脚本 | 中 |
| 6 | 增加数据质量巡检 SQL | 巡检脚本 | 低 |
| 7 | 评估 JSON 高频字段结构化 | 改造计划 | 中 |
| 8 | 收紧服务/仓储写入口白名单 | 代码巡检基线 | 中 |
| 9 | 对兼容快照读点分优先级迁移 | 分类清单与迁移任务 | 中 |
当前第一阶段已补充 `external` 只读视图、字段字典、授权脚本和巡检脚本,并已收紧 `materials_v1` 字段字典外列、外部稳定业务键空值/重复、`product_bom_v1.item_code` 内部 ID 兜底风险,以及资料库 payload 身份字段回读风险。后续治理重点转向真实数据质量:业务缺字段清洗、素材 URL 形态、成本/重量口径和高频 JSON 字段结构化。
## 8. 规范审查
结论:`passed`
这是自审,不是独立复审。
- 架构:本台账没有改变数据库结构,只明确混乱点、真相源和治理顺序。
- 视觉:本次不涉及前端视觉。
- 根因:没有把“直接开放只读账号”当解决方案,而是先治理数据地基和外部契约边界。

View File

@ -0,0 +1,92 @@
# 数据库质量巡检基线 2026-05-29
巡检时间:2026-05-29
巡检对象:本地开发库 `localhost:5432/furniture_3d`
巡检方式:在事务中临时执行 `external` 迁移和巡检查询,最后 `ROLLBACK`,不保留数据库结构变更。
## 1. 视图数据量
| 视图 | 行数 |
| --- | ---: |
| `external.materials_v1` | 22 |
| `external.asset_variants_v1` | 15 |
| `external.product_skus_v1` | 0 |
| `external.product_assets_v1` | 0 |
| `external.product_bom_v1` | 0 |
| `external.intake_products_v1` | 5 |
| `external.intake_sku_items_v1` | 12 |
判断:
- 本地库已有花色/材质、资产规格款、非集成开品数据。
- 本地库暂无正式 `ProductSKU` 数据,所以正式产品、正式素材和正式 BOM 视图当前为 0。这个不是视图失败,而是当前本地库数据状态。
- 正式 SKU 资产资料视图字段已改为读取 `ProductSkuAssetProfile`;本地库正式 SKU 为 0,所以本次 profile 回填数也是 0。
## 2. 契约风险检查
| 检查项 | 结果 |
| --- | --- |
| 私有字段风险 | 0 |
| 正式 SKU 重复编码 | 0 |
| 正式 SKU 缺少名称 | 0 |
| 资产规格款重复编码 | 0 |
| 正式产品重复条码 | 0 |
| 正式 SKU 缺少资产 profile | 0 |
| 正式 SKU profile 与兼容快照差异 | 0 |
| `data:` 素材 URL | 0 |
判断:
- 第一版 `external` 视图没有暴露整段 `payload`、权限字段、内部路径或密钥类字段名。
- 当前本地数据没有发现重复正式 SKU 编码、重复资产规格款编码和重复正式条码;正式 SKU profile 风险为 0。
## 3. 空字段检查
| 检查项 | 空值数量 |
| --- | ---: |
| 正式 SKU:`sku_code` | 0 |
| 正式 SKU:`product_model_number` | 0 |
| 正式 SKU:`sku_name` | 0 |
| 资产规格款:`spec_code` | 0 |
| 资产规格款:`spec_name` | 0 |
| 资产规格款:`asset_family_code` | 0 |
| 非集成 SKU:`sku_code` | 2 |
| 非集成 SKU:`spec_name` | 2 |
| 非集成 SKU:`model` | 2 |
判断:
- 资产中心规格款当前关键编码字段质量较好。
- 非集成 SKU 明细里有 2 行缺少 `sku_code`、`spec_name` 和 `model`,这是下一步数据质量治理的第一优先级。
- `external.intake_sku_items_v1` 已增加 `data_quality_status` 和 `missing_required_fields`,外部系统应过滤或人工处理 `INCOMPLETE` 记录。
## 4. 非集成 SKU 质量状态
| 状态 | 行数 |
| --- | ---: |
| `READY` | 10 |
| `INCOMPLETE` | 2 |
`INCOMPLETE` 来源:
| 产品 | SPU | SKU 行 |
| --- | --- | --- |
| 可升降桌移动边几 | `PD20260507114440519` | 第 1 行 |
| 浏览器验证开品草稿 | `KP-1777537404278` | 第 1 行 |
## 5. 后续治理优先级
1. 查出非集成 SKU 中这 2 行空字段的来源记录,判断是测试数据、旧数据,还是当前录入链路允许空值。
2. 如果是当前录入链路问题,在产品资料录入侧补必填、自动生成或冲突检测。
3. 如果是历史脏数据,补清洗脚本或标记为历史无效数据,避免外部系统读到。
4. 在生产库迁移前,对生产库跑同一套巡检,不能只依据本地库判断。
## 6. 规范审查
结论:`passed`
这是自审,不是独立复审。
- 架构:巡检基线只记录数据质量,不改变业务表和写入链路。
- 视觉:本次不涉及前端视觉。
- 根因:把“数据库哪里乱”落成可量化问题,后续按数据来源治理。

View File

@ -230,7 +230,8 @@ curl http://localhost:3000/api/health
2. **数据库初始化**
```bash
# 如果需要初始化数据库,在服务器上执行:
docker-compose -f docker-compose.prod.yml exec app npx prisma db push
docker-compose -f docker-compose.prod.yml exec app npx prisma migrate deploy
docker-compose -f docker-compose.prod.yml exec app npm run db:verify-master-data:all:strict
docker-compose -f docker-compose.prod.yml exec app npx prisma db seed
```
@ -290,8 +291,9 @@ docker stats furniture3d-app
# 进入PostgreSQL容器
docker-compose -f docker-compose.prod.yml exec postgres psql -U furniture -d furniture_db
# 执行数据库迁移
docker-compose -f docker-compose.prod.yml exec app npx prisma db push
# 执行数据库迁移;生产/准生产不要使用 prisma db push
docker-compose -f docker-compose.prod.yml exec app npx prisma migrate deploy
docker-compose -f docker-compose.prod.yml exec app npm run db:verify-master-data:all:strict
# 执行数据库种子
docker-compose -f docker-compose.prod.yml exec app npx prisma db seed

View File

@ -14,8 +14,14 @@
"clean:local": "node scripts/cleanup-local-artifacts.js",
"prisma:generate": "prisma generate",
"prisma:check-client-sync": "node scripts/check-prisma-client-sync.js",
"prisma:push": "prisma db push",
"prisma:push": "node scripts/prisma-db-push-local.js",
"prisma:studio": "prisma studio",
"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",
"db:verify-master-data:strict": "node scripts/verify-master-data-foundation.js --require-applied",
"db:verify-master-data:migration-status": "node scripts/verify-master-data-foundation.js --read-only-migration-status",
"db:cleanup-master-data": "node scripts/apply-master-data-cleanup-plan.js",
"db:cleanup-master-data:validate": "node scripts/apply-master-data-cleanup-plan.js --validate-plan",
"prisma:seed": "tsx prisma/seed.ts",
"open-platform:seed": "tsx scripts/seed-open-platform-demo.ts",
"type-check": "node scripts/run-type-check.js",
@ -24,10 +30,41 @@
"demo:update": "node scripts/update-demo-models.js",
"demo:refresh": "npm run prisma:push && npm run demo:update",
"thumbnails:backfill": "tsx scripts/backfill-asset-variant-thumbnails.ts",
"thumbnails:backfill:apply": "tsx scripts/backfill-asset-variant-thumbnails.ts --apply",
"materials:sync-textures": "tsx scripts/sync-material-textures.ts",
"materials:sync-standard": "tsx scripts/sync-standard-finishes.ts",
"materials:sync-standard:apply": "tsx scripts/sync-standard-finishes.ts --apply",
"sync:jst-sku-stock": "tsx scripts/sync-jst-sku-stock-products.ts",
"sync:employee-directory": "tsx scripts/sync-employee-directory.ts"
"sync:employee-directory": "tsx scripts/sync-employee-directory.ts",
"sync:employee-directory:apply": "tsx scripts/sync-employee-directory.ts --apply",
"db:verify-master-data:truth": "node scripts/verify-master-data-truth-sources.js",
"db:verify-master-data:code": "node scripts/verify-master-data-code-boundaries.js",
"db:verify-master-data:all": "npm run db:verify-master-data:truth && npm run db:verify-master-data:code && npm run db:verify-master-data:migration-status && npm run db:verify-master-data",
"db:verify-master-data:all:strict": "npm run db:verify-master-data:truth && npm run db:verify-master-data:code && npm run db:verify-master-data:migration-status && npm run db:verify-master-data:strict",
"db:verify-master-data:readiness": "node scripts/verify-master-data-readiness.js",
"db:verify-master-data:readiness:audit": "node scripts/verify-master-data-readiness.js --output .master-data-audits/readiness-current.json",
"db:diagnose-prisma-migration-baseline": "node scripts/diagnose-prisma-migration-baseline.js",
"db:preflight-database-foundation-change": "node scripts/preflight-database-foundation-change.js",
"db:preflight-database-foundation-change:audit": "node scripts/preflight-database-foundation-change.js --output .master-data-audits/foundation-preflight-current.json",
"db:backup:postgres": "node scripts/create-postgres-backup.js",
"db:foundation:migrate": "node scripts/run-database-foundation-migrations.js",
"db:foundation:migrate:audit": "node scripts/run-database-foundation-migrations.js --output .master-data-audits/foundation-migrate-plan-current.json",
"db:foundation:apply-required": "node scripts/apply-master-data-foundation-migrations.js",
"db:foundation:apply-required:audit": "node scripts/apply-master-data-foundation-migrations.js --output .master-data-audits/foundation-required-migration-apply-plan-current.json",
"db:foundation:apply-structure-repair": "node scripts/apply-prisma-structure-repair.js",
"db:foundation:apply-structure-repair:audit": "node scripts/apply-prisma-structure-repair.js --output .master-data-audits/authorized-structure-repair-apply-plan-current.json",
"db:foundation:baseline-resolve": "node scripts/apply-prisma-baseline-resolve.js",
"db:foundation:rehearse-migration": "node scripts/rehearse-database-foundation-migration.js",
"db:foundation:rehearse-structure-repair": "node scripts/rehearse-prisma-structure-repair.js",
"db:foundation:baseline-repair-plan": "node scripts/plan-prisma-baseline-repair.js",
"db:foundation:plan-pre-sku-formalization-repair": "node scripts/plan-pre-sku-formalization-repair.js",
"db:foundation:validate-pre-sku-formalization-repair": "node scripts/plan-pre-sku-formalization-repair.js --validate-plan",
"db:foundation:apply-pre-sku-formalization-repair": "node scripts/apply-pre-sku-formalization-repair.js",
"db:foundation:audit": "node scripts/run-master-data-foundation-audit.js",
"db:foundation:audit:cleanup-template": "node scripts/run-master-data-foundation-audit.js --include-cleanup-template",
"db:foundation:audit:full": "node scripts/run-master-data-foundation-audit.js --include-cleanup-template --use-docker-shadow --include-structure-repair-rehearsal --output-dir .master-data-audits/foundation-audit-current-goal",
"db:foundation:completion-checklist": "node scripts/print-master-data-foundation-checklist.js",
"db:foundation:decision-package": "node scripts/print-master-data-foundation-decision-package.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.948.0",

View File

@ -2,8 +2,12 @@ import fs from 'node:fs'
import path from 'node:path'
import { defineConfig } from 'prisma/config'
function loadDatabaseUrlFromDotEnv(): void {
if (typeof process.env.DATABASE_URL === 'string' && process.env.DATABASE_URL.length > 0) {
function loadEnvValuesFromDotEnv(names: string[]): void {
const missingNames = names.filter((name) => {
const value = process.env[name]
return !(typeof value === 'string' && value.length > 0)
})
if (missingNames.length === 0) {
return
}
@ -21,7 +25,7 @@ function loadDatabaseUrlFromDotEnv(): void {
if (separatorIndex <= 0) continue
const key = trimmed.slice(0, separatorIndex).trim()
if (key !== 'DATABASE_URL') continue
if (!missingNames.includes(key)) continue
let value = trimmed.slice(separatorIndex + 1).trim()
if (
@ -32,13 +36,12 @@ function loadDatabaseUrlFromDotEnv(): void {
}
if (value.length > 0) {
process.env.DATABASE_URL = value
process.env[key] = value
}
return
}
}
loadDatabaseUrlFromDotEnv()
loadEnvValuesFromDotEnv(['DATABASE_URL', 'SHADOW_DATABASE_URL'])
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL 未配置。请在 .env 中设置 DATABASE_URL。')
@ -51,5 +54,8 @@ export default defineConfig({
},
datasource: {
url: process.env.DATABASE_URL,
...(process.env.SHADOW_DATABASE_URL
? { shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL }
: {}),
},
})

View File

@ -2,10 +2,10 @@
CREATE TYPE "ApprovalWorkflowStatus" AS ENUM ('DRAFT', 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'REJECTED', 'ARCHIVED');
-- DropIndex
DROP INDEX "IntakeNonIntegratedProduct_spuBase_key";
DROP INDEX IF EXISTS "IntakeNonIntegratedProduct_spuBase_key";
-- DropIndex
DROP INDEX "IntakeNonIntegratedProduct_spu_idx";
DROP INDEX IF EXISTS "IntakeNonIntegratedProduct_spu_idx";
-- AlterTable
ALTER TABLE "Department" ADD COLUMN "description" TEXT,
@ -111,10 +111,10 @@ CREATE INDEX "ApprovalWorkflowFieldChange_userId_createdAt_idx" ON "ApprovalWork
CREATE INDEX "ApprovalWorkflowFieldChange_fieldKey_idx" ON "ApprovalWorkflowFieldChange"("fieldKey");
-- CreateIndex
CREATE UNIQUE INDEX "IntakeNonIntegratedProduct_spu_key" ON "IntakeNonIntegratedProduct"("spu");
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNonIntegratedProduct_spu_key" ON "IntakeNonIntegratedProduct"("spu");
-- CreateIndex
CREATE INDEX "IntakeNonIntegratedProduct_spuBase_idx" ON "IntakeNonIntegratedProduct"("spuBase");
CREATE INDEX IF NOT EXISTS "IntakeNonIntegratedProduct_spuBase_idx" ON "IntakeNonIntegratedProduct"("spuBase");
-- CreateIndex
CREATE INDEX "User_accountTemplateId_idx" ON "User"("accountTemplateId");

View File

@ -0,0 +1,402 @@
-- External read-only contract layer.
-- Keep external systems away from internal business tables and JSON payloads.
CREATE SCHEMA IF NOT EXISTS external;
ALTER TABLE "Material"
ADD COLUMN IF NOT EXISTS "textureSizeMm" DOUBLE PRECISION NOT NULL DEFAULT 600;
ALTER TABLE "ProductHardware"
ADD COLUMN IF NOT EXISTS "sourceBlockName" TEXT;
CREATE OR REPLACE VIEW external.materials_v1 AS
SELECT
m."id" AS material_id,
m."code" AS material_code,
m."name" AS material_name,
c."id" AS category_id,
c."name" AS category_name,
m."thumbnailUrl" AS thumbnail_url,
m."textureUrl" AS texture_url,
m."normalMapUrl" AS normal_map_url,
m."color" AS base_color,
m."metalness" AS metalness,
m."roughness" AS roughness,
m."opacity" AS opacity,
m."textureRepeat" AS texture_repeat,
m."textureRotation" AS texture_rotation,
m."textureSizeMm" AS texture_size_mm,
m."isPreset" AS is_preset,
m."isActive" AS is_active,
CASE WHEN m."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status,
m."createdAt" AS created_at,
m."updatedAt" AS updated_at
FROM "Material" m
JOIN "MaterialCategory" c ON c."id" = m."categoryId";
CREATE OR REPLACE VIEW external.asset_variants_v1 AS
SELECT
v."id" AS asset_variant_id,
v."code" AS spec_code,
v."name" AS spec_name,
v."assetType"::text AS asset_type,
v."status"::text AS asset_status,
f."id" AS asset_family_id,
f."code" AS asset_family_code,
f."name" AS asset_family_name,
s."id" AS asset_series_id,
s."code" AS asset_series_code,
s."name" AS asset_series_name,
pc."id" AS product_category_id,
pc."name" AS product_category_name,
v."sizeLabel" AS size_label,
v."dimensions" AS dimensions,
v."modelUrl" AS model_url,
v."thumbnailUrl" AS thumbnail_url,
v."barcode" AS barcode,
v."supplierName" AS supplier_name,
v."supplierCode" AS supplier_code,
v."versionNo" AS version_no,
sub."id" AS substrate_id,
sub."name" AS substrate_name,
finish."id" AS finish_id,
finish."code" AS finish_code,
finish."name" AS finish_name,
COALESCE(
NULLIF(v."businessData"::jsonb ->> 'materialLabel', ''),
CASE
WHEN sub."name" IS NOT NULL AND finish."name" IS NOT NULL THEN sub."name" || ' + ' || finish."name"
ELSE COALESCE(sub."name", finish."name")
END
) AS material_label,
v."weightKg" AS weight_kg,
v."costPrice" AS cost_price,
v."estimatedCostPrice" AS estimated_cost_price,
v."createdAt" AS created_at,
v."updatedAt" AS updated_at
FROM "AssetVariant" v
JOIN "AssetFamily" f ON f."id" = v."assetFamilyId"
LEFT JOIN "AssetSeries" s ON s."id" = f."assetSeriesId"
LEFT JOIN "ProductCategory" pc ON pc."id" = f."productCategoryId"
LEFT JOIN "Substrate" sub ON sub."id" = v."substrateId"
LEFT JOIN "Material" finish ON finish."id" = v."finishId"
WHERE v."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.product_skus_v1 AS
WITH sku_source AS (
SELECT
sku.*,
sku."attributes"::jsonb AS attributes_json,
sku."attributes"::jsonb -> 'assetSkuSnapshot' AS asset_snapshot
FROM "ProductSKU" sku
)
SELECT
p."id" AS product_id,
p."modelNumber" AS product_model_number,
p."name" AS product_name,
p."description" AS product_description,
pc."id" AS product_category_id,
pc."name" AS product_category_name,
p."thumbnailUrl" AS product_thumbnail_url,
p."modelUrl" AS product_model_url,
sku."id" AS sku_id,
sku."skuCode" AS sku_code,
COALESCE(
NULLIF(sku.attributes_json ->> 'skuName', ''),
NULLIF(sku.asset_snapshot ->> 'specName', ''),
sku."skuCode"
) AS sku_name,
sku."price" AS price,
sku."costPrice" AS cost_price,
sku."stock" AS stock,
sku."isActive" AS is_active,
CASE WHEN sku."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status,
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url,
COALESCE(
NULLIF(sku.asset_snapshot ->> 'imageUrl', ''),
sku."images"[1],
NULLIF(p."thumbnailUrl", '')
) AS image_url,
sku."images" AS image_urls,
sku."sourcePreSkuId" AS source_pre_sku_id,
sku."sourceAssetVariantId" AS source_asset_variant_id,
sku."sourceAssetVariantIds" AS source_asset_variant_ids,
sku.asset_snapshot ->> 'model' AS asset_family_code,
sku.asset_snapshot ->> 'modelName' AS asset_family_name,
sku.asset_snapshot ->> 'specCode' AS spec_code,
sku.asset_snapshot ->> 'specName' AS spec_name,
sku.asset_snapshot ->> 'barcode' AS barcode,
sku.asset_snapshot -> 'size' AS size_json,
sku.asset_snapshot ->> 'color' AS color,
sku.asset_snapshot ->> 'material' AS material,
sku.asset_snapshot ->> 'sourceStatus' AS source_status,
sku.asset_snapshot ->> 'syncedAt' AS synced_at,
wh."id" AS warehouse_location_id,
wh."code" AS warehouse_location_code,
wh."name" AS warehouse_location_name,
sku."createdAt" AS created_at,
sku."updatedAt" AS updated_at
FROM sku_source sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductCategory" pc ON pc."id" = p."categoryId"
LEFT JOIN "WarehouseLocation" wh ON wh."id" = sku."warehouseLocationId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.product_assets_v1 AS
WITH image_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
image_item.image_url,
image_item.sort_order
FROM "ProductSKU" sku
CROSS JOIN LATERAL unnest(sku."images") WITH ORDINALITY AS image_item(image_url, sort_order)
WHERE sku."deletedAt" IS NULL
),
model_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
),
thumbnail_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
COALESCE(
NULLIF(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'imageUrl', ''),
NULLIF(p."thumbnailUrl", '')
) AS thumbnail_url
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
)
SELECT
'SKU_IMAGE' AS asset_type,
product_id,
sku_id,
image_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
sort_order::integer AS sort_order
FROM image_assets
WHERE NULLIF(image_url, '') IS NOT NULL
UNION ALL
SELECT
'SKU_MODEL' AS asset_type,
product_id,
sku_id,
model_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
0 AS sort_order
FROM model_assets
WHERE NULLIF(model_url, '') IS NOT NULL
UNION ALL
SELECT
'SKU_THUMBNAIL' AS asset_type,
product_id,
sku_id,
thumbnail_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
0 AS sort_order
FROM thumbnail_assets
WHERE NULLIF(thumbnail_url, '') IS NOT NULL;
CREATE OR REPLACE VIEW external.product_bom_v1 AS
SELECT
'PANEL' AS item_type,
p."productId" AS product_id,
p."skuId" AS sku_id,
p."id" AS item_id,
p."code" AS item_code,
p."name" AS item_name,
1::numeric AS quantity,
'piece' AS unit,
m."id" AS material_id,
m."code" AS material_code,
m."name" AS material_name,
jsonb_build_object(
'length', p."length",
'width', p."width",
'thickness', p."thickness",
'unit', 'mm'
) AS dimensions,
NULL::numeric AS unit_cost,
NULL::numeric AS total_cost,
p."createdAt" AS created_at,
p."updatedAt" AS updated_at
FROM "Panel" p
JOIN "Product" product ON product."id" = p."productId"
LEFT JOIN "ProductSKU" sku ON sku."id" = p."skuId"
LEFT JOIN "PanelMaterial" pm ON pm."panelId" = p."id"
LEFT JOIN "Material" m ON m."id" = pm."materialId"
WHERE product."deletedAt" IS NULL
AND (sku."id" IS NULL OR sku."deletedAt" IS NULL)
UNION ALL
SELECT
'HARDWARE' AS item_type,
ph."productId" AS product_id,
ph."skuId" AS sku_id,
ph."id" AS item_id,
COALESCE(h."code", ph."sourceBlockName", ph."id") AS item_code,
COALESCE(h."name", ph."sourceBlockName", '未匹配五金') AS item_name,
1::numeric AS quantity,
'piece' AS unit,
NULL::text AS material_id,
NULL::text AS material_code,
NULL::text AS material_name,
h."dimensions" AS dimensions,
NULL::numeric AS unit_cost,
NULL::numeric AS total_cost,
ph."createdAt" AS created_at,
ph."updatedAt" AS updated_at
FROM "ProductHardware" ph
JOIN "Product" product ON product."id" = ph."productId"
LEFT JOIN "ProductSKU" sku ON sku."id" = ph."skuId"
LEFT JOIN "Hardware" h ON h."id" = ph."hardwareId"
WHERE product."deletedAt" IS NULL
AND (sku."id" IS NULL OR sku."deletedAt" IS NULL);
CREATE OR REPLACE VIEW external.intake_products_v1 AS
WITH product_source AS (
SELECT
p.*,
p."payload"::jsonb AS payload_json,
p."payload"::jsonb -> 'openingReference' AS opening_reference
FROM "IntakeNonIntegratedProduct" p
WHERE p."deletedAt" IS NULL
)
SELECT
p."id" AS intake_product_id,
p."spu" AS spu,
p."spuBase" AS spu_base,
p."brand" AS brand,
COALESCE(
NULLIF(p.opening_reference ->> 'productName', ''),
NULLIF(p.payload_json ->> 'previewImageName', ''),
p."spu"
) AS product_name,
COALESCE(
NULLIF(p.opening_reference ->> 'productImage', ''),
NULLIF(p.payload_json ->> 'previewImage', '')
) AS product_image_url,
COALESCE(NULLIF(p.opening_reference ->> 'productCategory', ''), NULLIF(p.payload_json ->> 'productCategory', '')) AS product_category,
COALESCE(NULLIF(p.opening_reference ->> 'salesCategory', ''), NULLIF(p.payload_json ->> 'salesCategory', '')) AS sales_category,
COALESCE(NULLIF(p.opening_reference ->> 'year', ''), NULLIF(p.payload_json ->> 'year', '')) AS product_year,
COALESCE(NULLIF(p.opening_reference ->> 'currentStage', ''), NULLIF(p.payload_json ->> 'projectStatus', '')) AS product_status,
COALESCE(NULLIF(p.opening_reference ->> 'designer', ''), NULLIF(p."designer", '')) AS designer,
p."operator" AS operator,
p."purchaser" AS purchaser,
p.payload_json ->> 'productDevelopmentKind' AS product_development_kind,
p.payload_json ->> 'goodsType' AS goods_type,
p.payload_json ->> 'materialType' AS material_type,
p.payload_json ->> 'productionType' AS production_type,
p."createdAt" AS created_at,
p."updatedAt" AS updated_at
FROM product_source p;
CREATE OR REPLACE VIEW external.intake_sku_items_v1 AS
WITH product_source AS (
SELECT
p.*,
p."payload"::jsonb AS payload_json
FROM "IntakeNonIntegratedProduct" p
WHERE p."deletedAt" IS NULL
),
sku_items AS (
SELECT
p."id" AS intake_product_id,
p."spu",
p."brand",
item.sku_item,
item.ordinality
FROM product_source p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p.payload_json -> 'skuItems') = 'array' THEN p.payload_json -> 'skuItems'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS item(sku_item, ordinality)
)
SELECT
intake_product_id,
spu,
brand,
sku_item ->> 'id' AS intake_sku_item_id,
CASE
WHEN COALESCE(sku_item ->> 'skuSequence', '') ~ '^[0-9]+$'
THEN (sku_item ->> 'skuSequence')::integer
ELSE NULL
END AS sku_sequence,
CASE
WHEN COALESCE(sku_item ->> 'batchNo', '') ~ '^[0-9]+$'
THEN (sku_item ->> 'batchNo')::integer
ELSE NULL
END AS batch_no,
sku_item ->> 'image' AS image_url,
sku_item ->> 'imageName' AS image_name,
sku_item ->> 'model' AS model,
sku_item ->> 'skuCode' AS sku_code,
sku_item ->> 'specName' AS spec_name,
sku_item ->> 'barcode' AS barcode,
sku_item ->> 'size' AS size,
sku_item ->> 'color' AS color,
sku_item ->> 'material' AS material,
sku_item ->> 'versionNo' AS version_no,
sku_item ->> 'attributeCode' AS attribute_code,
sku_item ->> 'drawingId' AS drawing_id,
sku_item ->> 'installMethod' AS install_method,
sku_item ->> 'executeStandard' AS execute_standard,
sku_item ->> 'manufacturer' AS manufacturer,
sku_item ->> 'manufacturerCode' AS manufacturer_code,
sku_item ->> 'salesStatus' AS sales_status,
sku_item ->> 'model3dDataUrl' AS model_3d_url,
sku_item ->> 'source' AS source,
sku_item ->> 'sourceTable' AS source_table,
sku_item ->> 'sourceSkuId' AS source_sku_id,
array_remove(
ARRAY[
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'skuCode', '')), '') IS NULL
THEN 'sku_code'
END,
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'specName', '')), '') IS NULL
THEN 'spec_name'
END,
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'model', '')), '') IS NULL
THEN 'model'
END
]::text[],
NULL
) AS missing_required_fields,
CASE
WHEN
NULLIF(btrim(COALESCE(sku_item ->> 'skuCode', '')), '') IS NULL OR
NULLIF(btrim(COALESCE(sku_item ->> 'specName', '')), '') IS NULL OR
NULLIF(btrim(COALESCE(sku_item ->> 'model', '')), '') IS NULL
THEN 'INCOMPLETE'
ELSE 'READY'
END AS data_quality_status,
LOWER(COALESCE(sku_item ->> 'contentLocked', '')) IN ('true', '1', 'yes', 'on') AS content_locked,
ordinality::integer AS sort_order
FROM sku_items;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'external_reader') THEN
GRANT USAGE ON SCHEMA external TO external_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA external TO external_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA external GRANT SELECT ON TABLES TO external_reader;
END IF;
END
$$;

View File

@ -0,0 +1,317 @@
-- Structure non-integrated intake SKU rows so SKU master data is not only stored in payload JSON.
DO $$
DECLARE
duplicate_sku_code_groups INTEGER;
duplicate_barcode_groups INTEGER;
BEGIN
WITH payload_sku_items AS (
SELECT
NULLIF(btrim(item.sku_item ->> 'skuCode'), '') AS sku_code,
NULLIF(btrim(item.sku_item ->> 'barcode'), '') AS barcode
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) AS item(sku_item)
WHERE p."deletedAt" IS NULL
),
duplicate_sku_codes AS (
SELECT sku_code
FROM payload_sku_items
WHERE sku_code IS NOT NULL
GROUP BY sku_code
HAVING COUNT(*) > 1
)
SELECT COUNT(*)::integer
INTO duplicate_sku_code_groups
FROM duplicate_sku_codes;
WITH payload_sku_items AS (
SELECT
NULLIF(btrim(item.sku_item ->> 'skuCode'), '') AS sku_code,
NULLIF(btrim(item.sku_item ->> 'barcode'), '') AS barcode
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) AS item(sku_item)
WHERE p."deletedAt" IS NULL
),
duplicate_barcodes AS (
SELECT barcode
FROM payload_sku_items
WHERE barcode IS NOT NULL
GROUP BY barcode
HAVING COUNT(*) > 1
)
SELECT COUNT(*)::integer
INTO duplicate_barcode_groups
FROM duplicate_barcodes;
IF duplicate_sku_code_groups > 0 OR duplicate_barcode_groups > 0 THEN
RAISE EXCEPTION
'Cannot structure non-integrated SKU items: active payload contains % duplicate skuCode groups and % duplicate barcode groups. Run scripts/sql/preflight-intake-non-integrated-sku-items.sql before this migration.',
duplicate_sku_code_groups,
duplicate_barcode_groups;
END IF;
END $$;
CREATE TYPE "IntakeNonIntegratedSkuDataQualityStatus" AS ENUM ('READY', 'INCOMPLETE');
CREATE TABLE "IntakeNonIntegratedSkuItem" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"skuItemId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"skuSequence" INTEGER NOT NULL DEFAULT 0,
"batchNo" INTEGER NOT NULL DEFAULT 1,
"packageGroupId" TEXT NOT NULL DEFAULT '',
"image" TEXT,
"imageName" TEXT NOT NULL DEFAULT '',
"model" TEXT NOT NULL DEFAULT '',
"skuCode" TEXT NOT NULL DEFAULT '',
"specName" TEXT NOT NULL DEFAULT '',
"barcode" TEXT NOT NULL DEFAULT '',
"size" TEXT NOT NULL DEFAULT '',
"color" TEXT NOT NULL DEFAULT '',
"material" TEXT NOT NULL DEFAULT '',
"boardMaterialType" TEXT NOT NULL DEFAULT '',
"versionNo" TEXT NOT NULL DEFAULT 'V01',
"attributeCode" TEXT NOT NULL DEFAULT '',
"drawingId" TEXT NOT NULL DEFAULT '',
"installMethod" TEXT NOT NULL DEFAULT '',
"executeStandard" TEXT NOT NULL DEFAULT '',
"nationalStandardCompliant" TEXT NOT NULL DEFAULT '',
"manufacturer" TEXT NOT NULL DEFAULT '',
"manufacturerCode" TEXT NOT NULL DEFAULT '',
"salesStatus" TEXT NOT NULL DEFAULT '',
"tagMaterial" TEXT NOT NULL DEFAULT '',
"model3dDataUrl" TEXT,
"source" TEXT NOT NULL DEFAULT '',
"sourceTable" TEXT NOT NULL DEFAULT '',
"sourceSkuId" TEXT NOT NULL DEFAULT '',
"contentLocked" BOOLEAN NOT NULL DEFAULT false,
"dataQualityStatus" "IntakeNonIntegratedSkuDataQualityStatus" NOT NULL DEFAULT 'INCOMPLETE',
"missingRequiredFields" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeNonIntegratedSkuItem_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "IntakeNonIntegratedSkuItem"
ADD CONSTRAINT "IntakeNonIntegratedSkuItem_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE UNIQUE INDEX "IntakeNonIntegratedSkuItem_productId_skuItemId_key"
ON "IntakeNonIntegratedSkuItem"("productId", "skuItemId");
CREATE UNIQUE INDEX "IntakeNonIntegratedSkuItem_skuCode_non_empty_key"
ON "IntakeNonIntegratedSkuItem"("skuCode")
WHERE NULLIF(btrim("skuCode"), '') IS NOT NULL;
CREATE UNIQUE INDEX "IntakeNonIntegratedSkuItem_barcode_non_empty_key"
ON "IntakeNonIntegratedSkuItem"("barcode")
WHERE NULLIF(btrim("barcode"), '') IS NOT NULL;
CREATE INDEX "IntakeNonIntegratedSkuItem_productId_idx" ON "IntakeNonIntegratedSkuItem"("productId");
CREATE INDEX "IntakeNonIntegratedSkuItem_skuCode_idx" ON "IntakeNonIntegratedSkuItem"("skuCode");
CREATE INDEX "IntakeNonIntegratedSkuItem_barcode_idx" ON "IntakeNonIntegratedSkuItem"("barcode");
CREATE INDEX "IntakeNonIntegratedSkuItem_model_idx" ON "IntakeNonIntegratedSkuItem"("model");
CREATE INDEX "IntakeNonIntegratedSkuItem_dataQualityStatus_idx" ON "IntakeNonIntegratedSkuItem"("dataQualityStatus");
CREATE INDEX "IntakeNonIntegratedSkuItem_sourceTable_sourceSkuId_idx" ON "IntakeNonIntegratedSkuItem"("sourceTable", "sourceSkuId");
CREATE OR REPLACE FUNCTION "cleanup_intake_non_integrated_sku_items_on_soft_delete"()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM "IntakeNonIntegratedSkuItem"
WHERE "productId" = NEW."id";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items"
AFTER UPDATE OF "deletedAt" ON "IntakeNonIntegratedProduct"
FOR EACH ROW
WHEN (OLD."deletedAt" IS NULL AND NEW."deletedAt" IS NOT NULL)
EXECUTE FUNCTION "cleanup_intake_non_integrated_sku_items_on_soft_delete"();
WITH source_sku_items AS (
SELECT
p."id" AS product_id,
item.sku_item,
item.ordinality::integer AS sort_order,
COALESCE(NULLIF(btrim(item.sku_item ->> 'id'), ''), 'sku-' || item.ordinality::text) AS raw_sku_item_id
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS item(sku_item, ordinality)
WHERE p."deletedAt" IS NULL
),
deduped_sku_items AS (
SELECT
source_sku_items.*,
CASE
WHEN COUNT(*) OVER (PARTITION BY product_id, raw_sku_item_id) = 1
THEN raw_sku_item_id
ELSE raw_sku_item_id || '-' || ROW_NUMBER() OVER (
PARTITION BY product_id, raw_sku_item_id
ORDER BY sort_order
)::text
END AS sku_item_id
FROM source_sku_items
),
normalized_sku_items AS (
SELECT
product_id,
sku_item,
sort_order,
sku_item_id,
array_remove(
ARRAY[
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'skuCode', '')), '') IS NULL
THEN 'sku_code'
END,
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'specName', '')), '') IS NULL
THEN 'spec_name'
END,
CASE
WHEN NULLIF(btrim(COALESCE(sku_item ->> 'model', '')), '') IS NULL
THEN 'model'
END
]::text[],
NULL
) AS missing_required_fields
FROM deduped_sku_items
)
INSERT INTO "IntakeNonIntegratedSkuItem" (
"id",
"productId",
"skuItemId",
"sortOrder",
"skuSequence",
"batchNo",
"packageGroupId",
"image",
"imageName",
"model",
"skuCode",
"specName",
"barcode",
"size",
"color",
"material",
"boardMaterialType",
"versionNo",
"attributeCode",
"drawingId",
"installMethod",
"executeStandard",
"nationalStandardCompliant",
"manufacturer",
"manufacturerCode",
"salesStatus",
"tagMaterial",
"model3dDataUrl",
"source",
"sourceTable",
"sourceSkuId",
"contentLocked",
"dataQualityStatus",
"missingRequiredFields"
)
SELECT
product_id || ':' || sku_item_id AS id,
product_id,
sku_item_id,
sort_order,
CASE
WHEN COALESCE(sku_item ->> 'skuSequence', '') ~ '^-?[0-9]+$'
THEN (sku_item ->> 'skuSequence')::integer
ELSE 0
END,
CASE
WHEN COALESCE(sku_item ->> 'batchNo', '') ~ '^[0-9]+$'
THEN GREATEST(1, (sku_item ->> 'batchNo')::integer)
ELSE 1
END,
COALESCE(sku_item ->> 'packageGroupId', ''),
NULLIF(sku_item ->> 'image', ''),
COALESCE(sku_item ->> 'imageName', ''),
COALESCE(sku_item ->> 'model', ''),
COALESCE(sku_item ->> 'skuCode', ''),
COALESCE(sku_item ->> 'specName', ''),
COALESCE(sku_item ->> 'barcode', ''),
COALESCE(sku_item ->> 'size', ''),
COALESCE(sku_item ->> 'color', ''),
COALESCE(sku_item ->> 'material', ''),
COALESCE(sku_item ->> 'boardMaterialType', ''),
COALESCE(NULLIF(sku_item ->> 'versionNo', ''), 'V01'),
COALESCE(sku_item ->> 'attributeCode', ''),
COALESCE(sku_item ->> 'drawingId', ''),
COALESCE(sku_item ->> 'installMethod', ''),
COALESCE(sku_item ->> 'executeStandard', ''),
COALESCE(sku_item ->> 'nationalStandardCompliant', ''),
COALESCE(sku_item ->> 'manufacturer', ''),
COALESCE(sku_item ->> 'manufacturerCode', ''),
COALESCE(sku_item ->> 'salesStatus', ''),
COALESCE(sku_item ->> 'tagMaterial', ''),
NULLIF(sku_item ->> 'model3dDataUrl', ''),
COALESCE(sku_item ->> 'source', ''),
COALESCE(sku_item ->> 'sourceTable', ''),
COALESCE(sku_item ->> 'sourceSkuId', ''),
LOWER(COALESCE(sku_item ->> 'contentLocked', '')) IN ('true', '1', 'yes', 'on'),
CASE
WHEN cardinality(missing_required_fields) = 0
THEN 'READY'::"IntakeNonIntegratedSkuDataQualityStatus"
ELSE 'INCOMPLETE'::"IntakeNonIntegratedSkuDataQualityStatus"
END,
missing_required_fields
FROM normalized_sku_items;
CREATE OR REPLACE VIEW external.intake_sku_items_v1 AS
SELECT
p."id" AS intake_product_id,
p."spu",
p."brand",
sku."skuItemId" AS intake_sku_item_id,
sku."skuSequence" AS sku_sequence,
sku."batchNo" AS batch_no,
sku."image" AS image_url,
sku."imageName" AS image_name,
sku."model",
sku."skuCode" AS sku_code,
sku."specName" AS spec_name,
sku."barcode",
sku."size",
sku."color",
sku."material",
sku."versionNo" AS version_no,
sku."attributeCode" AS attribute_code,
sku."drawingId" AS drawing_id,
sku."installMethod" AS install_method,
sku."executeStandard" AS execute_standard,
sku."manufacturer",
sku."manufacturerCode" AS manufacturer_code,
sku."salesStatus" AS sales_status,
sku."model3dDataUrl" AS model_3d_url,
sku."source",
sku."sourceTable" AS source_table,
sku."sourceSkuId" AS source_sku_id,
sku."missingRequiredFields" AS missing_required_fields,
sku."dataQualityStatus"::text AS data_quality_status,
sku."contentLocked" AS content_locked,
sku."sortOrder" AS sort_order
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" p ON p."id" = sku."productId"
WHERE p."deletedAt" IS NULL;

View File

@ -0,0 +1,278 @@
-- Structure the formal SKU asset data copy.
-- ProductSKU.attributes.assetSkuSnapshot remains as a compatibility snapshot,
-- but ProductSkuAssetProfile becomes the structured source for internal checks
-- and external read-only views.
CREATE TYPE "ProductSkuAssetSourceStatus" AS ENUM ('ACTIVE', 'DELETED', 'MISSING');
ALTER TABLE "ProductSKU" ADD COLUMN "name" TEXT;
CREATE INDEX "ProductSKU_name_idx" ON "ProductSKU"("name");
UPDATE "ProductSKU" sku
SET "name" = COALESCE(
NULLIF(sku."attributes"::jsonb ->> 'skuName', ''),
NULLIF(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'specName', ''),
sku."skuCode"
)
WHERE sku."name" IS NULL;
CREATE TABLE "ProductSkuAssetProfile" (
"id" TEXT NOT NULL,
"skuId" TEXT NOT NULL,
"primarySourceAssetVariantId" TEXT,
"sourceAssetVariantIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"sourceAssetFamilyId" TEXT,
"sourceAssetSeriesId" TEXT,
"model" TEXT,
"modelName" TEXT,
"specCode" TEXT,
"specName" TEXT,
"barcode" TEXT,
"size" JSONB NOT NULL,
"color" TEXT,
"material" TEXT,
"imageUrl" TEXT,
"sourceStatus" "ProductSkuAssetSourceStatus" NOT NULL DEFAULT 'ACTIVE',
"syncedAt" TIMESTAMP(3) NOT NULL,
"lockedFields" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"lockedReasons" JSONB NOT NULL,
"sourceVariants" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductSkuAssetProfile_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProductSkuAssetProfile_skuId_key" ON "ProductSkuAssetProfile"("skuId");
CREATE INDEX "ProductSkuAssetProfile_primarySourceAssetVariantId_idx" ON "ProductSkuAssetProfile"("primarySourceAssetVariantId");
CREATE INDEX "ProductSkuAssetProfile_sourceAssetVariantIds_idx" ON "ProductSkuAssetProfile" USING GIN ("sourceAssetVariantIds");
CREATE INDEX "ProductSkuAssetProfile_sourceAssetFamilyId_idx" ON "ProductSkuAssetProfile"("sourceAssetFamilyId");
CREATE INDEX "ProductSkuAssetProfile_sourceAssetSeriesId_idx" ON "ProductSkuAssetProfile"("sourceAssetSeriesId");
CREATE INDEX "ProductSkuAssetProfile_specCode_idx" ON "ProductSkuAssetProfile"("specCode");
CREATE INDEX "ProductSkuAssetProfile_barcode_idx" ON "ProductSkuAssetProfile"("barcode");
CREATE INDEX "ProductSkuAssetProfile_sourceStatus_idx" ON "ProductSkuAssetProfile"("sourceStatus");
ALTER TABLE "ProductSkuAssetProfile"
ADD CONSTRAINT "ProductSkuAssetProfile_skuId_fkey"
FOREIGN KEY ("skuId") REFERENCES "ProductSKU"("id") ON DELETE CASCADE ON UPDATE CASCADE;
WITH sku_source AS (
SELECT
sku."id" AS sku_id,
sku."sourceAssetVariantId",
sku."sourceAssetVariantIds",
sku."attributes"::jsonb -> 'assetSkuSnapshot' AS asset_snapshot,
sku."updatedAt"
FROM "ProductSKU" sku
JOIN "Product" product ON product."id" = sku."productId"
WHERE sku."deletedAt" IS NULL
AND product."deletedAt" IS NULL
AND jsonb_typeof(sku."attributes"::jsonb -> 'assetSkuSnapshot') = 'object'
)
INSERT INTO "ProductSkuAssetProfile" (
"id",
"skuId",
"primarySourceAssetVariantId",
"sourceAssetVariantIds",
"sourceAssetFamilyId",
"sourceAssetSeriesId",
"model",
"modelName",
"specCode",
"specName",
"barcode",
"size",
"color",
"material",
"imageUrl",
"sourceStatus",
"syncedAt",
"lockedFields",
"lockedReasons",
"sourceVariants",
"createdAt",
"updatedAt"
)
SELECT
sku_id,
sku_id,
COALESCE(NULLIF(asset_snapshot ->> 'sourceAssetVariantId', ''), "sourceAssetVariantId"),
CASE
WHEN jsonb_typeof(asset_snapshot -> 'sourceAssetVariantIds') = 'array' THEN ARRAY(
SELECT jsonb_array_elements_text(asset_snapshot -> 'sourceAssetVariantIds')
)
ELSE COALESCE("sourceAssetVariantIds", ARRAY[]::TEXT[])
END,
NULLIF(asset_snapshot ->> 'sourceAssetFamilyId', ''),
NULLIF(asset_snapshot ->> 'sourceAssetSeriesId', ''),
NULLIF(asset_snapshot ->> 'model', ''),
NULLIF(asset_snapshot ->> 'modelName', ''),
NULLIF(asset_snapshot ->> 'specCode', ''),
NULLIF(asset_snapshot ->> 'specName', ''),
NULLIF(asset_snapshot ->> 'barcode', ''),
CASE
WHEN jsonb_typeof(asset_snapshot -> 'size') = 'object' THEN asset_snapshot -> 'size'
ELSE '{}'::jsonb
END,
NULLIF(asset_snapshot ->> 'color', ''),
NULLIF(asset_snapshot ->> 'material', ''),
NULLIF(asset_snapshot ->> 'imageUrl', ''),
CASE asset_snapshot ->> 'sourceStatus'
WHEN 'DELETED' THEN 'DELETED'::"ProductSkuAssetSourceStatus"
WHEN 'MISSING' THEN 'MISSING'::"ProductSkuAssetSourceStatus"
ELSE 'ACTIVE'::"ProductSkuAssetSourceStatus"
END,
CASE
WHEN COALESCE(asset_snapshot ->> 'syncedAt', '') ~ '^\d{4}-\d{2}-\d{2}T'
THEN (asset_snapshot ->> 'syncedAt')::timestamp(3)
ELSE "updatedAt"
END,
COALESCE(
ARRAY(
SELECT jsonb_array_elements_text(asset_snapshot -> 'lockedFields')
WHERE jsonb_typeof(asset_snapshot -> 'lockedFields') = 'array'
),
ARRAY[]::TEXT[]
),
CASE
WHEN jsonb_typeof(asset_snapshot -> 'lockedReasons') = 'object' THEN asset_snapshot -> 'lockedReasons'
ELSE '{}'::jsonb
END,
CASE
WHEN jsonb_typeof(asset_snapshot -> 'sourceVariants') = 'array' THEN asset_snapshot -> 'sourceVariants'
ELSE '[]'::jsonb
END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM sku_source
ON CONFLICT ("skuId") DO NOTHING;
CREATE OR REPLACE VIEW external.product_skus_v1 AS
WITH sku_source AS (
SELECT
sku.*,
sku."attributes"::jsonb AS attributes_json
FROM "ProductSKU" sku
)
SELECT
p."id" AS product_id,
p."modelNumber" AS product_model_number,
p."name" AS product_name,
p."description" AS product_description,
pc."id" AS product_category_id,
pc."name" AS product_category_name,
p."thumbnailUrl" AS product_thumbnail_url,
p."modelUrl" AS product_model_url,
sku."id" AS sku_id,
sku."skuCode" AS sku_code,
COALESCE(
NULLIF(sku."name", ''),
NULLIF(sku.attributes_json ->> 'skuName', ''),
NULLIF(profile."specName", ''),
sku."skuCode"
) AS sku_name,
sku."price" AS price,
sku."costPrice" AS cost_price,
sku."stock" AS stock,
sku."isActive" AS is_active,
CASE WHEN sku."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status,
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url,
COALESCE(
NULLIF(profile."imageUrl", ''),
sku."images"[1],
NULLIF(p."thumbnailUrl", '')
) AS image_url,
sku."images" AS image_urls,
sku."sourcePreSkuId" AS source_pre_sku_id,
sku."sourceAssetVariantId" AS source_asset_variant_id,
sku."sourceAssetVariantIds" AS source_asset_variant_ids,
profile."model" AS asset_family_code,
profile."modelName" AS asset_family_name,
profile."specCode" AS spec_code,
profile."specName" AS spec_name,
profile."barcode" AS barcode,
profile."size" AS size_json,
profile."color" AS color,
profile."material" AS material,
profile."sourceStatus"::text AS source_status,
profile."syncedAt"::text AS synced_at,
wh."id" AS warehouse_location_id,
wh."code" AS warehouse_location_code,
wh."name" AS warehouse_location_name,
sku."createdAt" AS created_at,
sku."updatedAt" AS updated_at
FROM sku_source sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
LEFT JOIN "ProductCategory" pc ON pc."id" = p."categoryId"
LEFT JOIN "WarehouseLocation" wh ON wh."id" = sku."warehouseLocationId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.product_assets_v1 AS
WITH image_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
image_item.image_url,
image_item.sort_order
FROM "ProductSKU" sku
CROSS JOIN LATERAL unnest(sku."images") WITH ORDINALITY AS image_item(image_url, sort_order)
WHERE sku."deletedAt" IS NULL
),
model_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
),
thumbnail_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
COALESCE(
NULLIF(profile."imageUrl", ''),
NULLIF(p."thumbnailUrl", '')
) AS thumbnail_url
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
)
SELECT
'SKU_IMAGE' AS asset_type,
product_id,
sku_id,
image_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
sort_order::integer AS sort_order
FROM image_assets
WHERE NULLIF(image_url, '') IS NOT NULL
UNION ALL
SELECT
'SKU_MODEL' AS asset_type,
product_id,
sku_id,
model_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
0 AS sort_order
FROM model_assets
WHERE NULLIF(model_url, '') IS NOT NULL
UNION ALL
SELECT
'SKU_THUMBNAIL' AS asset_type,
product_id,
sku_id,
thumbnail_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
0 AS sort_order
FROM thumbnail_assets
WHERE NULLIF(thumbnail_url, '') IS NOT NULL;

View File

@ -0,0 +1,106 @@
-- Structure editable PreSku basic information.
-- PreSku.displaySummary remains a compatibility/process snapshot, while
-- PreSkuBasicInfo becomes the structured draft source for fields that can flow
-- into formal intake.
CREATE TABLE "PreSkuBasicInfo" (
"id" TEXT NOT NULL,
"preSkuId" TEXT NOT NULL,
"model" TEXT,
"formalSkuCode" TEXT,
"barcode" TEXT,
"size" TEXT,
"color" TEXT,
"material" TEXT,
"specName" TEXT,
"versionNo" TEXT,
"setCount" TEXT,
"attributeCode" TEXT,
"drawingId" TEXT,
"installMethod" TEXT,
"manualUpdatedBy" TEXT,
"manualUpdatedByName" TEXT,
"manualUpdatedAt" TIMESTAMP(3),
"source" TEXT NOT NULL DEFAULT 'DRAFT_GROUP',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PreSkuBasicInfo_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PreSkuBasicInfo_preSkuId_key" ON "PreSkuBasicInfo"("preSkuId");
CREATE INDEX "PreSkuBasicInfo_model_idx" ON "PreSkuBasicInfo"("model");
CREATE INDEX "PreSkuBasicInfo_formalSkuCode_idx" ON "PreSkuBasicInfo"("formalSkuCode");
CREATE INDEX "PreSkuBasicInfo_barcode_idx" ON "PreSkuBasicInfo"("barcode");
CREATE INDEX "PreSkuBasicInfo_specName_versionNo_idx" ON "PreSkuBasicInfo"("specName", "versionNo");
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_preSkuId_fkey"
FOREIGN KEY ("preSkuId") REFERENCES "PreSku"("id") ON DELETE CASCADE ON UPDATE CASCADE;
INSERT INTO "PreSkuBasicInfo" (
"id",
"preSkuId",
"model",
"formalSkuCode",
"barcode",
"size",
"color",
"material",
"specName",
"versionNo",
"setCount",
"attributeCode",
"drawingId",
"installMethod",
"manualUpdatedBy",
"manualUpdatedByName",
"manualUpdatedAt",
"source",
"createdAt",
"updatedAt"
)
SELECT
pre_sku."id",
pre_sku."id",
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'size', pre_sku."familySelection"::jsonb ->> 'size', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'color', pre_sku."familySelection"::jsonb ->> 'color', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'material', pre_sku."familySelection"::jsonb ->> 'material', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'versionNo', pre_sku."familySelection"::jsonb ->> 'versionNo', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'setCount', pre_sku."familySelection"::jsonb ->> 'setCount', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'attributeCode', pre_sku."familySelection"::jsonb ->> 'attributeCode', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'drawingId', pre_sku."familySelection"::jsonb ->> 'drawingId', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'installMethod', pre_sku."familySelection"::jsonb ->> 'installMethod', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'basicInfoManualUpdatedBy', '')), ''),
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'basicInfoManualUpdatedByName', '')), ''),
CASE
WHEN COALESCE(pre_sku."displaySummary"::jsonb ->> 'basicInfoManualUpdatedAt', '') ~ '^\d{4}-\d{2}-\d{2}T'
THEN (pre_sku."displaySummary"::jsonb ->> 'basicInfoManualUpdatedAt')::timestamp(3)
ELSE NULL
END,
CASE
WHEN pre_sku."displaySummary"::jsonb ? 'basicInfoManualUpdatedAt' THEN 'MANUAL'
ELSE 'DRAFT_GROUP'
END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "PreSku" pre_sku
WHERE (
NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'size', pre_sku."familySelection"::jsonb ->> 'size', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'color', pre_sku."familySelection"::jsonb ->> 'color', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'material', pre_sku."familySelection"::jsonb ->> 'material', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'versionNo', pre_sku."familySelection"::jsonb ->> 'versionNo', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'setCount', pre_sku."familySelection"::jsonb ->> 'setCount', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'attributeCode', pre_sku."familySelection"::jsonb ->> 'attributeCode', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'drawingId', pre_sku."familySelection"::jsonb ->> 'drawingId', '')), '') IS NOT NULL
OR NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'installMethod', pre_sku."familySelection"::jsonb ->> 'installMethod', '')), '') IS NOT NULL
)
ON CONFLICT ("preSkuId") DO NOTHING;

View File

@ -0,0 +1,102 @@
-- Tighten external read-only views so finished product and asset-family fields
-- read structured columns instead of compatibility JSON snapshots.
CREATE OR REPLACE VIEW external.asset_variants_v1 AS
SELECT
v."id" AS asset_variant_id,
v."code" AS spec_code,
v."name" AS spec_name,
v."assetType"::text AS asset_type,
v."status"::text AS asset_status,
f."id" AS asset_family_id,
f."code" AS asset_family_code,
f."name" AS asset_family_name,
s."id" AS asset_series_id,
s."code" AS asset_series_code,
s."name" AS asset_series_name,
pc."id" AS product_category_id,
pc."name" AS product_category_name,
v."sizeLabel" AS size_label,
v."dimensions" AS dimensions,
v."modelUrl" AS model_url,
v."thumbnailUrl" AS thumbnail_url,
v."barcode" AS barcode,
v."supplierName" AS supplier_name,
v."supplierCode" AS supplier_code,
v."versionNo" AS version_no,
sub."id" AS substrate_id,
sub."name" AS substrate_name,
finish."id" AS finish_id,
finish."code" AS finish_code,
finish."name" AS finish_name,
CASE
WHEN sub."name" IS NOT NULL AND finish."name" IS NOT NULL THEN sub."name" || ' + ' || finish."name"
ELSE COALESCE(sub."name", finish."name")
END AS material_label,
v."weightKg" AS weight_kg,
v."costPrice" AS cost_price,
v."estimatedCostPrice" AS estimated_cost_price,
v."createdAt" AS created_at,
v."updatedAt" AS updated_at
FROM "AssetVariant" v
JOIN "AssetFamily" f ON f."id" = v."assetFamilyId"
LEFT JOIN "AssetSeries" s ON s."id" = f."assetSeriesId"
LEFT JOIN "ProductCategory" pc ON pc."id" = f."productCategoryId"
LEFT JOIN "Substrate" sub ON sub."id" = v."substrateId"
LEFT JOIN "Material" finish ON finish."id" = v."finishId"
WHERE v."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.product_skus_v1 AS
SELECT
p."id" AS product_id,
p."modelNumber" AS product_model_number,
p."name" AS product_name,
p."description" AS product_description,
pc."id" AS product_category_id,
pc."name" AS product_category_name,
p."thumbnailUrl" AS product_thumbnail_url,
p."modelUrl" AS product_model_url,
sku."id" AS sku_id,
sku."skuCode" AS sku_code,
COALESCE(
NULLIF(sku."name", ''),
NULLIF(profile."specName", ''),
sku."skuCode"
) AS sku_name,
sku."price" AS price,
sku."costPrice" AS cost_price,
sku."stock" AS stock,
sku."isActive" AS is_active,
CASE WHEN sku."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status,
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url,
COALESCE(
NULLIF(profile."imageUrl", ''),
sku."images"[1],
NULLIF(p."thumbnailUrl", '')
) AS image_url,
sku."images" AS image_urls,
sku."sourcePreSkuId" AS source_pre_sku_id,
sku."sourceAssetVariantId" AS source_asset_variant_id,
sku."sourceAssetVariantIds" AS source_asset_variant_ids,
profile."model" AS asset_family_code,
profile."modelName" AS asset_family_name,
profile."specCode" AS spec_code,
profile."specName" AS spec_name,
profile."barcode" AS barcode,
profile."size" AS size_json,
profile."color" AS color,
profile."material" AS material,
profile."sourceStatus"::text AS source_status,
profile."syncedAt"::text AS synced_at,
wh."id" AS warehouse_location_id,
wh."code" AS warehouse_location_code,
wh."name" AS warehouse_location_name,
sku."createdAt" AS created_at,
sku."updatedAt" AS updated_at
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
LEFT JOIN "ProductCategory" pc ON pc."id" = p."categoryId"
LEFT JOIN "WarehouseLocation" wh ON wh."id" = sku."warehouseLocationId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL;

View File

@ -0,0 +1,184 @@
-- Structure non-integrated product-level master fields that were previously
-- only available inside IntakeNonIntegratedProduct.payload.
CREATE TABLE "IntakeNonIntegratedProductProfile" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productName" TEXT NOT NULL DEFAULT '',
"productImageUrl" TEXT,
"productCategory" TEXT NOT NULL DEFAULT '',
"salesCategory" TEXT NOT NULL DEFAULT '',
"productYear" TEXT NOT NULL DEFAULT '',
"productStatus" TEXT NOT NULL DEFAULT '',
"productDevelopmentKind" TEXT NOT NULL DEFAULT '',
"goodsType" TEXT NOT NULL DEFAULT '',
"materialType" TEXT NOT NULL DEFAULT '',
"productionType" TEXT NOT NULL DEFAULT '',
"size" TEXT NOT NULL DEFAULT '',
"color" TEXT NOT NULL DEFAULT '',
"tagMaterial" TEXT NOT NULL DEFAULT '',
"priceBand" TEXT NOT NULL DEFAULT '',
"demandFlowNo" TEXT NOT NULL DEFAULT '',
"processTemplate" TEXT NOT NULL DEFAULT '',
"productPlanning" TEXT NOT NULL DEFAULT '',
"brandPlanning" TEXT NOT NULL DEFAULT '',
"demandDepartment" TEXT NOT NULL DEFAULT '',
"spuOwner" TEXT NOT NULL DEFAULT '',
"operationsOwner" TEXT NOT NULL DEFAULT '',
"purchaseOwner" TEXT NOT NULL DEFAULT '',
"visualDirector" TEXT NOT NULL DEFAULT '',
"visualPlanning" TEXT NOT NULL DEFAULT '',
"structureDesigner" TEXT NOT NULL DEFAULT '',
"source" TEXT NOT NULL DEFAULT 'PAYLOAD_SYNC',
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeNonIntegratedProductProfile_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "IntakeNonIntegratedProductProfile_productId_key"
ON "IntakeNonIntegratedProductProfile"("productId");
CREATE INDEX "IntakeNonIntegratedProductProfile_productName_idx"
ON "IntakeNonIntegratedProductProfile"("productName");
CREATE INDEX "IntakeNonIntegratedProductProfile_productCategory_idx"
ON "IntakeNonIntegratedProductProfile"("productCategory");
CREATE INDEX "IntakeNonIntegratedProductProfile_salesCategory_idx"
ON "IntakeNonIntegratedProductProfile"("salesCategory");
CREATE INDEX "IntakeNonIntegratedProductProfile_productStatus_idx"
ON "IntakeNonIntegratedProductProfile"("productStatus");
CREATE INDEX "IntakeNonIntegratedProductProfile_productDevelopmentKind_idx"
ON "IntakeNonIntegratedProductProfile"("productDevelopmentKind");
CREATE INDEX "IntakeNonIntegratedProductProfile_spuOwner_idx"
ON "IntakeNonIntegratedProductProfile"("spuOwner");
CREATE INDEX "IntakeNonIntegratedProductProfile_purchaseOwner_idx"
ON "IntakeNonIntegratedProductProfile"("purchaseOwner");
ALTER TABLE "IntakeNonIntegratedProductProfile"
ADD CONSTRAINT "IntakeNonIntegratedProductProfile_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE;
INSERT INTO "IntakeNonIntegratedProductProfile" (
"id",
"productId",
"productName",
"productImageUrl",
"productCategory",
"salesCategory",
"productYear",
"productStatus",
"productDevelopmentKind",
"goodsType",
"materialType",
"productionType",
"size",
"color",
"tagMaterial",
"priceBand",
"demandFlowNo",
"processTemplate",
"productPlanning",
"brandPlanning",
"demandDepartment",
"spuOwner",
"operationsOwner",
"purchaseOwner",
"visualDirector",
"visualPlanning",
"structureDesigner",
"source",
"syncedAt",
"createdAt",
"updatedAt"
)
SELECT
'inip_' || p."id",
p."id",
COALESCE(
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'productName'), ''),
NULLIF(btrim(p."payload"::jsonb ->> 'previewImageName'), ''),
p."spu"
),
COALESCE(
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'productImage'), ''),
NULLIF(btrim(p."payload"::jsonb ->> 'previewImage'), '')
),
COALESCE(
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'productCategory'), ''),
NULLIF(btrim(p."payload"::jsonb ->> 'productCategory'), ''),
''
),
COALESCE(
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'salesCategory'), ''),
NULLIF(btrim(p."payload"::jsonb ->> 'salesCategory'), ''),
''
),
COALESCE(
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'year'), ''),
NULLIF(btrim(p."payload"::jsonb ->> 'year'), ''),
''
),
COALESCE(
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'currentStage'), ''),
NULLIF(btrim(p."payload"::jsonb ->> 'projectStatus'), ''),
''
),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'productDevelopmentKind'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'goodsType'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'materialType'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'productionType'), ''), ''),
COALESCE(
NULLIF(btrim(p."payload"::jsonb ->> 'size'), ''),
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'size'), ''),
''
),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'color'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'tagMaterial'), ''), ''),
COALESCE(
NULLIF(btrim(p."payload"::jsonb ->> 'priceBand'), ''),
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'targetPriceBand'), ''),
''
),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'demandFlowNo'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'processTemplate'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'productPlanning'), ''),
NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'planningOwner'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'brandPlanning'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'demandDepartment'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'spuOwner'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'operationsOwner'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'purchaseOwner'), ''), NULLIF(btrim(p."purchaser"), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'visualDirector'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb ->> 'visualPlanning'), ''), ''),
COALESCE(NULLIF(btrim(p."payload"::jsonb -> 'openingReference' ->> 'structureDesigner'), ''), ''),
'MIGRATED_FROM_PAYLOAD',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "IntakeNonIntegratedProduct" p
WHERE p."deletedAt" IS NULL
ON CONFLICT ("productId") DO NOTHING;
CREATE OR REPLACE VIEW external.intake_products_v1 AS
SELECT
p."id" AS intake_product_id,
p."spu" AS spu,
p."spuBase" AS spu_base,
p."brand" AS brand,
COALESCE(NULLIF(profile."productName", ''), p."spu") AS product_name,
profile."productImageUrl" AS product_image_url,
profile."productCategory" AS product_category,
profile."salesCategory" AS sales_category,
profile."productYear" AS product_year,
profile."productStatus" AS product_status,
COALESCE(NULLIF(profile."structureDesigner", ''), NULLIF(p."designer", '')) AS designer,
p."operator" AS operator,
p."purchaser" AS purchaser,
profile."productDevelopmentKind" AS product_development_kind,
profile."goodsType" AS goods_type,
profile."materialType" AS material_type,
profile."productionType" AS production_type,
p."createdAt" AS created_at,
p."updatedAt" AS updated_at
FROM "IntakeNonIntegratedProduct" p
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = p."id"
WHERE p."deletedAt" IS NULL;

View File

@ -0,0 +1,54 @@
-- Move product patent ledger records from browser localStorage into PostgreSQL.
-- localStorage may remain as a legacy client cache, but this table is the data truth.
CREATE TABLE IF NOT EXISTS "ProductPatentRecord" (
"id" TEXT NOT NULL,
"patentGroupKey" TEXT NOT NULL DEFAULT '',
"productId" TEXT NOT NULL DEFAULT '',
"linkedProductIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"linkedProductNames" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"linkedSpus" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"linkedBrands" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"productDisplayName" TEXT NOT NULL DEFAULT '',
"productImage" TEXT,
"productImageName" TEXT NOT NULL DEFAULT '',
"patentStatus" TEXT NOT NULL DEFAULT '待确认',
"patentType" TEXT NOT NULL DEFAULT '外观设计',
"patentName" TEXT NOT NULL DEFAULT '',
"spu" TEXT NOT NULL DEFAULT '',
"saRequired" TEXT NOT NULL DEFAULT '否',
"salesStatus" TEXT NOT NULL DEFAULT '在售',
"designer" TEXT NOT NULL DEFAULT '',
"applicationNo" TEXT NOT NULL DEFAULT '',
"patentNo" TEXT NOT NULL DEFAULT '',
"applicationDate" TEXT NOT NULL DEFAULT '',
"authorizationDate" TEXT NOT NULL DEFAULT '',
"firstFeeDate" TEXT NOT NULL DEFAULT '',
"annualFees" JSONB NOT NULL DEFAULT '{}'::jsonb,
"annualFeeDates" JSONB NOT NULL DEFAULT '{}'::jsonb,
"nextFeeDueDate" TEXT NOT NULL DEFAULT '',
"feeAmount" TEXT NOT NULL DEFAULT '',
"patentCertificateFileName" TEXT NOT NULL DEFAULT '',
"patentCertificateFileUrl" TEXT,
"patentCertificateFileMimeType" TEXT NOT NULL DEFAULT '',
"patentCertificateStorageKey" TEXT NOT NULL DEFAULT '',
"annualFeeNote" TEXT NOT NULL DEFAULT '',
"continueDecision" TEXT NOT NULL DEFAULT '待判断',
"decisionReason" TEXT NOT NULL DEFAULT '',
"owner" TEXT NOT NULL DEFAULT '',
"remark" TEXT NOT NULL DEFAULT '',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductPatentRecord_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_patentGroupKey_idx" ON "ProductPatentRecord"("patentGroupKey");
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_productId_idx" ON "ProductPatentRecord"("productId");
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_spu_idx" ON "ProductPatentRecord"("spu");
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_patentStatus_idx" ON "ProductPatentRecord"("patentStatus");
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_patentNo_idx" ON "ProductPatentRecord"("patentNo");
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_applicationNo_idx" ON "ProductPatentRecord"("applicationNo");
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_designer_idx" ON "ProductPatentRecord"("designer");
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_nextFeeDueDate_idx" ON "ProductPatentRecord"("nextFeeDueDate");
CREATE INDEX IF NOT EXISTS "ProductPatentRecord_sortOrder_idx" ON "ProductPatentRecord"("sortOrder");

View File

@ -0,0 +1,44 @@
-- Move JST stock source fields from IntakeNonIntegratedProduct.payload.metadata
-- into the structured product profile. Payload remains a compatibility snapshot.
ALTER TABLE "IntakeNonIntegratedProductProfile"
ADD COLUMN "sourceSpu" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sourceSkuId" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sourceCategory" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sourceItemType" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sourceSalesStatus" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sourceEnabled" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sourceStockDisabled" TEXT NOT NULL DEFAULT '',
ADD COLUMN "stockSkuCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "stockHasOnSaleSku" BOOLEAN NOT NULL DEFAULT false;
UPDATE "IntakeNonIntegratedProductProfile" profile
SET
"sourceSpu" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSpu'), ''), ''),
"sourceSkuId" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSkuId'), ''), ''),
"sourceCategory" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceCategory'), ''), ''),
"sourceItemType" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceItemType'), ''), ''),
"sourceSalesStatus" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), ''),
"sourceEnabled" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceEnabled'), ''), ''),
"sourceStockDisabled" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceStockDisabled'), ''), ''),
"stockSkuCount" = CASE
WHEN jsonb_typeof(product."payload"::jsonb -> 'skuItems') = 'array'
THEN jsonb_array_length(product."payload"::jsonb -> 'skuItems')
ELSE 0
END,
"stockHasOnSaleSku" = COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') = '销售'
FROM "IntakeNonIntegratedProduct" product
WHERE product."id" = profile."productId";
CREATE INDEX "IntakeNonIntegratedProductProfile_sourceSpu_idx"
ON "IntakeNonIntegratedProductProfile"("sourceSpu");
CREATE INDEX "IntakeNonIntegratedProductProfile_sourceSkuId_idx"
ON "IntakeNonIntegratedProductProfile"("sourceSkuId");
CREATE INDEX "IntakeNonIntegratedProductProfile_sourceCategory_idx"
ON "IntakeNonIntegratedProductProfile"("sourceCategory");
CREATE INDEX "IntakeNonIntegratedProductProfile_sourceItemType_idx"
ON "IntakeNonIntegratedProductProfile"("sourceItemType");
CREATE INDEX "IntakeNonIntegratedProductProfile_sourceSalesStatus_idx"
ON "IntakeNonIntegratedProductProfile"("sourceSalesStatus");
CREATE INDEX "IntakeNonIntegratedProductProfile_sourceEnabled_idx"
ON "IntakeNonIntegratedProductProfile"("sourceEnabled");

View File

@ -0,0 +1,35 @@
-- Provide a database truth-source landing zone for intake-side reference libraries
-- that are still surfaced as localStorage advisory items.
CREATE TABLE "IntakeReferenceLibraryItem" (
"id" TEXT NOT NULL,
"libraryType" TEXT NOT NULL,
"itemKey" TEXT NOT NULL,
"code" TEXT NOT NULL DEFAULT '',
"name" TEXT NOT NULL DEFAULT '',
"category" TEXT NOT NULL DEFAULT '',
"letter" TEXT NOT NULL DEFAULT '',
"payload" JSONB NOT NULL DEFAULT '{}'::jsonb,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeReferenceLibraryItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "IntakeReferenceLibraryItem_libraryType_itemKey_key"
ON "IntakeReferenceLibraryItem"("libraryType", "itemKey");
CREATE UNIQUE INDEX "IntakeReferenceLibraryItem_libraryType_code_non_empty_key"
ON "IntakeReferenceLibraryItem"("libraryType", "code")
WHERE NULLIF(btrim("code"), '') IS NOT NULL;
CREATE INDEX "IntakeReferenceLibraryItem_libraryType_isActive_sortOrder_idx"
ON "IntakeReferenceLibraryItem"("libraryType", "isActive", "sortOrder");
CREATE INDEX "IntakeReferenceLibraryItem_libraryType_code_idx"
ON "IntakeReferenceLibraryItem"("libraryType", "code");
CREATE INDEX "IntakeReferenceLibraryItem_libraryType_name_idx"
ON "IntakeReferenceLibraryItem"("libraryType", "name");
CREATE INDEX "IntakeReferenceLibraryItem_category_idx"
ON "IntakeReferenceLibraryItem"("category");

View File

@ -0,0 +1,78 @@
-- Tighten external intake views so external systems read structured source
-- tables instead of compatibility JSON payload snapshots.
CREATE OR REPLACE VIEW external.intake_products_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."spuBase" AS spu_base,
product."brand" AS brand,
profile."productName" AS product_name,
profile."productImageUrl" AS product_image_url,
profile."productCategory" AS product_category,
profile."salesCategory" AS sales_category,
profile."productYear" AS product_year,
profile."productStatus" AS product_status,
product."designer" AS designer,
product."operator" AS operator,
product."purchaser" AS purchaser,
profile."productDevelopmentKind" AS product_development_kind,
profile."goodsType" AS goods_type,
profile."materialType" AS material_type,
profile."productionType" AS production_type,
product."createdAt" AS created_at,
product."updatedAt" AS updated_at
FROM "IntakeNonIntegratedProduct" product
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_sku_items_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."brand" AS brand,
sku."skuItemId" AS intake_sku_item_id,
sku."skuSequence" AS sku_sequence,
sku."batchNo" AS batch_no,
sku."image" AS image_url,
sku."imageName" AS image_name,
sku."model" AS model,
sku."skuCode" AS sku_code,
sku."specName" AS spec_name,
sku."barcode" AS barcode,
sku."size" AS size,
sku."color" AS color,
sku."material" AS material,
sku."versionNo" AS version_no,
sku."attributeCode" AS attribute_code,
sku."drawingId" AS drawing_id,
sku."installMethod" AS install_method,
sku."executeStandard" AS execute_standard,
sku."manufacturer" AS manufacturer,
sku."manufacturerCode" AS manufacturer_code,
sku."salesStatus" AS sales_status,
sku."model3dDataUrl" AS model_3d_url,
sku."source" AS source,
sku."sourceTable" AS source_table,
sku."sourceSkuId" AS source_sku_id,
sku."missingRequiredFields" AS missing_required_fields,
sku."dataQualityStatus"::text AS data_quality_status,
sku."contentLocked" AS content_locked,
sku."sortOrder" AS sort_order
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_products_ready_v1 AS
SELECT *
FROM external.intake_products_v1
WHERE NULLIF(btrim(COALESCE(product_name, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_category, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_development_kind, '')), '') IS NOT NULL;
CREATE OR REPLACE VIEW external.intake_sku_items_ready_v1 AS
SELECT sku.*
FROM external.intake_sku_items_v1 sku
JOIN external.intake_products_ready_v1 product
ON product.intake_product_id = sku.intake_product_id
WHERE sku.data_quality_status = 'READY';

View File

@ -0,0 +1,69 @@
-- Dedicated SPU number counter.
-- SPU_RULE reference-library rows describe business rules; this table is the
-- transactional source for the next formal number in each product category.
CREATE TABLE IF NOT EXISTS "SpuNumberCounter" (
"id" TEXT NOT NULL,
"productCategory" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"lastUsedIndex" INTEGER NOT NULL DEFAULT 0,
"sourceLibraryItemId" TEXT,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SpuNumberCounter_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "SpuNumberCounter_productCategory_key"
ON "SpuNumberCounter"("productCategory");
CREATE INDEX IF NOT EXISTS "SpuNumberCounter_prefix_idx"
ON "SpuNumberCounter"("prefix");
CREATE INDEX IF NOT EXISTS "SpuNumberCounter_sourceLibraryItemId_idx"
ON "SpuNumberCounter"("sourceLibraryItemId");
INSERT INTO "SpuNumberCounter" (
"id",
"productCategory",
"prefix",
"lastUsedIndex",
"sourceLibraryItemId",
"createdBy",
"updatedBy",
"createdAt",
"updatedAt"
)
SELECT
'spu-counter-' || regexp_replace(lower(row."itemKey"), '[^a-z0-9_-]+', '-', 'g') AS "id",
row."name" AS "productCategory",
upper(row."code") AS "prefix",
GREATEST(
COALESCE(NULLIF(row."payload"::jsonb -> 'rule' ->> 'spuIndex', '')::integer, 0),
COALESCE(used.max_used_index, 0)
) AS "lastUsedIndex",
row."id" AS "sourceLibraryItemId",
row."createdBy",
row."updatedBy",
row."createdAt",
row."updatedAt"
FROM "IntakeReferenceLibraryItem" row
LEFT JOIN LATERAL (
SELECT MAX((substring(product."spuBase" from ('^' || upper(row."code") || '([0-9]{4})$')))::integer) AS max_used_index
FROM "IntakeNonIntegratedProduct" product
WHERE product."deletedAt" IS NULL
AND NULLIF(btrim(row."code"), '') IS NOT NULL
AND product."spuBase" ~ ('^' || upper(row."code") || '[0-9]{4}$')
) used ON true
WHERE row."libraryType" = 'SPU_RULE'
AND row."isActive" = true
AND NULLIF(btrim(row."name"), '') IS NOT NULL
AND NULLIF(btrim(row."code"), '') IS NOT NULL
ON CONFLICT ("productCategory") DO UPDATE SET
"prefix" = EXCLUDED."prefix",
"lastUsedIndex" = GREATEST("SpuNumberCounter"."lastUsedIndex", EXCLUDED."lastUsedIndex"),
"sourceLibraryItemId" = EXCLUDED."sourceLibraryItemId",
"updatedBy" = EXCLUDED."updatedBy",
"updatedAt" = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,88 @@
-- Add physical database guards for non-integrated master-data quality.
-- Draft rows may still be incomplete, but invalid enums or inconsistent READY
-- states must not survive service bypasses, imports, or manual SQL writes.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'IntakeNonIntegratedProductProfile_productDevelopmentKind_allowe'
)
AND NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'IntakeProductProfile_developmentKind_allowed'
) THEN
ALTER TABLE "IntakeNonIntegratedProductProfile"
RENAME CONSTRAINT "IntakeNonIntegratedProductProfile_productDevelopmentKind_allowe"
TO "IntakeProductProfile_developmentKind_allowed";
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'IntakeProductProfile_developmentKind_allowed'
) THEN
ALTER TABLE "IntakeNonIntegratedProductProfile"
ADD CONSTRAINT "IntakeProductProfile_developmentKind_allowed"
CHECK (
"productDevelopmentKind" = ''
OR "productDevelopmentKind" IN ('product', 'series')
);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "PreSkuBasicInfo_formalSkuCode_non_empty_key"
ON "PreSkuBasicInfo"("formalSkuCode")
WHERE NULLIF(btrim("formalSkuCode"), '') IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "PreSkuBasicInfo_barcode_non_empty_key"
ON "PreSkuBasicInfo"("barcode")
WHERE NULLIF(btrim("barcode"), '') IS NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'IntakeNonIntegratedSkuItem_missingRequiredFields_allowed'
) THEN
ALTER TABLE "IntakeNonIntegratedSkuItem"
ADD CONSTRAINT "IntakeNonIntegratedSkuItem_missingRequiredFields_allowed"
CHECK (
"missingRequiredFields" <@ ARRAY['sku_code', 'spec_name', 'model']::TEXT[]
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'IntakeNonIntegratedSkuItem_quality_state_required_fields'
) THEN
ALTER TABLE "IntakeNonIntegratedSkuItem"
ADD CONSTRAINT "IntakeNonIntegratedSkuItem_quality_state_required_fields"
CHECK (
(
"dataQualityStatus" = 'READY'
AND NULLIF(btrim("skuCode"), '') IS NOT NULL
AND NULLIF(btrim("specName"), '') IS NOT NULL
AND NULLIF(btrim("model"), '') IS NOT NULL
AND cardinality("missingRequiredFields") = 0
)
OR
(
"dataQualityStatus" = 'INCOMPLETE'
AND (
NULLIF(btrim("skuCode"), '') IS NULL
OR NULLIF(btrim("specName"), '') IS NULL
OR NULLIF(btrim("model"), '') IS NULL
OR cardinality("missingRequiredFields") > 0
)
)
);
END IF;
END $$;

View File

@ -0,0 +1,42 @@
-- Harden formal SPU counter source.
-- The counter table is the transactional source for formal SPU numbers; empty
-- categories/prefixes or negative indexes must be blocked below the service layer.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'SpuNumberCounter_productCategory_not_blank'
) THEN
ALTER TABLE "SpuNumberCounter"
ADD CONSTRAINT "SpuNumberCounter_productCategory_not_blank"
CHECK (NULLIF(btrim("productCategory"), '') IS NOT NULL);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'SpuNumberCounter_prefix_not_blank'
) THEN
ALTER TABLE "SpuNumberCounter"
ADD CONSTRAINT "SpuNumberCounter_prefix_not_blank"
CHECK (NULLIF(btrim("prefix"), '') IS NOT NULL);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'SpuNumberCounter_lastUsedIndex_non_negative'
) THEN
ALTER TABLE "SpuNumberCounter"
ADD CONSTRAINT "SpuNumberCounter_lastUsedIndex_non_negative"
CHECK ("lastUsedIndex" >= 0);
END IF;
END $$;

View File

@ -0,0 +1,8 @@
-- SPU_RULE product category must be one-to-one.
-- The formal counter is keyed by product category, so duplicate active rules
-- would make the initial counter source ambiguous.
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeReferenceLibraryItem_spu_rule_name_non_empty_key"
ON "IntakeReferenceLibraryItem"("name")
WHERE "libraryType" = 'SPU_RULE'
AND NULLIF(btrim("name"), '') IS NOT NULL;

View File

@ -0,0 +1,38 @@
-- External intake product people fields must read structured profile columns
-- first. Legacy product columns remain only as compatibility fallback.
CREATE OR REPLACE VIEW external.intake_products_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."spuBase" AS spu_base,
product."brand" AS brand,
profile."productName" AS product_name,
profile."productImageUrl" AS product_image_url,
profile."productCategory" AS product_category,
profile."salesCategory" AS sales_category,
profile."productYear" AS product_year,
profile."productStatus" AS product_status,
COALESCE(NULLIF(profile."structureDesigner", ''), NULLIF(product."designer", '')) AS designer,
COALESCE(NULLIF(profile."productPlanning", ''), NULLIF(product."operator", '')) AS operator,
COALESCE(NULLIF(profile."purchaseOwner", ''), NULLIF(product."purchaser", '')) AS purchaser,
profile."productDevelopmentKind" AS product_development_kind,
profile."goodsType" AS goods_type,
profile."materialType" AS material_type,
profile."productionType" AS production_type,
product."createdAt" AS created_at,
product."updatedAt" AS updated_at,
profile."productPlanning" AS product_planning,
profile."visualPlanning" AS visual_planning,
profile."structureDesigner" AS structure_designer,
profile."purchaseOwner" AS purchase_owner
FROM "IntakeNonIntegratedProduct" product
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_products_ready_v1 AS
SELECT *
FROM external.intake_products_v1
WHERE NULLIF(btrim(COALESCE(product_name, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_category, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_development_kind, '')), '') IS NOT NULL;

View File

@ -0,0 +1,8 @@
-- Active SKU asset-profile barcodes are externally consumed identities.
-- Empty barcodes remain allowed while data is incomplete, but non-empty active
-- barcodes must not collide.
CREATE UNIQUE INDEX IF NOT EXISTS "ProductSkuAssetProfile_barcode_active_non_empty_key"
ON "ProductSkuAssetProfile"("barcode")
WHERE "sourceStatus" = 'ACTIVE'
AND NULLIF(btrim("barcode"), '') IS NOT NULL;

View File

@ -0,0 +1,6 @@
ALTER TABLE "ProductSKU"
ADD CONSTRAINT "ProductSKU_active_skuCode_not_deleted_marker"
CHECK (
"deletedAt" IS NOT NULL
OR POSITION('__deleted__' IN "skuCode") = 0
);

View File

@ -0,0 +1,24 @@
-- ProductSkuAssetProfile.sourceStatus describes the source asset status.
-- SKU lifecycle must be tracked separately so soft-deleted formal SKUs release
-- the active barcode pool without pretending the source asset was deleted.
ALTER TABLE "ProductSkuAssetProfile"
ADD COLUMN IF NOT EXISTS "skuDeletedAt" TIMESTAMP(3);
UPDATE "ProductSkuAssetProfile" profile
SET "skuDeletedAt" = sku."deletedAt"
FROM "ProductSKU" sku
WHERE profile."skuId" = sku."id"
AND sku."deletedAt" IS NOT NULL
AND profile."skuDeletedAt" IS NULL;
DROP INDEX IF EXISTS "ProductSkuAssetProfile_barcode_active_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "ProductSkuAssetProfile_barcode_active_non_empty_key"
ON "ProductSkuAssetProfile"("barcode")
WHERE "sourceStatus" = 'ACTIVE'
AND "skuDeletedAt" IS NULL
AND NULLIF(btrim("barcode"), '') IS NOT NULL;
CREATE INDEX IF NOT EXISTS "ProductSkuAssetProfile_skuDeletedAt_idx"
ON "ProductSkuAssetProfile"("skuDeletedAt");

View File

@ -0,0 +1,696 @@
-- Core code/barcode identities must be normalized at the database layer.
-- Service code already trims and uppercases most write paths, but physical
-- guards keep direct SQL, imports, and future services from bypassing that rule.
CREATE TABLE IF NOT EXISTS "CommonComponentCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"icon" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommonComponentCategory_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "CommonComponent" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"description" TEXT,
"modelUrl" TEXT NOT NULL,
"thumbnailUrl" TEXT,
"dimensions" JSONB,
"specifications" JSONB,
"mountPoints" JSONB,
"isPreset" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommonComponent_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "CommonComponentCategory_name_key"
ON "CommonComponentCategory"("name");
CREATE INDEX IF NOT EXISTS "CommonComponentCategory_sortOrder_idx"
ON "CommonComponentCategory"("sortOrder");
CREATE INDEX IF NOT EXISTS "CommonComponentCategory_isActive_idx"
ON "CommonComponentCategory"("isActive");
CREATE UNIQUE INDEX IF NOT EXISTS "CommonComponent_code_key"
ON "CommonComponent"("code");
CREATE INDEX IF NOT EXISTS "CommonComponent_categoryId_idx"
ON "CommonComponent"("categoryId");
CREATE INDEX IF NOT EXISTS "CommonComponent_code_idx"
ON "CommonComponent"("code");
CREATE INDEX IF NOT EXISTS "CommonComponent_isPreset_idx"
ON "CommonComponent"("isPreset");
CREATE INDEX IF NOT EXISTS "CommonComponent_isActive_idx"
ON "CommonComponent"("isActive");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'CommonComponent_categoryId_fkey'
) THEN
ALTER TABLE "CommonComponent"
ADD CONSTRAINT "CommonComponent_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "CommonComponentCategory"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
DO $$
DECLARE
duplicate_group_count INTEGER;
BEGIN
WITH duplicate_groups AS (
SELECT 'AssetSeries.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "AssetSeries"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'AssetFamily.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "AssetFamily"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'DraftGroup.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "DraftGroup"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'IntakeReferenceLibraryItem.code' AS field_name, "libraryType" || ':' || upper(btrim("code")) AS normalized_value
FROM "IntakeReferenceLibraryItem"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY "libraryType", upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'SpuNumberCounter.prefix' AS field_name, upper(btrim("prefix")) AS normalized_value
FROM "SpuNumberCounter"
WHERE NULLIF(btrim("prefix"), '') IS NOT NULL
GROUP BY upper(btrim("prefix"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'PlanningTemplate.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "PlanningTemplate"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'Substrate.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "Substrate"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'WarehouseLocation.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "WarehouseLocation"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'PackagedItem.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "PackagedItem"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'Material.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "Material"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'Hardware.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "Hardware"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'CommonComponent.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "CommonComponent"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'IntakeNonIntegratedProduct.spu_brand' AS field_name, upper(btrim("spu")) || ':' || btrim("brand") AS normalized_value
FROM "IntakeNonIntegratedProduct"
WHERE NULLIF(btrim("spu"), '') IS NOT NULL
AND NULLIF(btrim("brand"), '') IS NOT NULL
GROUP BY upper(btrim("spu")), btrim("brand")
HAVING COUNT(*) > 1
UNION ALL
SELECT 'Product.modelNumber' AS field_name, upper(btrim("modelNumber")) AS normalized_value
FROM "Product"
WHERE NULLIF(btrim("modelNumber"), '') IS NOT NULL
GROUP BY upper(btrim("modelNumber"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'ProductSKU.skuCode' AS field_name, upper(btrim("skuCode")) AS normalized_value
FROM "ProductSKU"
WHERE "deletedAt" IS NULL
AND NULLIF(btrim("skuCode"), '') IS NOT NULL
GROUP BY upper(btrim("skuCode"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'AssetVariant.code' AS field_name, upper(btrim("code")) AS normalized_value
FROM "AssetVariant"
WHERE NULLIF(btrim("code"), '') IS NOT NULL
GROUP BY upper(btrim("code"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'AssetVariant.barcode' AS field_name, upper(btrim("barcode")) AS normalized_value
FROM "AssetVariant"
WHERE "deletedAt" IS NULL
AND NULLIF(btrim(COALESCE("barcode", '')), '') IS NOT NULL
GROUP BY upper(btrim("barcode"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'PreSkuBasicInfo.formalSkuCode' AS field_name, upper(btrim("formalSkuCode")) AS normalized_value
FROM "PreSkuBasicInfo"
WHERE NULLIF(btrim(COALESCE("formalSkuCode", '')), '') IS NOT NULL
GROUP BY upper(btrim("formalSkuCode"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'PreSkuBasicInfo.barcode' AS field_name, upper(btrim("barcode")) AS normalized_value
FROM "PreSkuBasicInfo"
WHERE NULLIF(btrim(COALESCE("barcode", '')), '') IS NOT NULL
GROUP BY upper(btrim("barcode"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'ProductSkuAssetProfile.barcode' AS field_name, upper(btrim("barcode")) AS normalized_value
FROM "ProductSkuAssetProfile"
WHERE "sourceStatus" = 'ACTIVE'
AND "skuDeletedAt" IS NULL
AND NULLIF(btrim(COALESCE("barcode", '')), '') IS NOT NULL
GROUP BY upper(btrim("barcode"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'IntakeNonIntegratedSkuItem.skuCode' AS field_name, upper(btrim("skuCode")) AS normalized_value
FROM "IntakeNonIntegratedSkuItem"
WHERE NULLIF(btrim("skuCode"), '') IS NOT NULL
GROUP BY upper(btrim("skuCode"))
HAVING COUNT(*) > 1
UNION ALL
SELECT 'IntakeNonIntegratedSkuItem.barcode' AS field_name, upper(btrim("barcode")) AS normalized_value
FROM "IntakeNonIntegratedSkuItem"
WHERE NULLIF(btrim("barcode"), '') IS NOT NULL
GROUP BY upper(btrim("barcode"))
HAVING COUNT(*) > 1
)
SELECT COUNT(*)::integer
INTO duplicate_group_count
FROM duplicate_groups;
IF duplicate_group_count > 0 THEN
RAISE EXCEPTION
'Cannot normalize core code/barcode guards: % duplicate normalized identity groups exist. Inspect duplicate groups with upper(btrim(field)) before applying this migration.',
duplicate_group_count;
END IF;
END $$;
UPDATE "AssetSeries"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "AssetFamily"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "DraftGroup"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "IntakeReferenceLibraryItem"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "SpuNumberCounter"
SET "prefix" = upper(btrim("prefix"))
WHERE "prefix" <> upper(btrim("prefix"));
UPDATE "PlanningTemplate"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "Substrate"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "WarehouseLocation"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "PackagedItem"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "Material"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "Hardware"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "CommonComponent"
SET "code" = upper(btrim("code"))
WHERE "code" <> upper(btrim("code"));
UPDATE "IntakeNonIntegratedProduct"
SET
"spu" = upper(btrim("spu")),
"spuBase" = upper(btrim("spuBase")),
"brand" = btrim("brand")
WHERE "spu" <> upper(btrim("spu"))
OR "spuBase" <> upper(btrim("spuBase"))
OR "brand" <> btrim("brand");
UPDATE "Product"
SET "modelNumber" = upper(btrim("modelNumber"))
WHERE "modelNumber" <> upper(btrim("modelNumber"));
UPDATE "ProductSKU"
SET "skuCode" = upper(btrim("skuCode"))
WHERE "deletedAt" IS NULL
AND "skuCode" <> upper(btrim("skuCode"));
UPDATE "AssetVariant"
SET
"code" = upper(btrim("code")),
"barcode" = CASE
WHEN NULLIF(btrim(COALESCE("barcode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("barcode"))
END
WHERE "code" <> upper(btrim("code"))
OR "barcode" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("barcode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("barcode"))
END;
UPDATE "PreSkuBasicInfo"
SET
"formalSkuCode" = CASE
WHEN NULLIF(btrim(COALESCE("formalSkuCode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("formalSkuCode"))
END,
"barcode" = CASE
WHEN NULLIF(btrim(COALESCE("barcode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("barcode"))
END
WHERE "formalSkuCode" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("formalSkuCode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("formalSkuCode"))
END
OR "barcode" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("barcode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("barcode"))
END;
UPDATE "ProductSkuAssetProfile"
SET "barcode" = CASE
WHEN NULLIF(btrim(COALESCE("barcode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("barcode"))
END
WHERE "barcode" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("barcode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("barcode"))
END;
UPDATE "IntakeNonIntegratedSkuItem"
SET
"skuCode" = CASE
WHEN NULLIF(btrim("skuCode"), '') IS NULL THEN ''
ELSE upper(btrim("skuCode"))
END,
"barcode" = CASE
WHEN NULLIF(btrim("barcode"), '') IS NULL THEN ''
ELSE upper(btrim("barcode"))
END
WHERE "skuCode" <> CASE
WHEN NULLIF(btrim("skuCode"), '') IS NULL THEN ''
ELSE upper(btrim("skuCode"))
END
OR "barcode" <> CASE
WHEN NULLIF(btrim("barcode"), '') IS NULL THEN ''
ELSE upper(btrim("barcode"))
END;
DROP INDEX IF EXISTS "AssetVariant_barcode_active_key";
CREATE UNIQUE INDEX IF NOT EXISTS "AssetVariant_barcode_active_key"
ON "AssetVariant"(upper(btrim("barcode")))
WHERE NULLIF(btrim(COALESCE("barcode", '')), '') IS NOT NULL
AND "deletedAt" IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "ProductSKU_active_skuCode_normalized_key"
ON "ProductSKU"(upper(btrim("skuCode")))
WHERE "deletedAt" IS NULL;
DROP INDEX IF EXISTS "PreSkuBasicInfo_formalSkuCode_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "PreSkuBasicInfo_formalSkuCode_non_empty_key"
ON "PreSkuBasicInfo"(upper(btrim("formalSkuCode")))
WHERE NULLIF(btrim(COALESCE("formalSkuCode", '')), '') IS NOT NULL;
DROP INDEX IF EXISTS "PreSkuBasicInfo_barcode_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "PreSkuBasicInfo_barcode_non_empty_key"
ON "PreSkuBasicInfo"(upper(btrim("barcode")))
WHERE NULLIF(btrim(COALESCE("barcode", '')), '') IS NOT NULL;
DROP INDEX IF EXISTS "ProductSkuAssetProfile_barcode_active_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "ProductSkuAssetProfile_barcode_active_non_empty_key"
ON "ProductSkuAssetProfile"(upper(btrim("barcode")))
WHERE "sourceStatus" = 'ACTIVE'
AND "skuDeletedAt" IS NULL
AND NULLIF(btrim(COALESCE("barcode", '')), '') IS NOT NULL;
DROP INDEX IF EXISTS "IntakeNonIntegratedSkuItem_skuCode_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNonIntegratedSkuItem_skuCode_non_empty_key"
ON "IntakeNonIntegratedSkuItem"(upper(btrim("skuCode")))
WHERE NULLIF(btrim("skuCode"), '') IS NOT NULL;
DROP INDEX IF EXISTS "IntakeNonIntegratedSkuItem_barcode_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNonIntegratedSkuItem_barcode_non_empty_key"
ON "IntakeNonIntegratedSkuItem"(upper(btrim("barcode")))
WHERE NULLIF(btrim("barcode"), '') IS NOT NULL;
DROP INDEX IF EXISTS "IntakeReferenceLibraryItem_libraryType_code_non_empty_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeReferenceLibraryItem_libraryType_code_non_empty_key"
ON "IntakeReferenceLibraryItem"("libraryType", upper(btrim("code")))
WHERE NULLIF(btrim("code"), '') IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "SpuNumberCounter_prefix_key"
ON "SpuNumberCounter"("prefix");
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNonIntegratedProduct_spu_brand_normalized_key"
ON "IntakeNonIntegratedProduct"(upper(btrim("spu")), btrim("brand"));
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetSeries_code_normalized'
) THEN
ALTER TABLE "AssetSeries"
ADD CONSTRAINT "AssetSeries_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetFamily_code_normalized'
) THEN
ALTER TABLE "AssetFamily"
ADD CONSTRAINT "AssetFamily_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'DraftGroup_code_normalized'
) THEN
ALTER TABLE "DraftGroup"
ADD CONSTRAINT "DraftGroup_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeReferenceLibraryItem_code_normalized'
) THEN
ALTER TABLE "IntakeReferenceLibraryItem"
ADD CONSTRAINT "IntakeReferenceLibraryItem_code_normalized"
CHECK (
"code" = ''
OR "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PlanningTemplate_code_normalized'
) THEN
ALTER TABLE "PlanningTemplate"
ADD CONSTRAINT "PlanningTemplate_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Substrate_code_normalized'
) THEN
ALTER TABLE "Substrate"
ADD CONSTRAINT "Substrate_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'WarehouseLocation_code_normalized'
) THEN
ALTER TABLE "WarehouseLocation"
ADD CONSTRAINT "WarehouseLocation_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PackagedItem_code_normalized'
) THEN
ALTER TABLE "PackagedItem"
ADD CONSTRAINT "PackagedItem_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Material_code_normalized'
) THEN
ALTER TABLE "Material"
ADD CONSTRAINT "Material_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Hardware_code_normalized'
) THEN
ALTER TABLE "Hardware"
ADD CONSTRAINT "Hardware_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'CommonComponent_code_normalized'
) THEN
ALTER TABLE "CommonComponent"
ADD CONSTRAINT "CommonComponent_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeNonIntegratedProduct_spu_brand_normalized'
) THEN
ALTER TABLE "IntakeNonIntegratedProduct"
ADD CONSTRAINT "IntakeNonIntegratedProduct_spu_brand_normalized"
CHECK (
NULLIF(btrim("spu"), '') IS NOT NULL
AND "spu" = upper(btrim("spu"))
AND NULLIF(btrim("spuBase"), '') IS NOT NULL
AND "spuBase" = upper(btrim("spuBase"))
AND NULLIF(btrim("brand"), '') IS NOT NULL
AND "brand" = btrim("brand")
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'SpuNumberCounter_prefix_normalized'
) THEN
ALTER TABLE "SpuNumberCounter"
ADD CONSTRAINT "SpuNumberCounter_prefix_normalized"
CHECK (
NULLIF(btrim("prefix"), '') IS NOT NULL
AND "prefix" = upper(btrim("prefix"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Product_modelNumber_normalized'
) THEN
ALTER TABLE "Product"
ADD CONSTRAINT "Product_modelNumber_normalized"
CHECK (
NULLIF(btrim("modelNumber"), '') IS NOT NULL
AND "modelNumber" = upper(btrim("modelNumber"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSKU_skuCode_normalized'
) THEN
ALTER TABLE "ProductSKU"
ADD CONSTRAINT "ProductSKU_skuCode_normalized"
CHECK (
"deletedAt" IS NOT NULL
OR (
NULLIF(btrim("skuCode"), '') IS NOT NULL
AND "skuCode" = upper(btrim("skuCode"))
)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSKU_active_skuCode_no_deleted_marker_ci'
) THEN
ALTER TABLE "ProductSKU"
ADD CONSTRAINT "ProductSKU_active_skuCode_no_deleted_marker_ci"
CHECK (
"deletedAt" IS NOT NULL
OR POSITION('__deleted__' IN lower("skuCode")) = 0
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetVariant_code_normalized'
) THEN
ALTER TABLE "AssetVariant"
ADD CONSTRAINT "AssetVariant_code_normalized"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetVariant_barcode_normalized'
) THEN
ALTER TABLE "AssetVariant"
ADD CONSTRAINT "AssetVariant_barcode_normalized"
CHECK (
"barcode" IS NULL
OR "barcode" = upper(btrim("barcode"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuBasicInfo_formalSkuCode_normalized'
) THEN
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_formalSkuCode_normalized"
CHECK (
"formalSkuCode" IS NULL
OR "formalSkuCode" = upper(btrim("formalSkuCode"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuBasicInfo_barcode_normalized'
) THEN
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_barcode_normalized"
CHECK (
"barcode" IS NULL
OR "barcode" = upper(btrim("barcode"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSkuAssetProfile_barcode_normalized'
) THEN
ALTER TABLE "ProductSkuAssetProfile"
ADD CONSTRAINT "ProductSkuAssetProfile_barcode_normalized"
CHECK (
"barcode" IS NULL
OR "barcode" = upper(btrim("barcode"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeNonIntegratedSkuItem_skuCode_normalized'
) THEN
ALTER TABLE "IntakeNonIntegratedSkuItem"
ADD CONSTRAINT "IntakeNonIntegratedSkuItem_skuCode_normalized"
CHECK (
"skuCode" = ''
OR "skuCode" = upper(btrim("skuCode"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeNonIntegratedSkuItem_barcode_normalized'
) THEN
ALTER TABLE "IntakeNonIntegratedSkuItem"
ADD CONSTRAINT "IntakeNonIntegratedSkuItem_barcode_normalized"
CHECK (
"barcode" = ''
OR "barcode" = upper(btrim("barcode"))
);
END IF;
END $$;

View File

@ -0,0 +1,47 @@
-- Product SKU asset-profile model/spec codes are externally consumed identity fields.
-- Keep nullable draft/history rows allowed, but normalize any non-empty value and
-- add database guards so direct SQL or future services cannot bypass the rule.
UPDATE "ProductSkuAssetProfile"
SET
"model" = CASE
WHEN NULLIF(btrim(COALESCE("model", '')), '') IS NULL THEN NULL
ELSE upper(btrim("model"))
END,
"specCode" = CASE
WHEN NULLIF(btrim(COALESCE("specCode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("specCode"))
END
WHERE "model" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("model", '')), '') IS NULL THEN NULL
ELSE upper(btrim("model"))
END
OR "specCode" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("specCode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("specCode"))
END;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSkuAssetProfile_model_normalized'
) THEN
ALTER TABLE "ProductSkuAssetProfile"
ADD CONSTRAINT "ProductSkuAssetProfile_model_normalized"
CHECK (
"model" IS NULL
OR "model" = upper(btrim("model"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSkuAssetProfile_specCode_normalized'
) THEN
ALTER TABLE "ProductSkuAssetProfile"
ADD CONSTRAINT "ProductSkuAssetProfile_specCode_normalized"
CHECK (
"specCode" IS NULL
OR "specCode" = upper(btrim("specCode"))
);
END IF;
END $$;

View File

@ -0,0 +1,179 @@
-- Product patent ledger records are now structured master data, not browser cache.
-- Guard the stable group/product/patent identity at the database layer so direct
-- SQL, imports, and future services cannot silently create orphan ledger rows.
DO $$
DECLARE
duplicate_group_count INTEGER;
invalid_identity_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO duplicate_group_count
FROM (
SELECT upper(btrim("patentGroupKey")) AS normalized_group_key
FROM "ProductPatentRecord"
WHERE NULLIF(btrim("patentGroupKey"), '') IS NOT NULL
GROUP BY upper(btrim("patentGroupKey"))
HAVING COUNT(*) > 1
) duplicate_groups;
IF duplicate_group_count > 0 THEN
RAISE EXCEPTION
'Cannot add ProductPatentRecord identity guards: % duplicate normalized patentGroupKey groups exist.',
duplicate_group_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_identity_count
FROM "ProductPatentRecord"
WHERE NULLIF(btrim("productId"), '') IS NULL
OR NULLIF(btrim("patentGroupKey"), '') IS NULL
OR (
NULLIF(btrim("patentName"), '') IS NULL
AND NULLIF(btrim("patentNo"), '') IS NULL
AND NULLIF(btrim("applicationNo"), '') IS NULL
);
IF invalid_identity_count > 0 THEN
RAISE EXCEPTION
'Cannot add ProductPatentRecord identity guards: % rows are missing productId, patentGroupKey, or patent identity.',
invalid_identity_count;
END IF;
END $$;
UPDATE "ProductPatentRecord"
SET
"patentGroupKey" = upper(btrim("patentGroupKey")),
"productId" = btrim("productId"),
"linkedProductIds" = COALESCE((
SELECT array_agg(item ORDER BY item)
FROM (
SELECT DISTINCT btrim(value) AS item
FROM unnest("linkedProductIds") AS item_source(value)
WHERE NULLIF(btrim(value), '') IS NOT NULL
) normalized_items
), ARRAY[]::TEXT[]),
"linkedSpus" = COALESCE((
SELECT array_agg(item ORDER BY item)
FROM (
SELECT DISTINCT upper(btrim(value)) AS item
FROM unnest("linkedSpus") AS item_source(value)
WHERE NULLIF(btrim(value), '') IS NOT NULL
) normalized_items
), ARRAY[]::TEXT[]),
"applicationNo" = CASE
WHEN NULLIF(btrim("applicationNo"), '') IS NULL THEN ''
ELSE upper(btrim("applicationNo"))
END,
"patentNo" = CASE
WHEN NULLIF(btrim("patentNo"), '') IS NULL THEN ''
ELSE upper(btrim("patentNo"))
END,
"spu" = CASE
WHEN NULLIF(btrim("spu"), '') IS NULL THEN ''
ELSE upper(btrim("spu"))
END
WHERE "patentGroupKey" <> upper(btrim("patentGroupKey"))
OR "productId" <> btrim("productId")
OR "linkedProductIds" IS DISTINCT FROM COALESCE((
SELECT array_agg(item ORDER BY item)
FROM (
SELECT DISTINCT btrim(value) AS item
FROM unnest("linkedProductIds") AS item_source(value)
WHERE NULLIF(btrim(value), '') IS NOT NULL
) normalized_items
), ARRAY[]::TEXT[])
OR "linkedSpus" IS DISTINCT FROM COALESCE((
SELECT array_agg(item ORDER BY item)
FROM (
SELECT DISTINCT upper(btrim(value)) AS item
FROM unnest("linkedSpus") AS item_source(value)
WHERE NULLIF(btrim(value), '') IS NOT NULL
) normalized_items
), ARRAY[]::TEXT[])
OR "applicationNo" <> CASE
WHEN NULLIF(btrim("applicationNo"), '') IS NULL THEN ''
ELSE upper(btrim("applicationNo"))
END
OR "patentNo" <> CASE
WHEN NULLIF(btrim("patentNo"), '') IS NULL THEN ''
ELSE upper(btrim("patentNo"))
END
OR "spu" <> CASE
WHEN NULLIF(btrim("spu"), '') IS NULL THEN ''
ELSE upper(btrim("spu"))
END;
DROP INDEX IF EXISTS "ProductPatentRecord_patentGroupKey_key";
CREATE UNIQUE INDEX IF NOT EXISTS "ProductPatentRecord_patentGroupKey_key"
ON "ProductPatentRecord"("patentGroupKey");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductPatentRecord_patentGroupKey_normalized'
) THEN
ALTER TABLE "ProductPatentRecord"
ADD CONSTRAINT "ProductPatentRecord_patentGroupKey_normalized"
CHECK (
NULLIF(btrim("patentGroupKey"), '') IS NOT NULL
AND "patentGroupKey" = upper(btrim("patentGroupKey"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductPatentRecord_productId_required'
) THEN
ALTER TABLE "ProductPatentRecord"
ADD CONSTRAINT "ProductPatentRecord_productId_required"
CHECK (
NULLIF(btrim("productId"), '') IS NOT NULL
AND "productId" = btrim("productId")
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductPatentRecord_applicationNo_normalized'
) THEN
ALTER TABLE "ProductPatentRecord"
ADD CONSTRAINT "ProductPatentRecord_applicationNo_normalized"
CHECK (
"applicationNo" = ''
OR "applicationNo" = upper(btrim("applicationNo"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductPatentRecord_patentNo_normalized'
) THEN
ALTER TABLE "ProductPatentRecord"
ADD CONSTRAINT "ProductPatentRecord_patentNo_normalized"
CHECK (
"patentNo" = ''
OR "patentNo" = upper(btrim("patentNo"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductPatentRecord_spu_normalized'
) THEN
ALTER TABLE "ProductPatentRecord"
ADD CONSTRAINT "ProductPatentRecord_spu_normalized"
CHECK (
"spu" = ''
OR "spu" = upper(btrim("spu"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductPatentRecord_patent_identity_required'
) THEN
ALTER TABLE "ProductPatentRecord"
ADD CONSTRAINT "ProductPatentRecord_patent_identity_required"
CHECK (
NULLIF(btrim("patentName"), '') IS NOT NULL
OR NULLIF(btrim("patentNo"), '') IS NOT NULL
OR NULLIF(btrim("applicationNo"), '') IS NOT NULL
);
END IF;
END $$;

View File

@ -0,0 +1,105 @@
-- Reference-library rows replace several historical localStorage libraries.
-- Lock the library type and semantic item identity physically so direct SQL or
-- future import paths cannot create a second truth by changing case or spaces.
DO $$
DECLARE
duplicate_item_key_count INTEGER;
invalid_identity_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO duplicate_item_key_count
FROM (
SELECT "libraryType", lower(btrim("itemKey")) AS normalized_item_key
FROM "IntakeReferenceLibraryItem"
WHERE NULLIF(btrim("itemKey"), '') IS NOT NULL
GROUP BY "libraryType", lower(btrim("itemKey"))
HAVING COUNT(*) > 1
) duplicate_groups;
IF duplicate_item_key_count > 0 THEN
RAISE EXCEPTION
'Cannot add IntakeReferenceLibraryItem identity guards: % duplicate normalized itemKey groups exist.',
duplicate_item_key_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_identity_count
FROM "IntakeReferenceLibraryItem"
WHERE "libraryType" NOT IN (
'PACKAGING_MATERIAL',
'ACCESSORY',
'EDGE_BANDING',
'STEEL_FRAME',
'SPU_RULE',
'ALPHABET_LIBRARY'
)
OR NULLIF(btrim("itemKey"), '') IS NULL;
IF invalid_identity_count > 0 THEN
RAISE EXCEPTION
'Cannot add IntakeReferenceLibraryItem identity guards: % rows have invalid libraryType or blank itemKey.',
invalid_identity_count;
END IF;
END $$;
UPDATE "IntakeReferenceLibraryItem"
SET
"libraryType" = upper(btrim("libraryType")),
"itemKey" = btrim("itemKey"),
"letter" = CASE
WHEN NULLIF(btrim("letter"), '') IS NULL THEN ''
ELSE upper(btrim("letter"))
END
WHERE "libraryType" <> upper(btrim("libraryType"))
OR "itemKey" <> btrim("itemKey")
OR "letter" <> CASE
WHEN NULLIF(btrim("letter"), '') IS NULL THEN ''
ELSE upper(btrim("letter"))
END;
DROP INDEX IF EXISTS "IntakeReferenceLibraryItem_libraryType_itemKey_normalized_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeReferenceLibraryItem_libraryType_itemKey_normalized_key"
ON "IntakeReferenceLibraryItem"("libraryType", lower(btrim("itemKey")));
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeReferenceLibraryItem_libraryType_allowed'
) THEN
ALTER TABLE "IntakeReferenceLibraryItem"
ADD CONSTRAINT "IntakeReferenceLibraryItem_libraryType_allowed"
CHECK (
"libraryType" IN (
'PACKAGING_MATERIAL',
'ACCESSORY',
'EDGE_BANDING',
'STEEL_FRAME',
'SPU_RULE',
'ALPHABET_LIBRARY'
)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeReferenceLibraryItem_itemKey_required'
) THEN
ALTER TABLE "IntakeReferenceLibraryItem"
ADD CONSTRAINT "IntakeReferenceLibraryItem_itemKey_required"
CHECK (
NULLIF(btrim("itemKey"), '') IS NOT NULL
AND "itemKey" = btrim("itemKey")
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeReferenceLibraryItem_letter_normalized'
) THEN
ALTER TABLE "IntakeReferenceLibraryItem"
ADD CONSTRAINT "IntakeReferenceLibraryItem_letter_normalized"
CHECK (
"letter" = ''
OR "letter" = upper(btrim("letter"))
);
END IF;
END $$;

View File

@ -0,0 +1,69 @@
-- The SPU number counter is the transactional source for formal SPU numbers.
-- Product categories must be semantically unique after trimming/case folding, or
-- one business category can accidentally get two independent number pools.
DO $$
DECLARE
duplicate_category_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO duplicate_category_count
FROM (
SELECT lower(btrim("productCategory")) AS normalized_product_category
FROM "SpuNumberCounter"
WHERE NULLIF(btrim("productCategory"), '') IS NOT NULL
GROUP BY lower(btrim("productCategory"))
HAVING COUNT(*) > 1
) duplicate_groups;
IF duplicate_category_count > 0 THEN
RAISE EXCEPTION
'Cannot add SpuNumberCounter identity guards: % duplicate normalized productCategory groups exist.',
duplicate_category_count;
END IF;
END $$;
UPDATE "SpuNumberCounter"
SET
"productCategory" = btrim("productCategory"),
"sourceLibraryItemId" = CASE
WHEN NULLIF(btrim(COALESCE("sourceLibraryItemId", '')), '') IS NULL THEN NULL
ELSE btrim("sourceLibraryItemId")
END
WHERE "productCategory" <> btrim("productCategory")
OR "sourceLibraryItemId" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("sourceLibraryItemId", '')), '') IS NULL THEN NULL
ELSE btrim("sourceLibraryItemId")
END;
DROP INDEX IF EXISTS "SpuNumberCounter_productCategory_normalized_key";
CREATE UNIQUE INDEX IF NOT EXISTS "SpuNumberCounter_productCategory_normalized_key"
ON "SpuNumberCounter"(lower(btrim("productCategory")));
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'SpuNumberCounter_productCategory_normalized'
) THEN
ALTER TABLE "SpuNumberCounter"
ADD CONSTRAINT "SpuNumberCounter_productCategory_normalized"
CHECK (
NULLIF(btrim("productCategory"), '') IS NOT NULL
AND "productCategory" = btrim("productCategory")
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'SpuNumberCounter_sourceLibraryItemId_normalized'
) THEN
ALTER TABLE "SpuNumberCounter"
ADD CONSTRAINT "SpuNumberCounter_sourceLibraryItemId_normalized"
CHECK (
"sourceLibraryItemId" IS NULL
OR (
NULLIF(btrim("sourceLibraryItemId"), '') IS NOT NULL
AND "sourceLibraryItemId" = btrim("sourceLibraryItemId")
)
);
END IF;
END $$;

View File

@ -0,0 +1,166 @@
-- Strengthen PreSkuBasicInfo as the structured truth source for draft SKU
-- identity fields. JSON snapshots may still exist for display compatibility,
-- but structured values must not keep blank strings or unnormalized codes.
UPDATE "PreSkuBasicInfo"
SET
"model" = CASE
WHEN NULLIF(btrim(COALESCE("model", '')), '') IS NULL THEN NULL
ELSE upper(btrim("model"))
END,
"formalSkuCode" = CASE
WHEN NULLIF(btrim(COALESCE("formalSkuCode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("formalSkuCode"))
END,
"barcode" = CASE
WHEN NULLIF(btrim(COALESCE("barcode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("barcode"))
END,
"versionNo" = CASE
WHEN NULLIF(btrim(COALESCE("versionNo", '')), '') IS NULL THEN NULL
ELSE upper(btrim("versionNo"))
END,
"attributeCode" = CASE
WHEN NULLIF(btrim(COALESCE("attributeCode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("attributeCode"))
END,
"drawingId" = CASE
WHEN NULLIF(btrim(COALESCE("drawingId", '')), '') IS NULL THEN NULL
ELSE upper(btrim("drawingId"))
END,
"size" = NULLIF(btrim(COALESCE("size", '')), ''),
"color" = NULLIF(btrim(COALESCE("color", '')), ''),
"material" = NULLIF(btrim(COALESCE("material", '')), ''),
"specName" = NULLIF(btrim(COALESCE("specName", '')), ''),
"setCount" = NULLIF(btrim(COALESCE("setCount", '')), ''),
"installMethod" = NULLIF(btrim(COALESCE("installMethod", '')), ''),
"manualUpdatedBy" = NULLIF(btrim(COALESCE("manualUpdatedBy", '')), ''),
"manualUpdatedByName" = NULLIF(btrim(COALESCE("manualUpdatedByName", '')), ''),
"source" = CASE
WHEN "source" = 'MANUAL' THEN 'MANUAL'
ELSE 'DRAFT_GROUP'
END
WHERE
"model" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("model", '')), '') IS NULL THEN NULL
ELSE upper(btrim("model"))
END
OR "formalSkuCode" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("formalSkuCode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("formalSkuCode"))
END
OR "barcode" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("barcode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("barcode"))
END
OR "versionNo" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("versionNo", '')), '') IS NULL THEN NULL
ELSE upper(btrim("versionNo"))
END
OR "attributeCode" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("attributeCode", '')), '') IS NULL THEN NULL
ELSE upper(btrim("attributeCode"))
END
OR "drawingId" IS DISTINCT FROM CASE
WHEN NULLIF(btrim(COALESCE("drawingId", '')), '') IS NULL THEN NULL
ELSE upper(btrim("drawingId"))
END
OR "size" IS DISTINCT FROM NULLIF(btrim(COALESCE("size", '')), '')
OR "color" IS DISTINCT FROM NULLIF(btrim(COALESCE("color", '')), '')
OR "material" IS DISTINCT FROM NULLIF(btrim(COALESCE("material", '')), '')
OR "specName" IS DISTINCT FROM NULLIF(btrim(COALESCE("specName", '')), '')
OR "setCount" IS DISTINCT FROM NULLIF(btrim(COALESCE("setCount", '')), '')
OR "installMethod" IS DISTINCT FROM NULLIF(btrim(COALESCE("installMethod", '')), '')
OR "manualUpdatedBy" IS DISTINCT FROM NULLIF(btrim(COALESCE("manualUpdatedBy", '')), '')
OR "manualUpdatedByName" IS DISTINCT FROM NULLIF(btrim(COALESCE("manualUpdatedByName", '')), '')
OR "source" NOT IN ('DRAFT_GROUP', 'MANUAL');
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuBasicInfo_model_normalized'
) THEN
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_model_normalized"
CHECK (
"model" IS NULL
OR (
NULLIF(btrim("model"), '') IS NOT NULL
AND "model" = upper(btrim("model"))
)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuBasicInfo_versionNo_normalized'
) THEN
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_versionNo_normalized"
CHECK (
"versionNo" IS NULL
OR (
NULLIF(btrim("versionNo"), '') IS NOT NULL
AND "versionNo" = upper(btrim("versionNo"))
)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuBasicInfo_attributeCode_normalized'
) THEN
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_attributeCode_normalized"
CHECK (
"attributeCode" IS NULL
OR (
NULLIF(btrim("attributeCode"), '') IS NOT NULL
AND "attributeCode" = upper(btrim("attributeCode"))
)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuBasicInfo_drawingId_normalized'
) THEN
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_drawingId_normalized"
CHECK (
"drawingId" IS NULL
OR (
NULLIF(btrim("drawingId"), '') IS NOT NULL
AND "drawingId" = upper(btrim("drawingId"))
)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuBasicInfo_source_allowed'
) THEN
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_source_allowed"
CHECK ("source" IN ('DRAFT_GROUP', 'MANUAL'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuBasicInfo_optional_text_not_blank'
) THEN
ALTER TABLE "PreSkuBasicInfo"
ADD CONSTRAINT "PreSkuBasicInfo_optional_text_not_blank"
CHECK (
("model" IS NULL OR NULLIF(btrim("model"), '') IS NOT NULL)
AND ("formalSkuCode" IS NULL OR NULLIF(btrim("formalSkuCode"), '') IS NOT NULL)
AND ("barcode" IS NULL OR NULLIF(btrim("barcode"), '') IS NOT NULL)
AND ("size" IS NULL OR NULLIF(btrim("size"), '') IS NOT NULL)
AND ("color" IS NULL OR NULLIF(btrim("color"), '') IS NOT NULL)
AND ("material" IS NULL OR NULLIF(btrim("material"), '') IS NOT NULL)
AND ("specName" IS NULL OR NULLIF(btrim("specName"), '') IS NOT NULL)
AND ("versionNo" IS NULL OR NULLIF(btrim("versionNo"), '') IS NOT NULL)
AND ("setCount" IS NULL OR NULLIF(btrim("setCount"), '') IS NOT NULL)
AND ("attributeCode" IS NULL OR NULLIF(btrim("attributeCode"), '') IS NOT NULL)
AND ("drawingId" IS NULL OR NULLIF(btrim("drawingId"), '') IS NOT NULL)
AND ("installMethod" IS NULL OR NULLIF(btrim("installMethod"), '') IS NOT NULL)
AND ("manualUpdatedBy" IS NULL OR NULLIF(btrim("manualUpdatedBy"), '') IS NOT NULL)
AND ("manualUpdatedByName" IS NULL OR NULLIF(btrim("manualUpdatedByName"), '') IS NOT NULL)
);
END IF;
END $$;

View File

@ -0,0 +1,69 @@
-- AssetSkuSyncEvent is the durable source for SKU asset-profile refresh work.
-- Redis only accelerates queue/progress delivery, so the database event rows
-- must reject invalid reasons and impossible progress counters.
UPDATE "AssetSkuSyncEvent"
SET
"reason" = CASE
WHEN "reason" IN ('VARIANT_UPDATED', 'THUMBNAIL_REGENERATED', 'VARIANT_DELETED', 'MANUAL_RETRY')
THEN "reason"
ELSE 'MANUAL_RETRY'
END,
"affectedCount" = GREATEST("affectedCount", 0),
"syncedCount" = GREATEST("syncedCount", 0),
"failedCount" = GREATEST("failedCount", 0),
"retryCount" = GREATEST("retryCount", 0),
"processedAt" = CASE
WHEN "status" = 'COMPLETED' AND "processedAt" IS NULL THEN COALESCE("updatedAt", CURRENT_TIMESTAMP)
ELSE "processedAt"
END
WHERE "reason" NOT IN ('VARIANT_UPDATED', 'THUMBNAIL_REGENERATED', 'VARIANT_DELETED', 'MANUAL_RETRY')
OR "affectedCount" < 0
OR "syncedCount" < 0
OR "failedCount" < 0
OR "retryCount" < 0
OR ("status" = 'COMPLETED' AND "processedAt" IS NULL);
UPDATE "AssetSkuSyncEvent"
SET "syncedCount" = "affectedCount"
WHERE "syncedCount" > "affectedCount";
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetSkuSyncEvent_reason_allowed'
) THEN
ALTER TABLE "AssetSkuSyncEvent"
ADD CONSTRAINT "AssetSkuSyncEvent_reason_allowed"
CHECK ("reason" IN ('VARIANT_UPDATED', 'THUMBNAIL_REGENERATED', 'VARIANT_DELETED', 'MANUAL_RETRY'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetSkuSyncEvent_counts_non_negative'
) THEN
ALTER TABLE "AssetSkuSyncEvent"
ADD CONSTRAINT "AssetSkuSyncEvent_counts_non_negative"
CHECK (
"affectedCount" >= 0
AND "syncedCount" >= 0
AND "failedCount" >= 0
AND "retryCount" >= 0
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetSkuSyncEvent_synced_not_over_affected'
) THEN
ALTER TABLE "AssetSkuSyncEvent"
ADD CONSTRAINT "AssetSkuSyncEvent_synced_not_over_affected"
CHECK ("syncedCount" <= "affectedCount");
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'AssetSkuSyncEvent_completed_has_processed_at'
) THEN
ALTER TABLE "AssetSkuSyncEvent"
ADD CONSTRAINT "AssetSkuSyncEvent_completed_has_processed_at"
CHECK ("status" <> 'COMPLETED' OR "processedAt" IS NOT NULL);
END IF;
END $$;

View File

@ -0,0 +1,122 @@
-- ProductSkuAssetProfile is the structured asset-data copy for a formal SKU.
-- A profile row without a stable source variant cannot be trusted by external
-- read-only consumers, so source identity is normalized and guarded here.
WITH normalized_profile AS (
SELECT
profile."id",
COALESCE(
NULLIF(btrim(COALESCE(profile."primarySourceAssetVariantId", '')), ''),
NULLIF(btrim(COALESCE(profile."sourceAssetVariantIds"[1], '')), ''),
NULLIF(btrim(COALESCE(profile."sourceVariants" -> 0 ->> 'sourceAssetVariantId', '')), '')
) AS primary_source_asset_variant_id,
ARRAY(
SELECT DISTINCT source_id
FROM (
SELECT NULLIF(btrim(COALESCE(profile."primarySourceAssetVariantId", '')), '') AS source_id
UNION ALL
SELECT NULLIF(btrim(COALESCE(array_item, '')), '') AS source_id
FROM unnest(profile."sourceAssetVariantIds") AS array_item
UNION ALL
SELECT NULLIF(btrim(COALESCE(source_variant ->> 'sourceAssetVariantId', '')), '') AS source_id
FROM jsonb_array_elements(
CASE
WHEN jsonb_typeof(profile."sourceVariants") = 'array' THEN profile."sourceVariants"
ELSE '[]'::jsonb
END
) AS source_variant
) source_values
WHERE source_id IS NOT NULL
ORDER BY source_id
) AS source_asset_variant_ids
FROM "ProductSkuAssetProfile" profile
)
UPDATE "ProductSkuAssetProfile" profile
SET
"primarySourceAssetVariantId" = normalized.primary_source_asset_variant_id,
"sourceAssetVariantIds" = normalized.source_asset_variant_ids,
"sourceAssetFamilyId" = NULLIF(btrim(COALESCE(profile."sourceAssetFamilyId", '')), ''),
"sourceAssetSeriesId" = NULLIF(btrim(COALESCE(profile."sourceAssetSeriesId", '')), ''),
"modelName" = NULLIF(btrim(COALESCE(profile."modelName", '')), ''),
"specName" = NULLIF(btrim(COALESCE(profile."specName", '')), ''),
"color" = NULLIF(btrim(COALESCE(profile."color", '')), ''),
"material" = NULLIF(btrim(COALESCE(profile."material", '')), ''),
"imageUrl" = NULLIF(btrim(COALESCE(profile."imageUrl", '')), '')
FROM normalized_profile normalized
WHERE profile."id" = normalized."id"
AND (
profile."primarySourceAssetVariantId" IS DISTINCT FROM normalized.primary_source_asset_variant_id
OR profile."sourceAssetVariantIds" IS DISTINCT FROM normalized.source_asset_variant_ids
OR profile."sourceAssetFamilyId" IS DISTINCT FROM NULLIF(btrim(COALESCE(profile."sourceAssetFamilyId", '')), '')
OR profile."sourceAssetSeriesId" IS DISTINCT FROM NULLIF(btrim(COALESCE(profile."sourceAssetSeriesId", '')), '')
OR profile."modelName" IS DISTINCT FROM NULLIF(btrim(COALESCE(profile."modelName", '')), '')
OR profile."specName" IS DISTINCT FROM NULLIF(btrim(COALESCE(profile."specName", '')), '')
OR profile."color" IS DISTINCT FROM NULLIF(btrim(COALESCE(profile."color", '')), '')
OR profile."material" IS DISTINCT FROM NULLIF(btrim(COALESCE(profile."material", '')), '')
OR profile."imageUrl" IS DISTINCT FROM NULLIF(btrim(COALESCE(profile."imageUrl", '')), '')
);
DO $$
DECLARE
invalid_profile_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_profile_count
FROM "ProductSkuAssetProfile"
WHERE NULLIF(btrim(COALESCE("primarySourceAssetVariantId", '')), '') IS NULL
OR cardinality("sourceAssetVariantIds") = 0
OR array_position("sourceAssetVariantIds", "primarySourceAssetVariantId") IS NULL
OR array_position("sourceAssetVariantIds", '') IS NOT NULL;
IF invalid_profile_count > 0 THEN
RAISE EXCEPTION
'Cannot add ProductSkuAssetProfile source guards: % profile rows have missing or inconsistent source variant identities.',
invalid_profile_count;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSkuAssetProfile_primary_source_required'
) THEN
ALTER TABLE "ProductSkuAssetProfile"
ADD CONSTRAINT "ProductSkuAssetProfile_primary_source_required"
CHECK (NULLIF(btrim(COALESCE("primarySourceAssetVariantId", '')), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSkuAssetProfile_source_variant_ids_required'
) THEN
ALTER TABLE "ProductSkuAssetProfile"
ADD CONSTRAINT "ProductSkuAssetProfile_source_variant_ids_required"
CHECK (
cardinality("sourceAssetVariantIds") > 0
AND array_position("sourceAssetVariantIds", '') IS NULL
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSkuAssetProfile_primary_source_in_source_ids'
) THEN
ALTER TABLE "ProductSkuAssetProfile"
ADD CONSTRAINT "ProductSkuAssetProfile_primary_source_in_source_ids"
CHECK (array_position("sourceAssetVariantIds", "primarySourceAssetVariantId") IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ProductSkuAssetProfile_optional_text_not_blank'
) THEN
ALTER TABLE "ProductSkuAssetProfile"
ADD CONSTRAINT "ProductSkuAssetProfile_optional_text_not_blank"
CHECK (
("sourceAssetFamilyId" IS NULL OR NULLIF(btrim("sourceAssetFamilyId"), '') IS NOT NULL)
AND ("sourceAssetSeriesId" IS NULL OR NULLIF(btrim("sourceAssetSeriesId"), '') IS NOT NULL)
AND ("model" IS NULL OR NULLIF(btrim("model"), '') IS NOT NULL)
AND ("modelName" IS NULL OR NULLIF(btrim("modelName"), '') IS NOT NULL)
AND ("specCode" IS NULL OR NULLIF(btrim("specCode"), '') IS NOT NULL)
AND ("specName" IS NULL OR NULLIF(btrim("specName"), '') IS NOT NULL)
AND ("barcode" IS NULL OR NULLIF(btrim("barcode"), '') IS NOT NULL)
AND ("color" IS NULL OR NULLIF(btrim("color"), '') IS NOT NULL)
AND ("material" IS NULL OR NULLIF(btrim("material"), '') IS NOT NULL)
AND ("imageUrl" IS NULL OR NULLIF(btrim("imageUrl"), '') IS NOT NULL)
);
END IF;
END $$;

View File

@ -0,0 +1,324 @@
-- Strengthen packaging and lightweight-warehouse承接 tables.
-- JSON columns remain only where they represent bounded structured payloads or
-- process snapshots; identity, status, quantity, and location invariants are
-- guarded by the database so direct imports cannot bypass service validation.
UPDATE "PreSkuPackagingPlan"
SET
"sourceRefId" = NULLIF(btrim(COALESCE("sourceRefId", '')), ''),
"status" = upper(btrim("status")),
"notes" = NULLIF(btrim(COALESCE("notes", '')), '')
WHERE
"sourceRefId" IS DISTINCT FROM NULLIF(btrim(COALESCE("sourceRefId", '')), '')
OR "status" IS DISTINCT FROM upper(btrim("status"))
OR "notes" IS DISTINCT FROM NULLIF(btrim(COALESCE("notes", '')), '');
UPDATE "WarehouseLocation"
SET
"code" = upper(btrim("code")),
"name" = btrim("name"),
"zone" = NULLIF(btrim(COALESCE("zone", '')), ''),
"aisle" = NULLIF(btrim(COALESCE("aisle", '')), ''),
"shelf" = NULLIF(btrim(COALESCE("shelf", '')), ''),
"bin" = NULLIF(btrim(COALESCE("bin", '')), ''),
"notes" = NULLIF(btrim(COALESCE("notes", '')), '')
WHERE
"code" IS DISTINCT FROM upper(btrim("code"))
OR "name" IS DISTINCT FROM btrim("name")
OR "zone" IS DISTINCT FROM NULLIF(btrim(COALESCE("zone", '')), '')
OR "aisle" IS DISTINCT FROM NULLIF(btrim(COALESCE("aisle", '')), '')
OR "shelf" IS DISTINCT FROM NULLIF(btrim(COALESCE("shelf", '')), '')
OR "bin" IS DISTINCT FROM NULLIF(btrim(COALESCE("bin", '')), '')
OR "notes" IS DISTINCT FROM NULLIF(btrim(COALESCE("notes", '')), '');
UPDATE "BrandMaterialBinding"
SET
"brand" = btrim("brand"),
"materialType" = upper(btrim("materialType")),
"fileUrl" = NULLIF(btrim(COALESCE("fileUrl", '')), ''),
"fileName" = NULLIF(btrim(COALESCE("fileName", '')), '')
WHERE
"brand" IS DISTINCT FROM btrim("brand")
OR "materialType" IS DISTINCT FROM upper(btrim("materialType"))
OR "fileUrl" IS DISTINCT FROM NULLIF(btrim(COALESCE("fileUrl", '')), '')
OR "fileName" IS DISTINCT FROM NULLIF(btrim(COALESCE("fileName", '')), '');
UPDATE "PackagedItem"
SET
"code" = upper(btrim("code")),
"name" = btrim("name"),
"status" = upper(btrim("status"))
WHERE
"code" IS DISTINCT FROM upper(btrim("code"))
OR "name" IS DISTINCT FROM btrim("name")
OR "status" IS DISTINCT FROM upper(btrim("status"));
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_count
FROM "PreSkuPackagingPlan"
WHERE "status" NOT IN ('DRAFT', 'CONFIRMED', 'ARCHIVED')
OR jsonb_typeof("boxes") <> 'array'
OR ("costSummary" IS NOT NULL AND jsonb_typeof("costSummary") NOT IN ('object', 'null'))
OR ("processDraft" IS NOT NULL AND jsonb_typeof("processDraft") NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add PreSkuPackagingPlan guards: % rows have invalid status or JSON shape.',
invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "WarehouseLocation"
WHERE NULLIF(btrim("name"), '') IS NULL;
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add WarehouseLocation guards: % rows have blank names.',
invalid_count;
END IF;
WITH duplicate_positions AS (
SELECT
lower(btrim(COALESCE("zone", ''))) AS zone_key,
lower(btrim(COALESCE("aisle", ''))) AS aisle_key,
lower(btrim(COALESCE("shelf", ''))) AS shelf_key,
lower(btrim(COALESCE("bin", ''))) AS bin_key
FROM "WarehouseLocation"
WHERE "isActive" = true
GROUP BY
lower(btrim(COALESCE("zone", ''))),
lower(btrim(COALESCE("aisle", ''))),
lower(btrim(COALESCE("shelf", ''))),
lower(btrim(COALESCE("bin", '')))
HAVING COUNT(*) > 1
)
SELECT COUNT(*)::integer
INTO invalid_count
FROM duplicate_positions;
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add WarehouseLocation active position guard: % duplicate active positions exist.',
invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "BrandMaterialBinding"
WHERE NULLIF(btrim("brand"), '') IS NULL
OR "materialType" NOT IN ('MANUAL', 'NAMEPLATE', 'CERTIFICATE', 'CARTON_PRINT')
OR "fileSize" < 0
OR (
("fileUrl" IS NULL OR "fileName" IS NULL OR "fileSize" IS NULL)
AND NOT ("fileUrl" IS NULL AND "fileName" IS NULL AND "fileSize" IS NULL)
);
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add BrandMaterialBinding guards: % rows have invalid identity or file metadata.',
invalid_count;
END IF;
WITH duplicate_brand_materials AS (
SELECT lower(btrim("brand")) AS brand_key, upper(btrim("materialType")) AS material_type_key
FROM "BrandMaterialBinding"
GROUP BY lower(btrim("brand")), upper(btrim("materialType"))
HAVING COUNT(*) > 1
)
SELECT COUNT(*)::integer
INTO invalid_count
FROM duplicate_brand_materials;
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add BrandMaterialBinding normalized identity guard: % duplicate normalized brand/material pairs exist.',
invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "PackagedItem"
WHERE NULLIF(btrim("name"), '') IS NULL
OR "quantityPerPack" <= 0
OR "stockQuantity" < 0
OR ("length" IS NOT NULL AND "length" <= 0)
OR ("width" IS NOT NULL AND "width" <= 0)
OR ("height" IS NOT NULL AND "height" <= 0)
OR ("weight" IS NOT NULL AND "weight" < 0)
OR "status" NOT IN ('DRAFT', 'ACTIVE', 'ARCHIVED')
OR ("payload" IS NOT NULL AND jsonb_typeof("payload") NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add PackagedItem guards: % rows have invalid package item shape.',
invalid_count;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuPackagingPlan_status_allowed'
) THEN
ALTER TABLE "PreSkuPackagingPlan"
ADD CONSTRAINT "PreSkuPackagingPlan_status_allowed"
CHECK ("status" IN ('DRAFT', 'CONFIRMED', 'ARCHIVED'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuPackagingPlan_boxes_array'
) THEN
ALTER TABLE "PreSkuPackagingPlan"
ADD CONSTRAINT "PreSkuPackagingPlan_boxes_array"
CHECK (jsonb_typeof("boxes") = 'array');
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuPackagingPlan_costSummary_object'
) THEN
ALTER TABLE "PreSkuPackagingPlan"
ADD CONSTRAINT "PreSkuPackagingPlan_costSummary_object"
CHECK ("costSummary" IS NULL OR jsonb_typeof("costSummary") IN ('object', 'null'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuPackagingPlan_processDraft_object'
) THEN
ALTER TABLE "PreSkuPackagingPlan"
ADD CONSTRAINT "PreSkuPackagingPlan_processDraft_object"
CHECK ("processDraft" IS NULL OR jsonb_typeof("processDraft") IN ('object', 'null'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuPackagingPlan_optional_text_not_blank'
) THEN
ALTER TABLE "PreSkuPackagingPlan"
ADD CONSTRAINT "PreSkuPackagingPlan_optional_text_not_blank"
CHECK (
("sourceRefId" IS NULL OR NULLIF(btrim("sourceRefId"), '') IS NOT NULL)
AND ("notes" IS NULL OR NULLIF(btrim("notes"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'WarehouseLocation_name_not_blank'
) THEN
ALTER TABLE "WarehouseLocation"
ADD CONSTRAINT "WarehouseLocation_name_not_blank"
CHECK (NULLIF(btrim("name"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'WarehouseLocation_optional_text_not_blank'
) THEN
ALTER TABLE "WarehouseLocation"
ADD CONSTRAINT "WarehouseLocation_optional_text_not_blank"
CHECK (
("zone" IS NULL OR NULLIF(btrim("zone"), '') IS NOT NULL)
AND ("aisle" IS NULL OR NULLIF(btrim("aisle"), '') IS NOT NULL)
AND ("shelf" IS NULL OR NULLIF(btrim("shelf"), '') IS NOT NULL)
AND ("bin" IS NULL OR NULLIF(btrim("bin"), '') IS NOT NULL)
AND ("notes" IS NULL OR NULLIF(btrim("notes"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'BrandMaterialBinding_materialType_allowed'
) THEN
ALTER TABLE "BrandMaterialBinding"
ADD CONSTRAINT "BrandMaterialBinding_materialType_allowed"
CHECK ("materialType" IN ('MANUAL', 'NAMEPLATE', 'CERTIFICATE', 'CARTON_PRINT'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'BrandMaterialBinding_identity_not_blank'
) THEN
ALTER TABLE "BrandMaterialBinding"
ADD CONSTRAINT "BrandMaterialBinding_identity_not_blank"
CHECK (
NULLIF(btrim("brand"), '') IS NOT NULL
AND NULLIF(btrim("materialType"), '') IS NOT NULL
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'BrandMaterialBinding_file_metadata_consistent'
) THEN
ALTER TABLE "BrandMaterialBinding"
ADD CONSTRAINT "BrandMaterialBinding_file_metadata_consistent"
CHECK (
(
"fileUrl" IS NULL
AND "fileName" IS NULL
AND "fileSize" IS NULL
)
OR (
NULLIF(btrim(COALESCE("fileUrl", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE("fileName", '')), '') IS NOT NULL
AND "fileSize" >= 0
)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PackagedItem_status_allowed'
) THEN
ALTER TABLE "PackagedItem"
ADD CONSTRAINT "PackagedItem_status_allowed"
CHECK ("status" IN ('DRAFT', 'ACTIVE', 'ARCHIVED'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PackagedItem_name_not_blank'
) THEN
ALTER TABLE "PackagedItem"
ADD CONSTRAINT "PackagedItem_name_not_blank"
CHECK (NULLIF(btrim("name"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PackagedItem_quantities_valid'
) THEN
ALTER TABLE "PackagedItem"
ADD CONSTRAINT "PackagedItem_quantities_valid"
CHECK ("quantityPerPack" > 0 AND "stockQuantity" >= 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PackagedItem_dimensions_valid'
) THEN
ALTER TABLE "PackagedItem"
ADD CONSTRAINT "PackagedItem_dimensions_valid"
CHECK (
("length" IS NULL OR "length" > 0)
AND ("width" IS NULL OR "width" > 0)
AND ("height" IS NULL OR "height" > 0)
AND ("weight" IS NULL OR "weight" >= 0)
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'PackagedItem_payload_object'
) THEN
ALTER TABLE "PackagedItem"
ADD CONSTRAINT "PackagedItem_payload_object"
CHECK ("payload" IS NULL OR jsonb_typeof("payload") IN ('object', 'null'));
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "WarehouseLocation_active_position_key"
ON "WarehouseLocation" (
lower(btrim(COALESCE("zone", ''))),
lower(btrim(COALESCE("aisle", ''))),
lower(btrim(COALESCE("shelf", ''))),
lower(btrim(COALESCE("bin", '')))
)
WHERE "isActive" = true;
CREATE UNIQUE INDEX IF NOT EXISTS "BrandMaterialBinding_brand_material_normalized_key"
ON "BrandMaterialBinding" (
lower(btrim("brand")),
upper(btrim("materialType"))
);

View File

@ -0,0 +1,207 @@
-- Strengthen approval workflow foundation data.
-- Approval workflows are process records, but their identity, access key,
-- participant list, role matrix, and change-log source fields still need
-- database-level guards so direct imports cannot bypass service validation.
CREATE OR REPLACE FUNCTION public.text_array_distinct_non_blank(items text[])
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$
SELECT COALESCE(
(
SELECT bool_and(NULLIF(btrim(item), '') IS NOT NULL)
AND COUNT(*) = COUNT(DISTINCT item)
FROM unnest(items) AS item
),
true
);
$$;
UPDATE "ApprovalWorkflow"
SET
"approvalNumber" = upper(btrim("approvalNumber")),
"title" = btrim("title"),
"productId" = btrim("productId"),
"accessKey" = btrim("accessKey"),
"participantUserIds" = (
SELECT COALESCE(array_agg(DISTINCT btrim(item) ORDER BY btrim(item)), ARRAY[]::text[])
FROM unnest("participantUserIds") AS item
WHERE NULLIF(btrim(item), '') IS NOT NULL
)
WHERE
"approvalNumber" IS DISTINCT FROM upper(btrim("approvalNumber"))
OR "title" IS DISTINCT FROM btrim("title")
OR "productId" IS DISTINCT FROM btrim("productId")
OR "accessKey" IS DISTINCT FROM btrim("accessKey")
OR "participantUserIds" IS DISTINCT FROM (
SELECT COALESCE(array_agg(DISTINCT btrim(item) ORDER BY btrim(item)), ARRAY[]::text[])
FROM unnest("participantUserIds") AS item
WHERE NULLIF(btrim(item), '') IS NOT NULL
);
UPDATE "ApprovalWorkflowFieldChange"
SET
"fieldKey" = btrim("fieldKey"),
"source" = btrim("source"),
"sourcePage" = NULLIF(btrim(COALESCE("sourcePage", '')), '')
WHERE
"fieldKey" IS DISTINCT FROM btrim("fieldKey")
OR "source" IS DISTINCT FROM btrim("source")
OR "sourcePage" IS DISTINCT FROM NULLIF(btrim(COALESCE("sourcePage", '')), '');
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ApprovalWorkflow"
WHERE NULLIF(btrim("approvalNumber"), '') IS NULL
OR "approvalNumber" <> upper(btrim("approvalNumber"))
OR NULLIF(btrim("title"), '') IS NULL
OR NULLIF(btrim("productId"), '') IS NULL
OR NULLIF(btrim("accessKey"), '') IS NULL
OR NOT public.text_array_distinct_non_blank("participantUserIds")
OR cardinality(array_positions("businessRoles", 'DESIGN'::"BusinessAccountType")) > 1
OR cardinality(array_positions("businessRoles", 'PLANNING'::"BusinessAccountType")) > 1
OR cardinality(array_positions("businessRoles", 'PURCHASING'::"BusinessAccountType")) > 1
OR cardinality(array_positions("businessRoles", 'WAREHOUSE'::"BusinessAccountType")) > 1
OR cardinality(array_positions("businessRoles", 'SUPPLIER'::"BusinessAccountType")) > 1
OR ("fieldMatrix" IS NOT NULL AND jsonb_typeof("fieldMatrix") NOT IN ('array', 'null'))
OR ("summary" IS NOT NULL AND jsonb_typeof("summary") NOT IN ('object', 'null'))
OR ("metadata" IS NOT NULL AND jsonb_typeof("metadata") NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add ApprovalWorkflow guards: % rows have invalid identity, arrays, or JSON shape.',
invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ApprovalWorkflowFieldChange"
WHERE NULLIF(btrim("fieldKey"), '') IS NULL
OR NULLIF(btrim("source"), '') IS NULL
OR ("sourcePage" IS NOT NULL AND NULLIF(btrim("sourcePage"), '') IS NULL)
OR ("metadata" IS NOT NULL AND jsonb_typeof("metadata") NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add ApprovalWorkflowFieldChange guards: % rows have invalid source fields or metadata shape.',
invalid_count;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_approvalNumber_normalized'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_approvalNumber_normalized"
CHECK (
NULLIF(btrim("approvalNumber"), '') IS NOT NULL
AND "approvalNumber" = upper(btrim("approvalNumber"))
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_title_not_blank'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_title_not_blank"
CHECK (NULLIF(btrim("title"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_productId_not_blank'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_productId_not_blank"
CHECK (NULLIF(btrim("productId"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_accessKey_not_blank'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_accessKey_not_blank"
CHECK (NULLIF(btrim("accessKey"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_participant_user_ids_valid'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_participant_user_ids_valid"
CHECK (public.text_array_distinct_non_blank("participantUserIds"));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_business_roles_distinct'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_business_roles_distinct"
CHECK (
cardinality(array_positions("businessRoles", 'DESIGN'::"BusinessAccountType")) <= 1
AND cardinality(array_positions("businessRoles", 'PLANNING'::"BusinessAccountType")) <= 1
AND cardinality(array_positions("businessRoles", 'PURCHASING'::"BusinessAccountType")) <= 1
AND cardinality(array_positions("businessRoles", 'WAREHOUSE'::"BusinessAccountType")) <= 1
AND cardinality(array_positions("businessRoles", 'SUPPLIER'::"BusinessAccountType")) <= 1
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_fieldMatrix_array'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_fieldMatrix_array"
CHECK ("fieldMatrix" IS NULL OR jsonb_typeof("fieldMatrix") IN ('array', 'null'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_summary_object'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_summary_object"
CHECK ("summary" IS NULL OR jsonb_typeof("summary") IN ('object', 'null'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflow_metadata_object'
) THEN
ALTER TABLE "ApprovalWorkflow"
ADD CONSTRAINT "ApprovalWorkflow_metadata_object"
CHECK ("metadata" IS NULL OR jsonb_typeof("metadata") IN ('object', 'null'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflowFieldChange_fieldKey_not_blank'
) THEN
ALTER TABLE "ApprovalWorkflowFieldChange"
ADD CONSTRAINT "ApprovalWorkflowFieldChange_fieldKey_not_blank"
CHECK (NULLIF(btrim("fieldKey"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflowFieldChange_source_not_blank'
) THEN
ALTER TABLE "ApprovalWorkflowFieldChange"
ADD CONSTRAINT "ApprovalWorkflowFieldChange_source_not_blank"
CHECK (NULLIF(btrim("source"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflowFieldChange_optional_text_not_blank'
) THEN
ALTER TABLE "ApprovalWorkflowFieldChange"
ADD CONSTRAINT "ApprovalWorkflowFieldChange_optional_text_not_blank"
CHECK ("sourcePage" IS NULL OR NULLIF(btrim("sourcePage"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ApprovalWorkflowFieldChange_metadata_object'
) THEN
ALTER TABLE "ApprovalWorkflowFieldChange"
ADD CONSTRAINT "ApprovalWorkflowFieldChange_metadata_object"
CHECK ("metadata" IS NULL OR jsonb_typeof("metadata") IN ('object', 'null'));
END IF;
END $$;

View File

@ -0,0 +1,445 @@
-- Strengthen people and organization foundation data.
-- User, Department, Team, and EmployeeDirectory are permission and provisioning
-- roots. Their identity fields must be normalized and guarded by the database.
CREATE OR REPLACE FUNCTION public.text_array_distinct_non_blank(items text[])
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$
SELECT COALESCE(
(
SELECT bool_and(NULLIF(btrim(item), '') IS NOT NULL)
AND COUNT(*) = COUNT(DISTINCT item)
FROM unnest(items) AS item
),
true
);
$$;
DO $$
DECLARE
duplicate_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO duplicate_count
FROM (
SELECT lower(btrim(email))
FROM "User"
GROUP BY lower(btrim(email))
HAVING COUNT(*) > 1
) duplicates;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'Cannot add User normalized email guard: % duplicate normalized emails exist.', duplicate_count;
END IF;
SELECT COUNT(*)::integer
INTO duplicate_count
FROM (
SELECT btrim(name)
FROM "User"
GROUP BY btrim(name)
HAVING COUNT(*) > 1
) duplicates;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'Cannot add User normalized name guard: % duplicate normalized names exist.', duplicate_count;
END IF;
SELECT COUNT(*)::integer
INTO duplicate_count
FROM (
SELECT btrim(name)
FROM "Department"
GROUP BY btrim(name)
HAVING COUNT(*) > 1
) duplicates;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'Cannot add Department normalized name guard: % duplicate normalized names exist.', duplicate_count;
END IF;
SELECT COUNT(*)::integer
INTO duplicate_count
FROM (
SELECT btrim("externalId")
FROM "Department"
WHERE NULLIF(btrim(COALESCE("externalId", '')), '') IS NOT NULL
GROUP BY btrim("externalId")
HAVING COUNT(*) > 1
) duplicates;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'Cannot add Department normalized externalId guard: % duplicate normalized external IDs exist.', duplicate_count;
END IF;
SELECT COUNT(*)::integer
INTO duplicate_count
FROM (
SELECT btrim("dingtalkUserId")
FROM "EmployeeDirectory"
WHERE NULLIF(btrim(COALESCE("dingtalkUserId", '')), '') IS NOT NULL
GROUP BY btrim("dingtalkUserId")
HAVING COUNT(*) > 1
) duplicates;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'Cannot add EmployeeDirectory dingtalkUserId guard: % duplicate normalized IDs exist.', duplicate_count;
END IF;
SELECT COUNT(*)::integer
INTO duplicate_count
FROM (
SELECT btrim("dingtalkUnionId")
FROM "EmployeeDirectory"
WHERE NULLIF(btrim(COALESCE("dingtalkUnionId", '')), '') IS NOT NULL
GROUP BY btrim("dingtalkUnionId")
HAVING COUNT(*) > 1
) duplicates;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'Cannot add EmployeeDirectory dingtalkUnionId guard: % duplicate normalized IDs exist.', duplicate_count;
END IF;
SELECT COUNT(*)::integer
INTO duplicate_count
FROM (
SELECT btrim("jobNumber")
FROM "EmployeeDirectory"
WHERE NULLIF(btrim(COALESCE("jobNumber", '')), '') IS NOT NULL
GROUP BY btrim("jobNumber")
HAVING COUNT(*) > 1
) duplicates;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'Cannot add EmployeeDirectory jobNumber guard: % duplicate normalized job numbers exist.', duplicate_count;
END IF;
SELECT COUNT(*)::integer
INTO duplicate_count
FROM (
SELECT btrim(mobile)
FROM "EmployeeDirectory"
WHERE NULLIF(btrim(COALESCE(mobile, '')), '') IS NOT NULL
GROUP BY btrim(mobile)
HAVING COUNT(*) > 1
) duplicates;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'Cannot add EmployeeDirectory mobile guard: % duplicate normalized mobiles exist.', duplicate_count;
END IF;
END $$;
UPDATE "User"
SET
email = lower(btrim(email)),
name = btrim(name),
"loginSource" = btrim("loginSource"),
avatar = NULLIF(btrim(COALESCE(avatar, '')), ''),
phone = NULLIF(btrim(COALESCE(phone, '')), ''),
"dingtalkUserId" = NULLIF(btrim(COALESCE("dingtalkUserId", '')), ''),
"dingtalkUnionId" = NULLIF(btrim(COALESCE("dingtalkUnionId", '')), '')
WHERE
email IS DISTINCT FROM lower(btrim(email))
OR name IS DISTINCT FROM btrim(name)
OR "loginSource" IS DISTINCT FROM btrim("loginSource")
OR avatar IS DISTINCT FROM NULLIF(btrim(COALESCE(avatar, '')), '')
OR phone IS DISTINCT FROM NULLIF(btrim(COALESCE(phone, '')), '')
OR "dingtalkUserId" IS DISTINCT FROM NULLIF(btrim(COALESCE("dingtalkUserId", '')), '')
OR "dingtalkUnionId" IS DISTINCT FROM NULLIF(btrim(COALESCE("dingtalkUnionId", '')), '');
UPDATE "Department"
SET
name = btrim(name),
"fullPath" = btrim("fullPath"),
"externalId" = NULLIF(btrim(COALESCE("externalId", '')), ''),
description = NULLIF(btrim(COALESCE(description, '')), '')
WHERE
name IS DISTINCT FROM btrim(name)
OR "fullPath" IS DISTINCT FROM btrim("fullPath")
OR "externalId" IS DISTINCT FROM NULLIF(btrim(COALESCE("externalId", '')), '')
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '');
UPDATE "Team"
SET
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), '')
WHERE
name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '');
UPDATE "EmployeeDirectory"
SET
"dingtalkUserId" = NULLIF(btrim(COALESCE("dingtalkUserId", '')), ''),
"dingtalkUnionId" = NULLIF(btrim(COALESCE("dingtalkUnionId", '')), ''),
"jobNumber" = NULLIF(btrim(COALESCE("jobNumber", '')), ''),
name = btrim(name),
mobile = NULLIF(btrim(COALESCE(mobile, '')), ''),
email = NULLIF(lower(btrim(COALESCE(email, ''))), ''),
title = NULLIF(btrim(COALESCE(title, '')), ''),
"primaryDepartmentExternalId" = NULLIF(btrim(COALESCE("primaryDepartmentExternalId", '')), '')
WHERE
"dingtalkUserId" IS DISTINCT FROM NULLIF(btrim(COALESCE("dingtalkUserId", '')), '')
OR "dingtalkUnionId" IS DISTINCT FROM NULLIF(btrim(COALESCE("dingtalkUnionId", '')), '')
OR "jobNumber" IS DISTINCT FROM NULLIF(btrim(COALESCE("jobNumber", '')), '')
OR name IS DISTINCT FROM btrim(name)
OR mobile IS DISTINCT FROM NULLIF(btrim(COALESCE(mobile, '')), '')
OR email IS DISTINCT FROM NULLIF(lower(btrim(COALESCE(email, ''))), '')
OR title IS DISTINCT FROM NULLIF(btrim(COALESCE(title, '')), '')
OR "primaryDepartmentExternalId" IS DISTINCT FROM NULLIF(btrim(COALESCE("primaryDepartmentExternalId", '')), '');
UPDATE "EmployeeLookupCache"
SET
"cacheKey" = btrim("cacheKey"),
"keyType" = btrim("keyType"),
"lookupValue" = btrim("lookupValue")
WHERE
"cacheKey" IS DISTINCT FROM btrim("cacheKey")
OR "keyType" IS DISTINCT FROM btrim("keyType")
OR "lookupValue" IS DISTINCT FROM btrim("lookupValue");
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_count
FROM "User"
WHERE NULLIF(btrim(email), '') IS NULL
OR email <> lower(btrim(email))
OR NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("loginSource"), '') IS NULL
OR cardinality(array_positions("businessRoles", 'DESIGN'::"BusinessAccountType")) > 1
OR cardinality(array_positions("businessRoles", 'PLANNING'::"BusinessAccountType")) > 1
OR cardinality(array_positions("businessRoles", 'PURCHASING'::"BusinessAccountType")) > 1
OR cardinality(array_positions("businessRoles", 'WAREHOUSE'::"BusinessAccountType")) > 1
OR cardinality(array_positions("businessRoles", 'SUPPLIER'::"BusinessAccountType")) > 1
OR (avatar IS NOT NULL AND NULLIF(btrim(avatar), '') IS NULL)
OR (phone IS NOT NULL AND NULLIF(btrim(phone), '') IS NULL)
OR ("dingtalkUserId" IS NOT NULL AND NULLIF(btrim("dingtalkUserId"), '') IS NULL)
OR ("dingtalkUnionId" IS NOT NULL AND NULLIF(btrim("dingtalkUnionId"), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add User guards: % rows have invalid identity or optional text fields.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "Department"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("fullPath"), '') IS NULL
OR "fullPath" <> btrim("fullPath")
OR ("externalId" IS NOT NULL AND NULLIF(btrim("externalId"), '') IS NULL)
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR "orderIndex" < 0;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add Department guards: % rows have invalid identity or hierarchy fields.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "Team"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR "orderIndex" < 0;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add Team guards: % rows have invalid identity fields.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "EmployeeDirectory"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR ("dingtalkUserId" IS NOT NULL AND NULLIF(btrim("dingtalkUserId"), '') IS NULL)
OR ("dingtalkUnionId" IS NOT NULL AND NULLIF(btrim("dingtalkUnionId"), '') IS NULL)
OR ("jobNumber" IS NOT NULL AND NULLIF(btrim("jobNumber"), '') IS NULL)
OR (mobile IS NOT NULL AND NULLIF(btrim(mobile), '') IS NULL)
OR (email IS NOT NULL AND (NULLIF(btrim(email), '') IS NULL OR email <> lower(btrim(email))))
OR (title IS NOT NULL AND NULLIF(btrim(title), '') IS NULL)
OR ("primaryDepartmentExternalId" IS NOT NULL AND NULLIF(btrim("primaryDepartmentExternalId"), '') IS NULL)
OR ("departmentExternalIds" IS NOT NULL AND jsonb_typeof("departmentExternalIds") NOT IN ('array', 'null'))
OR ("rawSnapshot" IS NOT NULL AND jsonb_typeof("rawSnapshot") NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add EmployeeDirectory guards: % rows have invalid identity or JSON fields.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "EmployeeLookupCache"
WHERE NULLIF(btrim("cacheKey"), '') IS NULL
OR NULLIF(btrim("keyType"), '') IS NULL
OR NULLIF(btrim("lookupValue"), '') IS NULL
OR ("result" IS NOT NULL AND jsonb_typeof("result") NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add EmployeeLookupCache guards: % rows have invalid cache identity or result shape.', invalid_count;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'User_email_normalized') THEN
ALTER TABLE "User"
ADD CONSTRAINT "User_email_normalized"
CHECK (NULLIF(btrim(email), '') IS NOT NULL AND email = lower(btrim(email)));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'User_name_not_blank') THEN
ALTER TABLE "User"
ADD CONSTRAINT "User_name_not_blank"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'User_loginSource_not_blank') THEN
ALTER TABLE "User"
ADD CONSTRAINT "User_loginSource_not_blank"
CHECK (NULLIF(btrim("loginSource"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'User_business_roles_distinct') THEN
ALTER TABLE "User"
ADD CONSTRAINT "User_business_roles_distinct"
CHECK (
cardinality(array_positions("businessRoles", 'DESIGN'::"BusinessAccountType")) <= 1
AND cardinality(array_positions("businessRoles", 'PLANNING'::"BusinessAccountType")) <= 1
AND cardinality(array_positions("businessRoles", 'PURCHASING'::"BusinessAccountType")) <= 1
AND cardinality(array_positions("businessRoles", 'WAREHOUSE'::"BusinessAccountType")) <= 1
AND cardinality(array_positions("businessRoles", 'SUPPLIER'::"BusinessAccountType")) <= 1
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'User_optional_text_not_blank') THEN
ALTER TABLE "User"
ADD CONSTRAINT "User_optional_text_not_blank"
CHECK (
(avatar IS NULL OR NULLIF(btrim(avatar), '') IS NOT NULL)
AND (phone IS NULL OR NULLIF(btrim(phone), '') IS NOT NULL)
AND ("dingtalkUserId" IS NULL OR NULLIF(btrim("dingtalkUserId"), '') IS NOT NULL)
AND ("dingtalkUnionId" IS NULL OR NULLIF(btrim("dingtalkUnionId"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Department_identity_not_blank') THEN
ALTER TABLE "Department"
ADD CONSTRAINT "Department_identity_not_blank"
CHECK (
NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND NULLIF(btrim("fullPath"), '') IS NOT NULL
AND "fullPath" = btrim("fullPath")
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Department_optional_text_not_blank') THEN
ALTER TABLE "Department"
ADD CONSTRAINT "Department_optional_text_not_blank"
CHECK (
("externalId" IS NULL OR NULLIF(btrim("externalId"), '') IS NOT NULL)
AND (description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Department_orderIndex_non_negative') THEN
ALTER TABLE "Department"
ADD CONSTRAINT "Department_orderIndex_non_negative"
CHECK ("orderIndex" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Team_identity_not_blank') THEN
ALTER TABLE "Team"
ADD CONSTRAINT "Team_identity_not_blank"
CHECK (
NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND (description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND "orderIndex" >= 0
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'EmployeeDirectory_name_not_blank') THEN
ALTER TABLE "EmployeeDirectory"
ADD CONSTRAINT "EmployeeDirectory_name_not_blank"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'EmployeeDirectory_optional_text_not_blank') THEN
ALTER TABLE "EmployeeDirectory"
ADD CONSTRAINT "EmployeeDirectory_optional_text_not_blank"
CHECK (
("dingtalkUserId" IS NULL OR NULLIF(btrim("dingtalkUserId"), '') IS NOT NULL)
AND ("dingtalkUnionId" IS NULL OR NULLIF(btrim("dingtalkUnionId"), '') IS NOT NULL)
AND ("jobNumber" IS NULL OR NULLIF(btrim("jobNumber"), '') IS NOT NULL)
AND (mobile IS NULL OR NULLIF(btrim(mobile), '') IS NOT NULL)
AND (title IS NULL OR NULLIF(btrim(title), '') IS NOT NULL)
AND ("primaryDepartmentExternalId" IS NULL OR NULLIF(btrim("primaryDepartmentExternalId"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'EmployeeDirectory_email_normalized') THEN
ALTER TABLE "EmployeeDirectory"
ADD CONSTRAINT "EmployeeDirectory_email_normalized"
CHECK (email IS NULL OR email = lower(btrim(email)));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'EmployeeDirectory_departmentExternalIds_array') THEN
ALTER TABLE "EmployeeDirectory"
ADD CONSTRAINT "EmployeeDirectory_departmentExternalIds_array"
CHECK ("departmentExternalIds" IS NULL OR jsonb_typeof("departmentExternalIds") IN ('array', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'EmployeeDirectory_rawSnapshot_object') THEN
ALTER TABLE "EmployeeDirectory"
ADD CONSTRAINT "EmployeeDirectory_rawSnapshot_object"
CHECK ("rawSnapshot" IS NULL OR jsonb_typeof("rawSnapshot") IN ('object', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'EmployeeLookupCache_identity_not_blank') THEN
ALTER TABLE "EmployeeLookupCache"
ADD CONSTRAINT "EmployeeLookupCache_identity_not_blank"
CHECK (
NULLIF(btrim("cacheKey"), '') IS NOT NULL
AND NULLIF(btrim("keyType"), '') IS NOT NULL
AND NULLIF(btrim("lookupValue"), '') IS NOT NULL
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'EmployeeLookupCache_result_object') THEN
ALTER TABLE "EmployeeLookupCache"
ADD CONSTRAINT "EmployeeLookupCache_result_object"
CHECK ("result" IS NULL OR jsonb_typeof("result") IN ('object', 'null'));
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "User_email_normalized_key"
ON "User" (lower(btrim(email)));
CREATE UNIQUE INDEX IF NOT EXISTS "User_name_normalized_key"
ON "User" (btrim(name));
CREATE UNIQUE INDEX IF NOT EXISTS "Department_name_normalized_key"
ON "Department" (btrim(name));
CREATE UNIQUE INDEX IF NOT EXISTS "Department_externalId_normalized_key"
ON "Department" (btrim("externalId"))
WHERE NULLIF(btrim(COALESCE("externalId", '')), '') IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "EmployeeDirectory_dingtalkUserId_normalized_key"
ON "EmployeeDirectory" (btrim("dingtalkUserId"))
WHERE NULLIF(btrim(COALESCE("dingtalkUserId", '')), '') IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "EmployeeDirectory_dingtalkUnionId_normalized_key"
ON "EmployeeDirectory" (btrim("dingtalkUnionId"))
WHERE NULLIF(btrim(COALESCE("dingtalkUnionId", '')), '') IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "EmployeeDirectory_jobNumber_normalized_key"
ON "EmployeeDirectory" (btrim("jobNumber"))
WHERE NULLIF(btrim(COALESCE("jobNumber", '')), '') IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "EmployeeDirectory_mobile_normalized_key"
ON "EmployeeDirectory" (btrim(mobile))
WHERE NULLIF(btrim(COALESCE(mobile, '')), '') IS NOT NULL;

View File

@ -0,0 +1,194 @@
-- Strengthen planning foundation data.
-- Planning templates and workspaces define the pre-product planning process.
-- JSON columns are allowed only as bounded template/process payloads; identity,
-- visibility, and draft snapshot shapes are guarded at the database layer.
UPDATE "PlanningTemplate"
SET
code = upper(btrim(code)),
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), ''),
"namePattern" = NULLIF(btrim(COALESCE("namePattern", '')), '')
WHERE
code IS DISTINCT FROM upper(btrim(code))
OR name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR "namePattern" IS DISTINCT FROM NULLIF(btrim(COALESCE("namePattern", '')), '');
UPDATE "PlanningWorkspace"
SET
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), ''),
visibility = upper(btrim(visibility))
WHERE
name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR visibility IS DISTINCT FROM upper(btrim(visibility));
UPDATE "PlanningDraft"
SET
name = btrim(name),
"selectedCandidateKey" = btrim("selectedCandidateKey"),
notes = NULLIF(btrim(COALESCE(notes, '')), '')
WHERE
name IS DISTINCT FROM btrim(name)
OR "selectedCandidateKey" IS DISTINCT FROM btrim("selectedCandidateKey")
OR notes IS DISTINCT FROM NULLIF(btrim(COALESCE(notes, '')), '');
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_count
FROM "PlanningTemplate"
WHERE NULLIF(btrim(code), '') IS NULL
OR code <> upper(btrim(code))
OR NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR ("namePattern" IS NOT NULL AND NULLIF(btrim("namePattern"), '') IS NULL)
OR jsonb_typeof(slots) <> 'array'
OR jsonb_array_length(slots) = 0
OR (rules IS NOT NULL AND jsonb_typeof(rules) NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add PlanningTemplate guards: % rows have invalid identity or JSON shape.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "PlanningWorkspace"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR visibility NOT IN ('PRIVATE', 'TEAM', 'DEPARTMENT', 'PUBLIC')
OR jsonb_typeof("slotSelections") <> 'object'
OR (metadata IS NOT NULL AND jsonb_typeof(metadata) NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add PlanningWorkspace guards: % rows have invalid identity, visibility, or JSON shape.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "PlanningDraft"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("selectedCandidateKey"), '') IS NULL
OR "selectedCandidateKey" <> btrim("selectedCandidateKey")
OR jsonb_typeof("selectedAssets") <> 'object'
OR jsonb_typeof("previewNodes") <> 'array'
OR jsonb_typeof(summary) <> 'array'
OR (warnings IS NOT NULL AND jsonb_typeof(warnings) NOT IN ('array', 'null'))
OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL)
OR ("sourceSnapshot" IS NOT NULL AND jsonb_typeof("sourceSnapshot") NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add PlanningDraft guards: % rows have invalid identity or snapshot JSON shape.', invalid_count;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningTemplate_name_not_blank') THEN
ALTER TABLE "PlanningTemplate"
ADD CONSTRAINT "PlanningTemplate_name_not_blank"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningTemplate_slots_array') THEN
ALTER TABLE "PlanningTemplate"
ADD CONSTRAINT "PlanningTemplate_slots_array"
CHECK (jsonb_typeof(slots) = 'array' AND jsonb_array_length(slots) > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningTemplate_rules_object') THEN
ALTER TABLE "PlanningTemplate"
ADD CONSTRAINT "PlanningTemplate_rules_object"
CHECK (rules IS NULL OR jsonb_typeof(rules) IN ('object', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningTemplate_optional_text_not_blank') THEN
ALTER TABLE "PlanningTemplate"
ADD CONSTRAINT "PlanningTemplate_optional_text_not_blank"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND ("namePattern" IS NULL OR NULLIF(btrim("namePattern"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningWorkspace_name_not_blank') THEN
ALTER TABLE "PlanningWorkspace"
ADD CONSTRAINT "PlanningWorkspace_name_not_blank"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningWorkspace_visibility_allowed') THEN
ALTER TABLE "PlanningWorkspace"
ADD CONSTRAINT "PlanningWorkspace_visibility_allowed"
CHECK (visibility IN ('PRIVATE', 'TEAM', 'DEPARTMENT', 'PUBLIC'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningWorkspace_slotSelections_object') THEN
ALTER TABLE "PlanningWorkspace"
ADD CONSTRAINT "PlanningWorkspace_slotSelections_object"
CHECK (jsonb_typeof("slotSelections") = 'object');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningWorkspace_metadata_object') THEN
ALTER TABLE "PlanningWorkspace"
ADD CONSTRAINT "PlanningWorkspace_metadata_object"
CHECK (metadata IS NULL OR jsonb_typeof(metadata) IN ('object', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningWorkspace_optional_text_not_blank') THEN
ALTER TABLE "PlanningWorkspace"
ADD CONSTRAINT "PlanningWorkspace_optional_text_not_blank"
CHECK (description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningDraft_identity_not_blank') THEN
ALTER TABLE "PlanningDraft"
ADD CONSTRAINT "PlanningDraft_identity_not_blank"
CHECK (
NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND NULLIF(btrim("selectedCandidateKey"), '') IS NOT NULL
AND "selectedCandidateKey" = btrim("selectedCandidateKey")
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningDraft_selectedAssets_object') THEN
ALTER TABLE "PlanningDraft"
ADD CONSTRAINT "PlanningDraft_selectedAssets_object"
CHECK (jsonb_typeof("selectedAssets") = 'object');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningDraft_previewNodes_array') THEN
ALTER TABLE "PlanningDraft"
ADD CONSTRAINT "PlanningDraft_previewNodes_array"
CHECK (jsonb_typeof("previewNodes") = 'array');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningDraft_summary_array') THEN
ALTER TABLE "PlanningDraft"
ADD CONSTRAINT "PlanningDraft_summary_array"
CHECK (jsonb_typeof(summary) = 'array');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningDraft_warnings_array') THEN
ALTER TABLE "PlanningDraft"
ADD CONSTRAINT "PlanningDraft_warnings_array"
CHECK (warnings IS NULL OR jsonb_typeof(warnings) IN ('array', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningDraft_sourceSnapshot_object') THEN
ALTER TABLE "PlanningDraft"
ADD CONSTRAINT "PlanningDraft_sourceSnapshot_object"
CHECK ("sourceSnapshot" IS NULL OR jsonb_typeof("sourceSnapshot") IN ('object', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PlanningDraft_optional_text_not_blank') THEN
ALTER TABLE "PlanningDraft"
ADD CONSTRAINT "PlanningDraft_optional_text_not_blank"
CHECK (notes IS NULL OR NULLIF(btrim(notes), '') IS NOT NULL);
END IF;
END $$;

View File

@ -0,0 +1,123 @@
-- Strengthen formal product sharing, upload, and collaboration guards.
-- Product remains the formal SPU truth source; ShareLink is an access record;
-- ProductCollaborator is a permission relation, not a second people master table.
UPDATE "Product"
SET
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), ''),
visibility = upper(btrim(visibility))
WHERE
name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR visibility IS DISTINCT FROM upper(btrim(visibility));
UPDATE "ProductCollaborator"
SET
role = lower(btrim(role)),
"addedBy" = NULLIF(btrim(COALESCE("addedBy", '')), '')
WHERE
role IS DISTINCT FROM lower(btrim(role))
OR "addedBy" IS DISTINCT FROM NULLIF(btrim(COALESCE("addedBy", '')), '');
UPDATE "ShareLink"
SET token = btrim(token)
WHERE token IS DISTINCT FROM btrim(token);
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_count
FROM "Product"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR visibility NOT IN ('PRIVATE', 'TEAM', 'DEPARTMENT', 'PUBLIC')
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add Product sharing/upload guards: % rows have invalid name, visibility, or optional text.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ProductCollaborator"
WHERE NULLIF(btrim("productId"), '') IS NULL
OR NULLIF(btrim("userId"), '') IS NULL
OR role NOT IN ('editor', 'viewer')
OR ("addedBy" IS NOT NULL AND NULLIF(btrim("addedBy"), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add ProductCollaborator guards: % rows have invalid identity, role, or addedBy.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ShareLink"
WHERE NULLIF(btrim(token), '') IS NULL
OR token <> btrim(token)
OR "viewCount" < 0
OR ("expiresAt" IS NOT NULL AND "expiresAt" <= "createdAt");
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add ShareLink guards: % rows have invalid token, view count, or expiry time.', invalid_count;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Product_name_not_blank') THEN
ALTER TABLE "Product"
ADD CONSTRAINT "Product_name_not_blank"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Product_visibility_allowed') THEN
ALTER TABLE "Product"
ADD CONSTRAINT "Product_visibility_allowed"
CHECK (visibility IN ('PRIVATE', 'TEAM', 'DEPARTMENT', 'PUBLIC'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Product_optional_text_not_blank') THEN
ALTER TABLE "Product"
ADD CONSTRAINT "Product_optional_text_not_blank"
CHECK (description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductCollaborator_identity_not_blank') THEN
ALTER TABLE "ProductCollaborator"
ADD CONSTRAINT "ProductCollaborator_identity_not_blank"
CHECK (
NULLIF(btrim("productId"), '') IS NOT NULL
AND NULLIF(btrim("userId"), '') IS NOT NULL
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductCollaborator_role_allowed') THEN
ALTER TABLE "ProductCollaborator"
ADD CONSTRAINT "ProductCollaborator_role_allowed"
CHECK (role IN ('editor', 'viewer'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductCollaborator_addedBy_not_blank') THEN
ALTER TABLE "ProductCollaborator"
ADD CONSTRAINT "ProductCollaborator_addedBy_not_blank"
CHECK ("addedBy" IS NULL OR NULLIF(btrim("addedBy"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ShareLink_token_not_blank') THEN
ALTER TABLE "ShareLink"
ADD CONSTRAINT "ShareLink_token_not_blank"
CHECK (NULLIF(btrim(token), '') IS NOT NULL AND token = btrim(token));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ShareLink_viewCount_non_negative') THEN
ALTER TABLE "ShareLink"
ADD CONSTRAINT "ShareLink_viewCount_non_negative"
CHECK ("viewCount" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ShareLink_expiresAt_after_createdAt') THEN
ALTER TABLE "ShareLink"
ADD CONSTRAINT "ShareLink_expiresAt_after_createdAt"
CHECK ("expiresAt" IS NULL OR "expiresAt" > "createdAt");
END IF;
END $$;

View File

@ -0,0 +1,462 @@
-- Strengthen legacy CAD structure data.
-- Panel, Hole, ProductHardware, AssemblyStep, and AssemblyVersion are the
-- legacy CAD compatibility chain. JSON columns remain bounded geometry,
-- animation, or snapshot payloads; identity, dimensions, enums, and relation
-- references are guarded as structured data.
CREATE OR REPLACE FUNCTION public.text_array_distinct_non_blank(items text[])
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$
SELECT COALESCE(
(
SELECT bool_and(NULLIF(btrim(item), '') IS NOT NULL)
AND COUNT(*) = COUNT(DISTINCT btrim(item))
FROM unnest(items) AS item
),
true
);
$$;
ALTER TABLE "ProductHardware"
ALTER COLUMN "hardwareId" DROP NOT NULL,
ADD COLUMN IF NOT EXISTS "connectedPanelId" TEXT,
ADD COLUMN IF NOT EXISTS "associatedHoleId" TEXT,
ADD COLUMN IF NOT EXISTS "holeDistance" DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS "parentHardwareId" TEXT,
ADD COLUMN IF NOT EXISTS "isAccessory" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "matchStatus" TEXT NOT NULL DEFAULT 'unmatched',
ADD COLUMN IF NOT EXISTS "matchConfidence" DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS "matchMethod" TEXT,
ADD COLUMN IF NOT EXISTS "sourceSignature" JSONB;
CREATE INDEX IF NOT EXISTS "ProductHardware_matchStatus_idx" ON "ProductHardware"("matchStatus");
ALTER TABLE "AssemblyStep"
ALTER COLUMN "duration" SET DEFAULT 1500,
ALTER COLUMN "easing" SET DEFAULT 'power2.inOut',
ADD COLUMN IF NOT EXISTS "stepType" TEXT NOT NULL DEFAULT 'panel',
ADD COLUMN IF NOT EXISTS "panelId" TEXT,
ADD COLUMN IF NOT EXISTS "hardwareIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS "cameraPosition" JSONB,
ADD COLUMN IF NOT EXISTS "cameraTransition" TEXT NOT NULL DEFAULT 'smooth',
ADD COLUMN IF NOT EXISTS "showInfoCard" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "infoCardData" JSONB;
UPDATE "AssemblyStep"
SET "partIds" = ARRAY[]::TEXT[]
WHERE "partIds" IS NULL;
ALTER TABLE "AssemblyStep"
ALTER COLUMN "partIds" SET DEFAULT ARRAY[]::TEXT[],
ALTER COLUMN "partIds" SET NOT NULL;
CREATE INDEX IF NOT EXISTS "AssemblyStep_stepType_idx" ON "AssemblyStep"("stepType");
CREATE TABLE IF NOT EXISTS "AssemblyVersion" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"snapshot" JSONB NOT NULL,
"description" TEXT,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssemblyVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "AssemblyVersion_productId_version_key" ON "AssemblyVersion"("productId", "version");
CREATE INDEX IF NOT EXISTS "AssemblyVersion_productId_idx" ON "AssemblyVersion"("productId");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'AssemblyVersion_productId_fkey'
AND table_name = 'AssemblyVersion'
) THEN
ALTER TABLE "AssemblyVersion"
ADD CONSTRAINT "AssemblyVersion_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id")
ON DELETE CASCADE
ON UPDATE CASCADE;
END IF;
END $$;
UPDATE "Panel"
SET
code = upper(btrim(code)),
name = btrim(name),
"gltfNodeName" = btrim("gltfNodeName"),
"partType" = NULLIF(btrim(COALESCE("partType", '')), ''),
"explosionDirection" = NULLIF(lower(btrim(COALESCE("explosionDirection", ''))), '')
WHERE
code IS DISTINCT FROM upper(btrim(code))
OR name IS DISTINCT FROM btrim(name)
OR "gltfNodeName" IS DISTINCT FROM btrim("gltfNodeName")
OR "partType" IS DISTINCT FROM NULLIF(btrim(COALESCE("partType", '')), '')
OR "explosionDirection" IS DISTINCT FROM NULLIF(lower(btrim(COALESCE("explosionDirection", ''))), '');
UPDATE "Hole"
SET
"holeType" = NULLIF(lower(btrim(COALESCE("holeType", ''))), ''),
"groupId" = NULLIF(btrim(COALESCE("groupId", '')), '')
WHERE
"holeType" IS DISTINCT FROM NULLIF(lower(btrim(COALESCE("holeType", ''))), '')
OR "groupId" IS DISTINCT FROM NULLIF(btrim(COALESCE("groupId", '')), '');
UPDATE "ProductHardware"
SET
"matchStatus" = lower(btrim("matchStatus")),
"matchMethod" = NULLIF(btrim(COALESCE("matchMethod", '')), ''),
"sourceBlockName" = NULLIF(btrim(COALESCE("sourceBlockName", '')), ''),
notes = NULLIF(btrim(COALESCE(notes, '')), ''),
"panelId" = NULLIF(btrim(COALESCE("panelId", '')), ''),
"connectedPanelId" = NULLIF(btrim(COALESCE("connectedPanelId", '')), ''),
"associatedHoleId" = NULLIF(btrim(COALESCE("associatedHoleId", '')), ''),
"parentHardwareId" = NULLIF(btrim(COALESCE("parentHardwareId", '')), '')
WHERE
"matchStatus" IS DISTINCT FROM lower(btrim("matchStatus"))
OR "matchMethod" IS DISTINCT FROM NULLIF(btrim(COALESCE("matchMethod", '')), '')
OR "sourceBlockName" IS DISTINCT FROM NULLIF(btrim(COALESCE("sourceBlockName", '')), '')
OR notes IS DISTINCT FROM NULLIF(btrim(COALESCE(notes, '')), '')
OR "panelId" IS DISTINCT FROM NULLIF(btrim(COALESCE("panelId", '')), '')
OR "connectedPanelId" IS DISTINCT FROM NULLIF(btrim(COALESCE("connectedPanelId", '')), '')
OR "associatedHoleId" IS DISTINCT FROM NULLIF(btrim(COALESCE("associatedHoleId", '')), '')
OR "parentHardwareId" IS DISTINCT FROM NULLIF(btrim(COALESCE("parentHardwareId", '')), '');
UPDATE "AssemblyStep"
SET
title = btrim(title),
description = NULLIF(btrim(COALESCE(description, '')), ''),
"stepType" = lower(btrim("stepType")),
"panelId" = NULLIF(btrim(COALESCE("panelId", '')), ''),
easing = btrim(easing),
"cameraTransition" = lower(btrim("cameraTransition"))
WHERE
title IS DISTINCT FROM btrim(title)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR "stepType" IS DISTINCT FROM lower(btrim("stepType"))
OR "panelId" IS DISTINCT FROM NULLIF(btrim(COALESCE("panelId", '')), '')
OR easing IS DISTINCT FROM btrim(easing)
OR "cameraTransition" IS DISTINCT FROM lower(btrim("cameraTransition"));
UPDATE "AssemblyVersion"
SET
description = NULLIF(btrim(COALESCE(description, '')), ''),
"createdBy" = NULLIF(btrim(COALESCE("createdBy", '')), '')
WHERE
description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR "createdBy" IS DISTINCT FROM NULLIF(btrim(COALESCE("createdBy", '')), '');
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_count
FROM "Panel"
WHERE NULLIF(btrim(code), '') IS NULL
OR code <> upper(btrim(code))
OR NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("gltfNodeName"), '') IS NULL
OR "gltfNodeName" <> btrim("gltfNodeName")
OR length <= 0
OR width <= 0
OR thickness <= 0
OR COALESCE("installOrder", 0) < 0
OR ("explosionDirection" IS NOT NULL AND "explosionDirection" NOT IN ('auto', 'up', 'down', 'left', 'right', 'front', 'back'))
OR ("partType" IS NOT NULL AND NULLIF(btrim("partType"), '') IS NULL)
OR ("assemblySemantic" IS NOT NULL AND jsonb_typeof("assemblySemantic") NOT IN ('object', 'null'));
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add Panel guards: % rows have invalid identity, dimensions, or semantic shape.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM (
SELECT "productId", COALESCE("skuId", '') AS sku_scope, upper(btrim(code)) AS normalized_code
FROM "Panel"
GROUP BY "productId", COALESCE("skuId", ''), upper(btrim(code))
HAVING COUNT(*) > 1
) duplicate_panel_codes;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add Panel normalized code unique guard: % product/SKU scopes have duplicate panel codes.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "Hole"
WHERE "positionX" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR "positionY" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR "positionZ" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR diameter <= 0
OR depth < 0
OR "normalX" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR "normalY" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR "normalZ" IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
OR ("normalX" = 0 AND "normalY" = 0 AND "normalZ" = 0)
OR confidence < 0
OR confidence > 1
OR ("holeType" IS NOT NULL AND "holeType" NOT IN ('hinge', 'connector', 'shelf_pin', 'screw', 'dowel', 'cable', 'unknown'))
OR ("groupId" IS NOT NULL AND NULLIF(btrim("groupId"), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add Hole guards: % rows have invalid geometry, type, confidence, or optional text.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ProductHardware"
WHERE jsonb_typeof(transform) <> 'object'
OR "matchStatus" NOT IN ('matched', 'pending', 'unmatched')
OR ("matchConfidence" IS NOT NULL AND ("matchConfidence" < 0 OR "matchConfidence" > 1))
OR ("matchMethod" IS NOT NULL AND "matchMethod" NOT IN ('blockName', 'mapping', 'size', 'manual'))
OR ("installOrder" IS NOT NULL AND "installOrder" < 0)
OR ("holeDistance" IS NOT NULL AND "holeDistance" < 0)
OR ("sourceSignature" IS NOT NULL AND jsonb_typeof("sourceSignature") NOT IN ('object', 'null'))
OR ("sourceBlockName" IS NOT NULL AND NULLIF(btrim("sourceBlockName"), '') IS NULL)
OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL)
OR ("panelId" IS NOT NULL AND NULLIF(btrim("panelId"), '') IS NULL)
OR ("connectedPanelId" IS NOT NULL AND NULLIF(btrim("connectedPanelId"), '') IS NULL)
OR ("associatedHoleId" IS NOT NULL AND NULLIF(btrim("associatedHoleId"), '') IS NULL)
OR ("parentHardwareId" IS NOT NULL AND NULLIF(btrim("parentHardwareId"), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add ProductHardware guards: % rows have invalid matching, transform, relation, or optional text data.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "AssemblyStep"
WHERE "order" < 0
OR NULLIF(btrim(title), '') IS NULL
OR title <> btrim(title)
OR "stepType" NOT IN ('panel', 'hardware', 'custom')
OR duration <= 0
OR NULLIF(btrim(easing), '') IS NULL
OR "cameraTransition" NOT IN ('smooth', 'cut', 'orbit')
OR jsonb_typeof(transform) <> 'object'
OR ("cameraPosition" IS NOT NULL AND jsonb_typeof("cameraPosition") NOT IN ('object', 'null'))
OR ("infoCardData" IS NOT NULL AND jsonb_typeof("infoCardData") NOT IN ('object', 'null'))
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR ("panelId" IS NOT NULL AND NULLIF(btrim("panelId"), '') IS NULL)
OR NOT public.text_array_distinct_non_blank("hardwareIds")
OR NOT public.text_array_distinct_non_blank("partIds");
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add AssemblyStep guards: % rows have invalid order, animation, arrays, or JSON shape.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "AssemblyVersion"
WHERE version <= 0
OR jsonb_typeof(snapshot) <> 'array'
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR ("createdBy" IS NOT NULL AND NULLIF(btrim("createdBy"), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add AssemblyVersion guards: % rows have invalid version, snapshot, or optional text.', invalid_count;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Panel_identity_dimensions_valid') THEN
ALTER TABLE "Panel"
ADD CONSTRAINT "Panel_identity_dimensions_valid"
CHECK (
NULLIF(btrim(code), '') IS NOT NULL
AND code = upper(btrim(code))
AND NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND NULLIF(btrim("gltfNodeName"), '') IS NOT NULL
AND "gltfNodeName" = btrim("gltfNodeName")
AND length > 0
AND width > 0
AND thickness > 0
AND COALESCE("installOrder", 0) >= 0
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Panel_explosionDirection_allowed') THEN
ALTER TABLE "Panel"
ADD CONSTRAINT "Panel_explosionDirection_allowed"
CHECK ("explosionDirection" IS NULL OR "explosionDirection" IN ('auto', 'up', 'down', 'left', 'right', 'front', 'back'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Panel_assemblySemantic_object') THEN
ALTER TABLE "Panel"
ADD CONSTRAINT "Panel_assemblySemantic_object"
CHECK ("assemblySemantic" IS NULL OR jsonb_typeof("assemblySemantic") IN ('object', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Panel_optional_text_not_blank') THEN
ALTER TABLE "Panel"
ADD CONSTRAINT "Panel_optional_text_not_blank"
CHECK ("partType" IS NULL OR NULLIF(btrim("partType"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Hole_geometry_quality') THEN
ALTER TABLE "Hole"
ADD CONSTRAINT "Hole_geometry_quality"
CHECK (
"positionX" NOT IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
AND "positionY" NOT IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
AND "positionZ" NOT IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
AND diameter > 0
AND depth >= 0
AND "normalX" NOT IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
AND "normalY" NOT IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
AND "normalZ" NOT IN ('Infinity'::float8, '-Infinity'::float8, 'NaN'::float8)
AND NOT ("normalX" = 0 AND "normalY" = 0 AND "normalZ" = 0)
AND confidence >= 0
AND confidence <= 1
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Hole_type_allowed') THEN
ALTER TABLE "Hole"
ADD CONSTRAINT "Hole_type_allowed"
CHECK ("holeType" IS NULL OR "holeType" IN ('hinge', 'connector', 'shelf_pin', 'screw', 'dowel', 'cable', 'unknown'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Hole_optional_text_not_blank') THEN
ALTER TABLE "Hole"
ADD CONSTRAINT "Hole_optional_text_not_blank"
CHECK ("groupId" IS NULL OR NULLIF(btrim("groupId"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductHardware_match_status_allowed') THEN
ALTER TABLE "ProductHardware"
ADD CONSTRAINT "ProductHardware_match_status_allowed"
CHECK ("matchStatus" IN ('matched', 'pending', 'unmatched'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductHardware_match_confidence_valid') THEN
ALTER TABLE "ProductHardware"
ADD CONSTRAINT "ProductHardware_match_confidence_valid"
CHECK ("matchConfidence" IS NULL OR ("matchConfidence" >= 0 AND "matchConfidence" <= 1));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductHardware_match_method_allowed') THEN
ALTER TABLE "ProductHardware"
ADD CONSTRAINT "ProductHardware_match_method_allowed"
CHECK ("matchMethod" IS NULL OR "matchMethod" IN ('blockName', 'mapping', 'size', 'manual'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductHardware_quantities_geometry_valid') THEN
ALTER TABLE "ProductHardware"
ADD CONSTRAINT "ProductHardware_quantities_geometry_valid"
CHECK (
("installOrder" IS NULL OR "installOrder" >= 0)
AND ("holeDistance" IS NULL OR "holeDistance" >= 0)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductHardware_transform_object') THEN
ALTER TABLE "ProductHardware"
ADD CONSTRAINT "ProductHardware_transform_object"
CHECK (jsonb_typeof(transform) = 'object');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductHardware_sourceSignature_object') THEN
ALTER TABLE "ProductHardware"
ADD CONSTRAINT "ProductHardware_sourceSignature_object"
CHECK ("sourceSignature" IS NULL OR jsonb_typeof("sourceSignature") IN ('object', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductHardware_optional_text_not_blank') THEN
ALTER TABLE "ProductHardware"
ADD CONSTRAINT "ProductHardware_optional_text_not_blank"
CHECK (
("sourceBlockName" IS NULL OR NULLIF(btrim("sourceBlockName"), '') IS NOT NULL)
AND (notes IS NULL OR NULLIF(btrim(notes), '') IS NOT NULL)
AND ("panelId" IS NULL OR NULLIF(btrim("panelId"), '') IS NOT NULL)
AND ("connectedPanelId" IS NULL OR NULLIF(btrim("connectedPanelId"), '') IS NOT NULL)
AND ("associatedHoleId" IS NULL OR NULLIF(btrim("associatedHoleId"), '') IS NOT NULL)
AND ("parentHardwareId" IS NULL OR NULLIF(btrim("parentHardwareId"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyStep_identity_order_valid') THEN
ALTER TABLE "AssemblyStep"
ADD CONSTRAINT "AssemblyStep_identity_order_valid"
CHECK (
"order" >= 0
AND NULLIF(btrim(title), '') IS NOT NULL
AND title = btrim(title)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyStep_type_allowed') THEN
ALTER TABLE "AssemblyStep"
ADD CONSTRAINT "AssemblyStep_type_allowed"
CHECK ("stepType" IN ('panel', 'hardware', 'custom'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyStep_animation_valid') THEN
ALTER TABLE "AssemblyStep"
ADD CONSTRAINT "AssemblyStep_animation_valid"
CHECK (
duration > 0
AND NULLIF(btrim(easing), '') IS NOT NULL
AND "cameraTransition" IN ('smooth', 'cut', 'orbit')
AND public.text_array_distinct_non_blank("hardwareIds")
AND public.text_array_distinct_non_blank("partIds")
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyStep_transform_object') THEN
ALTER TABLE "AssemblyStep"
ADD CONSTRAINT "AssemblyStep_transform_object"
CHECK (jsonb_typeof(transform) = 'object');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyStep_cameraPosition_object') THEN
ALTER TABLE "AssemblyStep"
ADD CONSTRAINT "AssemblyStep_cameraPosition_object"
CHECK ("cameraPosition" IS NULL OR jsonb_typeof("cameraPosition") IN ('object', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyStep_infoCardData_object') THEN
ALTER TABLE "AssemblyStep"
ADD CONSTRAINT "AssemblyStep_infoCardData_object"
CHECK ("infoCardData" IS NULL OR jsonb_typeof("infoCardData") IN ('object', 'null'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyStep_optional_text_not_blank') THEN
ALTER TABLE "AssemblyStep"
ADD CONSTRAINT "AssemblyStep_optional_text_not_blank"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND ("panelId" IS NULL OR NULLIF(btrim("panelId"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyVersion_snapshot_array') THEN
ALTER TABLE "AssemblyVersion"
ADD CONSTRAINT "AssemblyVersion_snapshot_array"
CHECK (jsonb_typeof(snapshot) = 'array');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyVersion_version_positive') THEN
ALTER TABLE "AssemblyVersion"
ADD CONSTRAINT "AssemblyVersion_version_positive"
CHECK (version > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssemblyVersion_optional_text_not_blank') THEN
ALTER TABLE "AssemblyVersion"
ADD CONSTRAINT "AssemblyVersion_optional_text_not_blank"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND ("createdBy" IS NULL OR NULLIF(btrim("createdBy"), '') IS NOT NULL)
);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "Panel_product_sku_code_normalized_key"
ON "Panel" ("productId", COALESCE("skuId", ''), upper(btrim(code)));

View File

@ -0,0 +1,473 @@
-- Strengthen reference options, mapping memory, location bindings, and legacy packaging.
-- These tables are supporting master data for the new platform and compatibility chain.
-- JSON remains payload/snapshot data only; identity, names, relation keys, counts, and
-- option dictionaries are guarded as structured database truth.
CREATE TABLE IF NOT EXISTS "HardwareMapping" (
"id" TEXT NOT NULL,
"blockName" TEXT,
"signatureHash" TEXT,
"hardwareId" TEXT NOT NULL,
"createdBy" TEXT NOT NULL DEFAULT 'manual',
"usageCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HardwareMapping_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "HardwareMapping_blockName_key" ON "HardwareMapping"("blockName");
CREATE UNIQUE INDEX IF NOT EXISTS "HardwareMapping_signatureHash_key" ON "HardwareMapping"("signatureHash");
CREATE INDEX IF NOT EXISTS "HardwareMapping_hardwareId_idx" ON "HardwareMapping"("hardwareId");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'HardwareMapping_hardwareId_fkey'
AND table_name = 'HardwareMapping'
) THEN
ALTER TABLE "HardwareMapping"
ADD CONSTRAINT "HardwareMapping_hardwareId_fkey"
FOREIGN KEY ("hardwareId") REFERENCES "Hardware"("id")
ON DELETE RESTRICT
ON UPDATE CASCADE;
END IF;
END $$;
CREATE TABLE IF NOT EXISTS "PackagingPlan" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"boxCount" INTEGER NOT NULL DEFAULT 1,
"boxes" JSONB NOT NULL DEFAULT '[]',
"totalWeight" DOUBLE PRECISION,
"totalVolume" DOUBLE PRECISION,
"notes" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PackagingPlan_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "PackagingPlan_productId_idx" ON "PackagingPlan"("productId");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'PackagingPlan_productId_fkey'
AND table_name = 'PackagingPlan'
) THEN
ALTER TABLE "PackagingPlan"
ADD CONSTRAINT "PackagingPlan_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id")
ON DELETE CASCADE
ON UPDATE CASCADE;
END IF;
END $$;
UPDATE "ProductCategory"
SET
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), ''),
icon = NULLIF(btrim(COALESCE(icon, '')), '')
WHERE
name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR icon IS DISTINCT FROM NULLIF(btrim(COALESCE(icon, '')), '');
UPDATE "MaterialCategory"
SET
name = btrim(name),
icon = NULLIF(btrim(COALESCE(icon, '')), '')
WHERE
name IS DISTINCT FROM btrim(name)
OR icon IS DISTINCT FROM NULLIF(btrim(COALESCE(icon, '')), '');
UPDATE "HardwareCategory"
SET
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), ''),
icon = NULLIF(btrim(COALESCE(icon, '')), '')
WHERE
name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR icon IS DISTINCT FROM NULLIF(btrim(COALESCE(icon, '')), '');
UPDATE "CommonComponentCategory"
SET
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), ''),
icon = NULLIF(btrim(COALESCE(icon, '')), '')
WHERE
name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR icon IS DISTINCT FROM NULLIF(btrim(COALESCE(icon, '')), '');
UPDATE "SubstrateCategory" SET name = btrim(name) WHERE name IS DISTINCT FROM btrim(name);
UPDATE "SubstrateEnvironmentGrade" SET name = btrim(name) WHERE name IS DISTINCT FROM btrim(name);
UPDATE "SubstrateSurfaceOption" SET name = btrim(name) WHERE name IS DISTINCT FROM btrim(name);
UPDATE "SubstrateSystemNameOption" SET name = btrim(name) WHERE name IS DISTINCT FROM btrim(name);
UPDATE "SubstrateThicknessSpec" SET name = btrim(name) WHERE name IS DISTINCT FROM btrim(name);
UPDATE "HardwareMapping"
SET
"blockName" = NULLIF(btrim(COALESCE("blockName", '')), ''),
"signatureHash" = NULLIF(btrim(COALESCE("signatureHash", '')), ''),
"createdBy" = btrim("createdBy")
WHERE
"blockName" IS DISTINCT FROM NULLIF(btrim(COALESCE("blockName", '')), '')
OR "signatureHash" IS DISTINCT FROM NULLIF(btrim(COALESCE("signatureHash", '')), '')
OR "createdBy" IS DISTINCT FROM btrim("createdBy");
UPDATE "PackagingPlan"
SET
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), ''),
notes = NULLIF(btrim(COALESCE(notes, '')), '')
WHERE
name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR notes IS DISTINCT FROM NULLIF(btrim(COALESCE(notes, '')), '');
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT SUM(row_count)::integer
INTO invalid_count
FROM (
SELECT COUNT(*)::integer AS row_count FROM "ProductCategory"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR (icon IS NOT NULL AND NULLIF(btrim(icon), '') IS NULL)
UNION ALL
SELECT COUNT(*)::integer FROM "MaterialCategory"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
OR (icon IS NOT NULL AND NULLIF(btrim(icon), '') IS NULL)
UNION ALL
SELECT COUNT(*)::integer FROM "HardwareCategory"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR (icon IS NOT NULL AND NULLIF(btrim(icon), '') IS NULL)
UNION ALL
SELECT COUNT(*)::integer FROM "CommonComponentCategory"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR (icon IS NOT NULL AND NULLIF(btrim(icon), '') IS NULL)
UNION ALL
SELECT COUNT(*)::integer FROM "SubstrateCategory"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
UNION ALL
SELECT COUNT(*)::integer FROM "SubstrateEnvironmentGrade"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
UNION ALL
SELECT COUNT(*)::integer FROM "SubstrateSurfaceOption"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
UNION ALL
SELECT COUNT(*)::integer FROM "SubstrateSystemNameOption"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
UNION ALL
SELECT COUNT(*)::integer FROM "SubstrateThicknessSpec"
WHERE NULLIF(btrim(name), '') IS NULL OR name <> btrim(name) OR "sortOrder" < 0
OR "thicknessMm" <= 0 OR "sheetLengthMm" <= 0 OR "sheetWidthMm" <= 0
) option_risks;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add reference option guards: % rows have invalid names, sort order, optional text, or dimensions.', invalid_count;
END IF;
SELECT SUM(row_count)::integer
INTO invalid_count
FROM (
SELECT COUNT(*)::integer AS row_count FROM (
SELECT lower(btrim(name)) AS normalized_name FROM "ProductCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
UNION ALL SELECT COUNT(*)::integer FROM (
SELECT lower(btrim(name)) FROM "MaterialCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
UNION ALL SELECT COUNT(*)::integer FROM (
SELECT lower(btrim(name)) FROM "HardwareCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
UNION ALL SELECT COUNT(*)::integer FROM (
SELECT lower(btrim(name)) FROM "CommonComponentCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
UNION ALL SELECT COUNT(*)::integer FROM (
SELECT lower(btrim(name)) FROM "SubstrateCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
UNION ALL SELECT COUNT(*)::integer FROM (
SELECT lower(btrim(name)) FROM "SubstrateEnvironmentGrade" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
UNION ALL SELECT COUNT(*)::integer FROM (
SELECT lower(btrim(name)) FROM "SubstrateSurfaceOption" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
UNION ALL SELECT COUNT(*)::integer FROM (
SELECT lower(btrim(name)) FROM "SubstrateSystemNameOption" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
UNION ALL SELECT COUNT(*)::integer FROM (
SELECT lower(btrim(name)) FROM "SubstrateThicknessSpec" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1
) duplicates
) duplicate_risks;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add normalized reference option unique guards: % normalized names are duplicated.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "HardwareMapping"
WHERE (
CASE WHEN NULLIF(btrim(COALESCE("blockName", '')), '') IS NOT NULL THEN 1 ELSE 0 END
+ CASE WHEN NULLIF(btrim(COALESCE("signatureHash", '')), '') IS NOT NULL THEN 1 ELSE 0 END
) <> 1
OR "usageCount" < 0
OR NULLIF(btrim("createdBy"), '') IS NULL
OR "createdBy" <> btrim("createdBy");
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add HardwareMapping guards: % rows have invalid match key, usage count, or source.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "SubstrateFinish"
WHERE NULLIF(btrim("substrateId"), '') IS NULL
OR NULLIF(btrim("finishId"), '') IS NULL;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add SubstrateFinish guards: % rows have blank relation ids.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "PanelMaterial"
WHERE NULLIF(btrim("panelId"), '') IS NULL
OR NULLIF(btrim("materialId"), '') IS NULL;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add PanelMaterial guards: % rows have blank relation ids.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "AssetVariantLocationBinding"
WHERE NULLIF(btrim("assetVariantId"), '') IS NULL
OR NULLIF(btrim("warehouseLocationId"), '') IS NULL;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add AssetVariantLocationBinding guards: % rows have blank relation ids.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "PreSkuLocationBinding"
WHERE NULLIF(btrim("preSkuId"), '') IS NULL
OR NULLIF(btrim("warehouseLocationId"), '') IS NULL;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add PreSkuLocationBinding guards: % rows have blank relation ids.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "PackagingPlan"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR "boxCount" <= 0
OR jsonb_typeof(boxes) <> 'array'
OR ("totalWeight" IS NOT NULL AND "totalWeight" < 0)
OR ("totalVolume" IS NOT NULL AND "totalVolume" < 0)
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add PackagingPlan guards: % rows have invalid name, counts, totals, JSON shape, or optional text.', invalid_count;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductCategory_identity_valid') THEN
ALTER TABLE "ProductCategory"
ADD CONSTRAINT "ProductCategory_identity_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "sortOrder" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProductCategory_optional_text_not_blank') THEN
ALTER TABLE "ProductCategory"
ADD CONSTRAINT "ProductCategory_optional_text_not_blank"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND (icon IS NULL OR NULLIF(btrim(icon), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'MaterialCategory_identity_valid') THEN
ALTER TABLE "MaterialCategory"
ADD CONSTRAINT "MaterialCategory_identity_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "sortOrder" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'MaterialCategory_optional_text_not_blank') THEN
ALTER TABLE "MaterialCategory"
ADD CONSTRAINT "MaterialCategory_optional_text_not_blank"
CHECK (icon IS NULL OR NULLIF(btrim(icon), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'HardwareCategory_identity_valid') THEN
ALTER TABLE "HardwareCategory"
ADD CONSTRAINT "HardwareCategory_identity_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "sortOrder" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'HardwareCategory_optional_text_not_blank') THEN
ALTER TABLE "HardwareCategory"
ADD CONSTRAINT "HardwareCategory_optional_text_not_blank"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND (icon IS NULL OR NULLIF(btrim(icon), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CommonComponentCategory_identity_valid') THEN
ALTER TABLE "CommonComponentCategory"
ADD CONSTRAINT "CommonComponentCategory_identity_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "sortOrder" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CommonComponentCategory_optional_text_not_blank') THEN
ALTER TABLE "CommonComponentCategory"
ADD CONSTRAINT "CommonComponentCategory_optional_text_not_blank"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND (icon IS NULL OR NULLIF(btrim(icon), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'SubstrateCategory_identity_valid') THEN
ALTER TABLE "SubstrateCategory"
ADD CONSTRAINT "SubstrateCategory_identity_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "sortOrder" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'SubstrateEnvironmentGrade_identity_valid') THEN
ALTER TABLE "SubstrateEnvironmentGrade"
ADD CONSTRAINT "SubstrateEnvironmentGrade_identity_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "sortOrder" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'SubstrateSurfaceOption_identity_valid') THEN
ALTER TABLE "SubstrateSurfaceOption"
ADD CONSTRAINT "SubstrateSurfaceOption_identity_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "sortOrder" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'SubstrateSystemNameOption_identity_valid') THEN
ALTER TABLE "SubstrateSystemNameOption"
ADD CONSTRAINT "SubstrateSystemNameOption_identity_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "sortOrder" >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'SubstrateThicknessSpec_identity_dimensions_valid') THEN
ALTER TABLE "SubstrateThicknessSpec"
ADD CONSTRAINT "SubstrateThicknessSpec_identity_dimensions_valid"
CHECK (
NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND "sortOrder" >= 0
AND "thicknessMm" > 0
AND "sheetLengthMm" > 0
AND "sheetWidthMm" > 0
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'HardwareMapping_match_key_exactly_one') THEN
ALTER TABLE "HardwareMapping"
ADD CONSTRAINT "HardwareMapping_match_key_exactly_one"
CHECK (
(
CASE WHEN NULLIF(btrim(COALESCE("blockName", '')), '') IS NOT NULL THEN 1 ELSE 0 END
+ CASE WHEN NULLIF(btrim(COALESCE("signatureHash", '')), '') IS NOT NULL THEN 1 ELSE 0 END
) = 1
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'HardwareMapping_usage_source_valid') THEN
ALTER TABLE "HardwareMapping"
ADD CONSTRAINT "HardwareMapping_usage_source_valid"
CHECK ("usageCount" >= 0 AND NULLIF(btrim("createdBy"), '') IS NOT NULL AND "createdBy" = btrim("createdBy"));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'SubstrateFinish_relation_ids_not_blank') THEN
ALTER TABLE "SubstrateFinish"
ADD CONSTRAINT "SubstrateFinish_relation_ids_not_blank"
CHECK (NULLIF(btrim("substrateId"), '') IS NOT NULL AND NULLIF(btrim("finishId"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PanelMaterial_relation_ids_not_blank') THEN
ALTER TABLE "PanelMaterial"
ADD CONSTRAINT "PanelMaterial_relation_ids_not_blank"
CHECK (NULLIF(btrim("panelId"), '') IS NOT NULL AND NULLIF(btrim("materialId"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssetVariantLocationBinding_relation_ids_not_blank') THEN
ALTER TABLE "AssetVariantLocationBinding"
ADD CONSTRAINT "AssetVariantLocationBinding_relation_ids_not_blank"
CHECK (NULLIF(btrim("assetVariantId"), '') IS NOT NULL AND NULLIF(btrim("warehouseLocationId"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PreSkuLocationBinding_relation_ids_not_blank') THEN
ALTER TABLE "PreSkuLocationBinding"
ADD CONSTRAINT "PreSkuLocationBinding_relation_ids_not_blank"
CHECK (NULLIF(btrim("preSkuId"), '') IS NOT NULL AND NULLIF(btrim("warehouseLocationId"), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PackagingPlan_identity_counts_valid') THEN
ALTER TABLE "PackagingPlan"
ADD CONSTRAINT "PackagingPlan_identity_counts_valid"
CHECK (NULLIF(btrim(name), '') IS NOT NULL AND name = btrim(name) AND "boxCount" > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PackagingPlan_boxes_array') THEN
ALTER TABLE "PackagingPlan"
ADD CONSTRAINT "PackagingPlan_boxes_array"
CHECK (jsonb_typeof(boxes) = 'array');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PackagingPlan_totals_valid') THEN
ALTER TABLE "PackagingPlan"
ADD CONSTRAINT "PackagingPlan_totals_valid"
CHECK (
("totalWeight" IS NULL OR "totalWeight" >= 0)
AND ("totalVolume" IS NULL OR "totalVolume" >= 0)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'PackagingPlan_optional_text_not_blank') THEN
ALTER TABLE "PackagingPlan"
ADD CONSTRAINT "PackagingPlan_optional_text_not_blank"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND (notes IS NULL OR NULLIF(btrim(notes), '') IS NOT NULL)
);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "ProductCategory_name_normalized_key"
ON "ProductCategory" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "MaterialCategory_name_normalized_key"
ON "MaterialCategory" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "HardwareCategory_name_normalized_key"
ON "HardwareCategory" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "CommonComponentCategory_name_normalized_key"
ON "CommonComponentCategory" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "SubstrateCategory_name_normalized_key"
ON "SubstrateCategory" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "SubstrateEnvironmentGrade_name_normalized_key"
ON "SubstrateEnvironmentGrade" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "SubstrateSurfaceOption_name_normalized_key"
ON "SubstrateSurfaceOption" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "SubstrateSystemNameOption_name_normalized_key"
ON "SubstrateSystemNameOption" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "SubstrateThicknessSpec_name_normalized_key"
ON "SubstrateThicknessSpec" (lower(btrim(name)));

View File

@ -0,0 +1,469 @@
-- Strengthen configuration, permission override, AI render, feedback, and audit-log foundations.
-- These tables are not source product master data, but they still carry system truth:
-- configuration identity, permission override identity, process snapshots, and audit trails.
UPDATE "SystemConfig"
SET
key = btrim(key),
description = NULLIF(btrim(COALESCE(description, '')), '')
WHERE
key IS DISTINCT FROM btrim(key)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '');
UPDATE "ProviderConfig"
SET
"serviceType" = btrim("serviceType"),
name = btrim(name),
"baseUrl" = regexp_replace(btrim("baseUrl"), '/+$', ''),
"apiKey" = btrim("apiKey"),
model = NULLIF(btrim(COALESCE(model, '')), '')
WHERE
"serviceType" IS DISTINCT FROM btrim("serviceType")
OR name IS DISTINCT FROM btrim(name)
OR "baseUrl" IS DISTINCT FROM regexp_replace(btrim("baseUrl"), '/+$', '')
OR "apiKey" IS DISTINCT FROM btrim("apiKey")
OR model IS DISTINCT FROM NULLIF(btrim(COALESCE(model, '')), '');
UPDATE "ThemeConfig"
SET
name = btrim(name),
description = NULLIF(btrim(COALESCE(description, '')), ''),
"createdBy" = btrim("createdBy")
WHERE
name IS DISTINCT FROM btrim(name)
OR description IS DISTINCT FROM NULLIF(btrim(COALESCE(description, '')), '')
OR "createdBy" IS DISTINCT FROM btrim("createdBy");
UPDATE "IntakeNodePermissionOverride"
SET
"targetKey" = btrim("targetKey"),
"subjectKey" = btrim("subjectKey"),
action = btrim(action),
"createdBy" = NULLIF(btrim(COALESCE("createdBy", '')), ''),
"updatedBy" = NULLIF(btrim(COALESCE("updatedBy", '')), '')
WHERE
"targetKey" IS DISTINCT FROM btrim("targetKey")
OR "subjectKey" IS DISTINCT FROM btrim("subjectKey")
OR action IS DISTINCT FROM btrim(action)
OR "createdBy" IS DISTINCT FROM NULLIF(btrim(COALESCE("createdBy", '')), '')
OR "updatedBy" IS DISTINCT FROM NULLIF(btrim(COALESCE("updatedBy", '')), '');
UPDATE "AssistedAssemblyAiRender"
SET
"createdBy" = btrim("createdBy"),
prompt = btrim(prompt),
"negativePrompt" = NULLIF(btrim(COALESCE("negativePrompt", '')), ''),
"referenceImageUrl" = NULLIF(btrim(COALESCE("referenceImageUrl", '')), ''),
"externalRequestId" = NULLIF(btrim(COALESCE("externalRequestId", '')), ''),
"errorMessage" = NULLIF(btrim(COALESCE("errorMessage", '')), '')
WHERE
"createdBy" IS DISTINCT FROM btrim("createdBy")
OR prompt IS DISTINCT FROM btrim(prompt)
OR "negativePrompt" IS DISTINCT FROM NULLIF(btrim(COALESCE("negativePrompt", '')), '')
OR "referenceImageUrl" IS DISTINCT FROM NULLIF(btrim(COALESCE("referenceImageUrl", '')), '')
OR "externalRequestId" IS DISTINCT FROM NULLIF(btrim(COALESCE("externalRequestId", '')), '')
OR "errorMessage" IS DISTINCT FROM NULLIF(btrim(COALESCE("errorMessage", '')), '');
UPDATE "Feedback"
SET
title = btrim(title),
description = btrim(description),
category = btrim(category),
resolution = NULLIF(btrim(COALESCE(resolution, '')), ''),
images = ARRAY(
SELECT btrim(image)
FROM unnest(images) AS image
WHERE NULLIF(btrim(image), '') IS NOT NULL
)
WHERE
title IS DISTINCT FROM btrim(title)
OR description IS DISTINCT FROM btrim(description)
OR category IS DISTINCT FROM btrim(category)
OR resolution IS DISTINCT FROM NULLIF(btrim(COALESCE(resolution, '')), '')
OR EXISTS (SELECT 1 FROM unnest(images) AS image WHERE image IS DISTINCT FROM btrim(image) OR NULLIF(btrim(image), '') IS NULL);
UPDATE "ActivityLog"
SET
"userId" = btrim("userId"),
"targetType" = NULLIF(btrim(COALESCE("targetType", '')), ''),
"targetId" = NULLIF(btrim(COALESCE("targetId", '')), ''),
action = btrim(action)
WHERE
"userId" IS DISTINCT FROM btrim("userId")
OR "targetType" IS DISTINCT FROM NULLIF(btrim(COALESCE("targetType", '')), '')
OR "targetId" IS DISTINCT FROM NULLIF(btrim(COALESCE("targetId", '')), '')
OR action IS DISTINCT FROM btrim(action);
CREATE OR REPLACE FUNCTION public.text_array_all_trimmed_non_blank(items text[])
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $function$
SELECT COALESCE(
bool_and(NULLIF(btrim(value), '') IS NOT NULL AND value = btrim(value)),
true
)
FROM unnest(items) AS value;
$function$;
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_count
FROM "SystemConfig"
WHERE NULLIF(btrim(key), '') IS NULL
OR key <> btrim(key)
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add SystemConfig guards: % rows have invalid key or description.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ProviderConfig"
WHERE NULLIF(btrim("serviceType"), '') IS NULL
OR "serviceType" <> btrim("serviceType")
OR NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("baseUrl"), '') IS NULL
OR "baseUrl" <> regexp_replace(btrim("baseUrl"), '/+$', '')
OR NULLIF(btrim("apiKey"), '') IS NULL
OR "apiKey" <> btrim("apiKey")
OR "sortOrder" < 0
OR (model IS NOT NULL AND NULLIF(btrim(model), '') IS NULL)
OR (extra IS NOT NULL AND jsonb_typeof(extra) <> 'object');
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add ProviderConfig guards: % rows have invalid identity, ordering, or JSON shape.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM (
SELECT "serviceType"
FROM "ProviderConfig"
WHERE "isActive" = true
GROUP BY "serviceType"
HAVING COUNT(*) > 1
) duplicated_active;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add ProviderConfig active guard: % service types have more than one active config.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ThemeConfig"
WHERE NULLIF(btrim(name), '') IS NULL
OR name <> btrim(name)
OR NULLIF(btrim("createdBy"), '') IS NULL
OR "createdBy" <> btrim("createdBy")
OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL)
OR ("startDate" IS NOT NULL AND "endDate" IS NOT NULL AND "startDate" >= "endDate")
OR ("homeBackground" IS NOT NULL AND jsonb_typeof("homeBackground") <> 'object')
OR ("loginPanel" IS NOT NULL AND jsonb_typeof("loginPanel") <> 'object');
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add ThemeConfig guards: % rows have invalid identity, time window, or JSON shape.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM (
SELECT lower(btrim(name)) AS normalized_name
FROM "ThemeConfig"
GROUP BY lower(btrim(name))
HAVING COUNT(*) > 1
) duplicated_theme_names;
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add ThemeConfig normalized name guard: % normalized names are duplicated.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ThemeConfig"
WHERE "isDefault" = true;
IF invalid_count > 1 THEN
RAISE EXCEPTION 'Cannot add ThemeConfig default guard: % default themes exist.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "IntakeNodePermissionOverride"
WHERE NULLIF(btrim("targetKey"), '') IS NULL
OR "targetKey" <> btrim("targetKey")
OR NULLIF(btrim("subjectKey"), '') IS NULL
OR "subjectKey" <> btrim("subjectKey")
OR NULLIF(btrim(action), '') IS NULL
OR action <> btrim(action)
OR ("createdBy" IS NOT NULL AND NULLIF(btrim("createdBy"), '') IS NULL)
OR ("updatedBy" IS NOT NULL AND NULLIF(btrim("updatedBy"), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add IntakeNodePermissionOverride guards: % rows have invalid identity or audit fields.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "AssistedAssemblyAiRender"
WHERE NULLIF(btrim("createdBy"), '') IS NULL
OR "createdBy" <> btrim("createdBy")
OR NULLIF(btrim(prompt), '') IS NULL
OR prompt <> btrim(prompt)
OR jsonb_typeof(params) <> 'object'
OR jsonb_typeof("resultImages") <> 'array'
OR jsonb_typeof("workspaceSnapshot") <> 'object'
OR jsonb_typeof("assetSnapshot") <> 'object'
OR ("attempts" IS NOT NULL AND jsonb_typeof("attempts") <> 'array')
OR ("durationMs" IS NOT NULL AND "durationMs" < 0)
OR ("negativePrompt" IS NOT NULL AND NULLIF(btrim("negativePrompt"), '') IS NULL)
OR ("referenceImageUrl" IS NOT NULL AND NULLIF(btrim("referenceImageUrl"), '') IS NULL)
OR ("externalRequestId" IS NOT NULL AND NULLIF(btrim("externalRequestId"), '') IS NULL)
OR ("errorMessage" IS NOT NULL AND NULLIF(btrim("errorMessage"), '') IS NULL)
OR (status = 'SUCCEEDED' AND jsonb_array_length("resultImages") = 0)
OR (status = 'FAILED' AND NULLIF(btrim(COALESCE("errorMessage", '')), '') IS NULL);
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add AssistedAssemblyAiRender guards: % rows have invalid identity, JSON shape, status, or duration.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "Feedback"
WHERE NULLIF(btrim(title), '') IS NULL
OR title <> btrim(title)
OR NULLIF(btrim(description), '') IS NULL
OR description <> btrim(description)
OR NULLIF(btrim(category), '') IS NULL
OR category <> btrim(category)
OR EXISTS (SELECT 1 FROM unnest(images) AS image WHERE NULLIF(btrim(image), '') IS NULL OR image <> btrim(image))
OR ("comments" IS NOT NULL AND jsonb_typeof("comments") <> 'array')
OR (resolution IS NOT NULL AND NULLIF(btrim(resolution), '') IS NULL)
OR (status = 'RESOLVED' AND ("resolvedAt" IS NULL OR NULLIF(btrim(COALESCE(resolution, '')), '') IS NULL));
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add Feedback guards: % rows have invalid identity, comments, images, or resolution state.', invalid_count;
END IF;
SELECT COUNT(*)::integer
INTO invalid_count
FROM "ActivityLog"
WHERE NULLIF(btrim("userId"), '') IS NULL
OR "userId" <> btrim("userId")
OR NULLIF(btrim(action), '') IS NULL
OR action <> btrim(action)
OR (("targetType" IS NULL) <> ("targetId" IS NULL))
OR ("targetType" IS NOT NULL AND NULLIF(btrim("targetType"), '') IS NULL)
OR ("targetId" IS NOT NULL AND NULLIF(btrim("targetId"), '') IS NULL)
OR ("changes" IS NOT NULL AND jsonb_typeof("changes") <> 'object')
OR ("metadata" IS NOT NULL AND jsonb_typeof("metadata") <> 'object');
IF invalid_count > 0 THEN
RAISE EXCEPTION 'Cannot add ActivityLog guards: % rows have invalid identity, target pair, or JSON shape.', invalid_count;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'SystemConfig_key_valid') THEN
ALTER TABLE "SystemConfig"
ADD CONSTRAINT "SystemConfig_key_valid"
CHECK (NULLIF(btrim(key), '') IS NOT NULL AND key = btrim(key));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'SystemConfig_description_not_blank') THEN
ALTER TABLE "SystemConfig"
ADD CONSTRAINT "SystemConfig_description_not_blank"
CHECK (description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProviderConfig_identity_valid') THEN
ALTER TABLE "ProviderConfig"
ADD CONSTRAINT "ProviderConfig_identity_valid"
CHECK (
NULLIF(btrim("serviceType"), '') IS NOT NULL
AND "serviceType" = btrim("serviceType")
AND NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND NULLIF(btrim("baseUrl"), '') IS NOT NULL
AND "baseUrl" = regexp_replace(btrim("baseUrl"), '/+$', '')
AND NULLIF(btrim("apiKey"), '') IS NOT NULL
AND "apiKey" = btrim("apiKey")
AND "sortOrder" >= 0
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ProviderConfig_optional_fields_valid') THEN
ALTER TABLE "ProviderConfig"
ADD CONSTRAINT "ProviderConfig_optional_fields_valid"
CHECK (
(model IS NULL OR NULLIF(btrim(model), '') IS NOT NULL)
AND (extra IS NULL OR jsonb_typeof(extra) = 'object')
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ThemeConfig_identity_valid') THEN
ALTER TABLE "ThemeConfig"
ADD CONSTRAINT "ThemeConfig_identity_valid"
CHECK (
NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND NULLIF(btrim("createdBy"), '') IS NOT NULL
AND "createdBy" = btrim("createdBy")
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ThemeConfig_optional_config_valid') THEN
ALTER TABLE "ThemeConfig"
ADD CONSTRAINT "ThemeConfig_optional_config_valid"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND ("startDate" IS NULL OR "endDate" IS NULL OR "startDate" < "endDate")
AND ("homeBackground" IS NULL OR jsonb_typeof("homeBackground") = 'object')
AND ("loginPanel" IS NULL OR jsonb_typeof("loginPanel") = 'object')
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'IntakeNodePermissionOverride_identity_valid') THEN
ALTER TABLE "IntakeNodePermissionOverride"
ADD CONSTRAINT "IntakeNodePermissionOverride_identity_valid"
CHECK (
NULLIF(btrim("targetKey"), '') IS NOT NULL
AND "targetKey" = btrim("targetKey")
AND NULLIF(btrim("subjectKey"), '') IS NOT NULL
AND "subjectKey" = btrim("subjectKey")
AND NULLIF(btrim(action), '') IS NOT NULL
AND action = btrim(action)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'IntakeNodePermissionOverride_audit_text_not_blank') THEN
ALTER TABLE "IntakeNodePermissionOverride"
ADD CONSTRAINT "IntakeNodePermissionOverride_audit_text_not_blank"
CHECK (
("createdBy" IS NULL OR NULLIF(btrim("createdBy"), '') IS NOT NULL)
AND ("updatedBy" IS NULL OR NULLIF(btrim("updatedBy"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssistedAssemblyAiRender_identity_valid') THEN
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_identity_valid"
CHECK (
NULLIF(btrim("createdBy"), '') IS NOT NULL
AND "createdBy" = btrim("createdBy")
AND NULLIF(btrim(prompt), '') IS NOT NULL
AND prompt = btrim(prompt)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssistedAssemblyAiRender_json_shape_valid') THEN
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_json_shape_valid"
CHECK (
jsonb_typeof(params) = 'object'
AND jsonb_typeof("resultImages") = 'array'
AND jsonb_typeof("workspaceSnapshot") = 'object'
AND jsonb_typeof("assetSnapshot") = 'object'
AND ("attempts" IS NULL OR jsonb_typeof("attempts") = 'array')
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssistedAssemblyAiRender_status_duration_valid') THEN
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_status_duration_valid"
CHECK (
("durationMs" IS NULL OR "durationMs" >= 0)
AND (status <> 'SUCCEEDED' OR jsonb_array_length("resultImages") > 0)
AND (status <> 'FAILED' OR NULLIF(btrim(COALESCE("errorMessage", '')), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AssistedAssemblyAiRender_optional_text_not_blank') THEN
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_optional_text_not_blank"
CHECK (
("negativePrompt" IS NULL OR NULLIF(btrim("negativePrompt"), '') IS NOT NULL)
AND ("referenceImageUrl" IS NULL OR NULLIF(btrim("referenceImageUrl"), '') IS NOT NULL)
AND ("externalRequestId" IS NULL OR NULLIF(btrim("externalRequestId"), '') IS NOT NULL)
AND ("errorMessage" IS NULL OR NULLIF(btrim("errorMessage"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Feedback_identity_valid') THEN
ALTER TABLE "Feedback"
ADD CONSTRAINT "Feedback_identity_valid"
CHECK (
NULLIF(btrim(title), '') IS NOT NULL
AND title = btrim(title)
AND NULLIF(btrim(description), '') IS NOT NULL
AND description = btrim(description)
AND NULLIF(btrim(category), '') IS NOT NULL
AND category = btrim(category)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Feedback_images_valid') THEN
ALTER TABLE "Feedback"
ADD CONSTRAINT "Feedback_images_valid"
CHECK (public.text_array_all_trimmed_non_blank(images));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Feedback_comments_array') THEN
ALTER TABLE "Feedback"
ADD CONSTRAINT "Feedback_comments_array"
CHECK ("comments" IS NULL OR jsonb_typeof("comments") = 'array');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'Feedback_resolution_state_valid') THEN
ALTER TABLE "Feedback"
ADD CONSTRAINT "Feedback_resolution_state_valid"
CHECK (
(resolution IS NULL OR NULLIF(btrim(resolution), '') IS NOT NULL)
AND (status <> 'RESOLVED' OR ("resolvedAt" IS NOT NULL AND NULLIF(btrim(COALESCE(resolution, '')), '') IS NOT NULL))
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ActivityLog_identity_valid') THEN
ALTER TABLE "ActivityLog"
ADD CONSTRAINT "ActivityLog_identity_valid"
CHECK (
NULLIF(btrim("userId"), '') IS NOT NULL
AND "userId" = btrim("userId")
AND NULLIF(btrim(action), '') IS NOT NULL
AND action = btrim(action)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ActivityLog_target_pair_valid') THEN
ALTER TABLE "ActivityLog"
ADD CONSTRAINT "ActivityLog_target_pair_valid"
CHECK (
("targetType" IS NULL) = ("targetId" IS NULL)
AND ("targetType" IS NULL OR NULLIF(btrim("targetType"), '') IS NOT NULL)
AND ("targetId" IS NULL OR NULLIF(btrim("targetId"), '') IS NOT NULL)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ActivityLog_json_shape_valid') THEN
ALTER TABLE "ActivityLog"
ADD CONSTRAINT "ActivityLog_json_shape_valid"
CHECK (
("changes" IS NULL OR jsonb_typeof("changes") = 'object')
AND ("metadata" IS NULL OR jsonb_typeof("metadata") = 'object')
);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "ProviderConfig_active_service_key"
ON "ProviderConfig" ("serviceType")
WHERE "isActive" = true;
CREATE UNIQUE INDEX IF NOT EXISTS "ThemeConfig_name_normalized_key"
ON "ThemeConfig" (lower(btrim(name)));
CREATE UNIQUE INDEX IF NOT EXISTS "ThemeConfig_default_key"
ON "ThemeConfig" ("isDefault")
WHERE "isDefault" = true;

View File

@ -0,0 +1,104 @@
-- Add explicit stable business keys to external intake views.
-- Technical row ids remain visible for tracing, but external systems should
-- anchor synchronization on these normalized business keys.
CREATE OR REPLACE VIEW external.intake_products_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."spuBase" AS spu_base,
product."brand" AS brand,
profile."productName" AS product_name,
profile."productImageUrl" AS product_image_url,
profile."productCategory" AS product_category,
profile."salesCategory" AS sales_category,
profile."productYear" AS product_year,
profile."productStatus" AS product_status,
COALESCE(NULLIF(profile."structureDesigner", ''), NULLIF(product."designer", '')) AS designer,
COALESCE(NULLIF(profile."productPlanning", ''), NULLIF(product."operator", '')) AS operator,
COALESCE(NULLIF(profile."purchaseOwner", ''), NULLIF(product."purchaser", '')) AS purchaser,
profile."productDevelopmentKind" AS product_development_kind,
profile."goodsType" AS goods_type,
profile."materialType" AS material_type,
profile."productionType" AS production_type,
product."createdAt" AS created_at,
product."updatedAt" AS updated_at,
profile."productPlanning" AS product_planning,
profile."visualPlanning" AS visual_planning,
profile."structureDesigner" AS structure_designer,
profile."purchaseOwner" AS purchase_owner,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu"))
ELSE NULL
END AS product_business_key
FROM "IntakeNonIntegratedProduct" product
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_products_ready_v1 AS
SELECT *
FROM external.intake_products_v1
WHERE NULLIF(btrim(COALESCE(product_name, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_category, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_development_kind, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NOT NULL;
CREATE OR REPLACE VIEW external.intake_sku_items_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."brand" AS brand,
sku."skuItemId" AS intake_sku_item_id,
sku."skuSequence" AS sku_sequence,
sku."batchNo" AS batch_no,
sku."image" AS image_url,
sku."imageName" AS image_name,
sku."model" AS model,
sku."skuCode" AS sku_code,
sku."specName" AS spec_name,
sku."barcode" AS barcode,
sku."size" AS size,
sku."color" AS color,
sku."material" AS material,
sku."versionNo" AS version_no,
sku."attributeCode" AS attribute_code,
sku."drawingId" AS drawing_id,
sku."installMethod" AS install_method,
sku."executeStandard" AS execute_standard,
sku."manufacturer" AS manufacturer,
sku."manufacturerCode" AS manufacturer_code,
sku."salesStatus" AS sales_status,
sku."model3dDataUrl" AS model_3d_url,
sku."source" AS source,
sku."sourceTable" AS source_table,
sku."sourceSkuId" AS source_sku_id,
sku."missingRequiredFields" AS missing_required_fields,
sku."dataQualityStatus"::text AS data_quality_status,
sku."contentLocked" AS content_locked,
sku."sortOrder" AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu")) || ':' || upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_sku_items_ready_v1 AS
SELECT sku.*
FROM external.intake_sku_items_v1 sku
JOIN external.intake_products_ready_v1 product
ON product.intake_product_id = sku.intake_product_id
WHERE sku.data_quality_status = 'READY'
AND NULLIF(btrim(COALESCE(sku.sku_business_key, '')), '') IS NOT NULL;

View File

@ -0,0 +1,68 @@
-- Add explicit stable business keys to the external finished-SKU view.
-- Product.modelNumber and ProductSKU.skuCode are already the structured
-- identities; these columns make that contract unambiguous for readers.
CREATE OR REPLACE VIEW external.product_skus_v1 AS
SELECT
p."id" AS product_id,
p."modelNumber" AS product_model_number,
p."name" AS product_name,
p."description" AS product_description,
pc."id" AS product_category_id,
pc."name" AS product_category_name,
p."thumbnailUrl" AS product_thumbnail_url,
p."modelUrl" AS product_model_url,
sku."id" AS sku_id,
sku."skuCode" AS sku_code,
COALESCE(
NULLIF(sku."name", ''),
NULLIF(profile."specName", ''),
sku."skuCode"
) AS sku_name,
sku."price" AS price,
sku."costPrice" AS cost_price,
sku."stock" AS stock,
sku."isActive" AS is_active,
CASE WHEN sku."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status,
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url,
COALESCE(
NULLIF(profile."imageUrl", ''),
sku."images"[1],
NULLIF(p."thumbnailUrl", '')
) AS image_url,
sku."images" AS image_urls,
sku."sourcePreSkuId" AS source_pre_sku_id,
sku."sourceAssetVariantId" AS source_asset_variant_id,
sku."sourceAssetVariantIds" AS source_asset_variant_ids,
profile."model" AS asset_family_code,
profile."modelName" AS asset_family_name,
profile."specCode" AS spec_code,
profile."specName" AS spec_name,
profile."barcode" AS barcode,
profile."size" AS size_json,
profile."color" AS color,
profile."material" AS material,
profile."sourceStatus"::text AS source_status,
profile."syncedAt"::text AS synced_at,
wh."id" AS warehouse_location_id,
wh."code" AS warehouse_location_code,
wh."name" AS warehouse_location_name,
sku."createdAt" AS created_at,
sku."updatedAt" AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(p."modelNumber", '')), '') IS NOT NULL
THEN upper(btrim(p."modelNumber"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
LEFT JOIN "ProductCategory" pc ON pc."id" = p."categoryId"
LEFT JOIN "WarehouseLocation" wh ON wh."id" = sku."warehouseLocationId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL;

View File

@ -0,0 +1,183 @@
-- Carry stable product/SKU business keys into external asset and BOM detail
-- views so downstream systems do not have to join on internal row ids.
CREATE OR REPLACE VIEW external.product_assets_v1 AS
WITH image_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
p."modelNumber" AS product_model_number,
sku."skuCode" AS sku_code,
image_item.image_url,
image_item.sort_order
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
CROSS JOIN LATERAL unnest(sku."images") WITH ORDINALITY AS image_item(image_url, sort_order)
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
),
model_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
p."modelNumber" AS product_model_number,
sku."skuCode" AS sku_code,
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
),
thumbnail_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
p."modelNumber" AS product_model_number,
sku."skuCode" AS sku_code,
COALESCE(
NULLIF(profile."imageUrl", ''),
NULLIF(p."thumbnailUrl", '')
) AS thumbnail_url
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
)
SELECT
'SKU_IMAGE' AS asset_type,
product_id,
sku_id,
image_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
sort_order::integer AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL
THEN upper(btrim(product_model_number))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS sku_business_key
FROM image_assets
WHERE NULLIF(image_url, '') IS NOT NULL
UNION ALL
SELECT
'SKU_MODEL' AS asset_type,
product_id,
sku_id,
model_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
0 AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL
THEN upper(btrim(product_model_number))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS sku_business_key
FROM model_assets
WHERE NULLIF(model_url, '') IS NOT NULL
UNION ALL
SELECT
'SKU_THUMBNAIL' AS asset_type,
product_id,
sku_id,
thumbnail_url AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
0 AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL
THEN upper(btrim(product_model_number))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS sku_business_key
FROM thumbnail_assets
WHERE NULLIF(thumbnail_url, '') IS NOT NULL;
CREATE OR REPLACE VIEW external.product_bom_v1 AS
SELECT
'PANEL' AS item_type,
p."productId" AS product_id,
p."skuId" AS sku_id,
p."id" AS item_id,
p."code" AS item_code,
p."name" AS item_name,
1::numeric AS quantity,
'piece' AS unit,
m."id" AS material_id,
m."code" AS material_code,
m."name" AS material_name,
jsonb_build_object(
'length', p."length",
'width', p."width",
'thickness', p."thickness",
'unit', 'mm'
) AS dimensions,
NULL::numeric AS unit_cost,
NULL::numeric AS total_cost,
p."createdAt" AS created_at,
p."updatedAt" AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(product."modelNumber", '')), '') IS NOT NULL
THEN upper(btrim(product."modelNumber"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key
FROM "Panel" p
JOIN "Product" product ON product."id" = p."productId"
LEFT JOIN "ProductSKU" sku ON sku."id" = p."skuId"
LEFT JOIN "PanelMaterial" pm ON pm."panelId" = p."id"
LEFT JOIN "Material" m ON m."id" = pm."materialId"
WHERE product."deletedAt" IS NULL
AND (sku."id" IS NULL OR sku."deletedAt" IS NULL)
UNION ALL
SELECT
'HARDWARE' AS item_type,
ph."productId" AS product_id,
ph."skuId" AS sku_id,
ph."id" AS item_id,
COALESCE(h."code", ph."sourceBlockName", ph."id") AS item_code,
COALESCE(h."name", ph."sourceBlockName", '未匹配五金') AS item_name,
1::numeric AS quantity,
'piece' AS unit,
NULL::text AS material_id,
NULL::text AS material_code,
NULL::text AS material_name,
h."dimensions" AS dimensions,
NULL::numeric AS unit_cost,
NULL::numeric AS total_cost,
ph."createdAt" AS created_at,
ph."updatedAt" AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(product."modelNumber", '')), '') IS NOT NULL
THEN upper(btrim(product."modelNumber"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key
FROM "ProductHardware" ph
JOIN "Product" product ON product."id" = ph."productId"
LEFT JOIN "ProductSKU" sku ON sku."id" = ph."skuId"
LEFT JOIN "Hardware" h ON h."id" = ph."hardwareId"
WHERE product."deletedAt" IS NULL
AND (sku."id" IS NULL OR sku."deletedAt" IS NULL);

View File

@ -0,0 +1,54 @@
-- SPU_RULE rows seed the formal SPU counter. The category lookup must use the
-- same normalized semantics as SpuNumberCounter, otherwise a case/space variant
-- can make the service miss the configured rule and fall through to a database
-- conflict instead of a clear business validation.
DO $$
DECLARE
duplicate_category_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO duplicate_category_count
FROM (
SELECT lower(btrim("name")) AS normalized_name
FROM "IntakeReferenceLibraryItem"
WHERE "libraryType" = 'SPU_RULE'
AND NULLIF(btrim("name"), '') IS NOT NULL
GROUP BY lower(btrim("name"))
HAVING COUNT(*) > 1
) duplicate_groups;
IF duplicate_category_count > 0 THEN
RAISE EXCEPTION
'Cannot add normalized SPU_RULE category guard: % duplicate normalized product category groups exist.',
duplicate_category_count;
END IF;
END $$;
UPDATE "IntakeReferenceLibraryItem"
SET "name" = btrim("name")
WHERE "libraryType" = 'SPU_RULE'
AND "name" IS DISTINCT FROM btrim("name");
DROP INDEX IF EXISTS "IntakeReferenceLibraryItem_spu_rule_name_normalized_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeReferenceLibraryItem_spu_rule_name_normalized_key"
ON "IntakeReferenceLibraryItem"(lower(btrim("name")))
WHERE "libraryType" = 'SPU_RULE'
AND NULLIF(btrim("name"), '') IS NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeReferenceLibraryItem_spu_rule_name_normalized'
) THEN
ALTER TABLE "IntakeReferenceLibraryItem"
ADD CONSTRAINT "IntakeReferenceLibraryItem_spu_rule_name_normalized"
CHECK (
"libraryType" <> 'SPU_RULE'
OR (
NULLIF(btrim("name"), '') IS NOT NULL
AND "name" = btrim("name")
)
);
END IF;
END $$;

View File

@ -0,0 +1,34 @@
-- Reference-library code is the structured lookup identity used by BOM pickers,
-- SPU rules, and external read-only consumers. It must not be left blank even
-- though payload can still carry legacy extension fields.
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*)::integer
INTO invalid_count
FROM "IntakeReferenceLibraryItem"
WHERE NULLIF(btrim("code"), '') IS NULL
OR "code" <> upper(btrim("code"));
IF invalid_count > 0 THEN
RAISE EXCEPTION
'Cannot add IntakeReferenceLibraryItem code guard: % rows have blank or non-normalized code.',
invalid_count;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'IntakeReferenceLibraryItem_code_required'
) THEN
ALTER TABLE "IntakeReferenceLibraryItem"
ADD CONSTRAINT "IntakeReferenceLibraryItem_code_required"
CHECK (
NULLIF(btrim("code"), '') IS NOT NULL
AND "code" = upper(btrim("code"))
);
END IF;
END $$;

View File

@ -0,0 +1,82 @@
ALTER TABLE "ProductSKU"
ADD COLUMN IF NOT EXISTS "originalSkuCode" TEXT,
ADD COLUMN IF NOT EXISTS "deletedCodeAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "deletedByProductId" TEXT,
ADD COLUMN IF NOT EXISTS "productDeletedAt" TIMESTAMP(3);
UPDATE "ProductSKU"
SET
"originalSkuCode" = upper(btrim(
COALESCE(
NULLIF(attributes->>'_originalSkuCode', ''),
CASE
WHEN strpos("skuCode", '__deleted__') > 0
THEN substring("skuCode" from 1 for strpos("skuCode", '__deleted__') - 1)
ELSE "skuCode"
END
)
)),
"deletedCodeAt" = COALESCE(
"deletedCodeAt",
CASE
WHEN NULLIF(attributes->>'_deletedCodeAt', '') ~ '^\d{4}-\d{2}-\d{2}T'
THEN (attributes->>'_deletedCodeAt')::timestamp(3)
ELSE "deletedAt"
END
),
"deletedByProductId" = COALESCE("deletedByProductId", NULLIF(btrim(attributes->>'_deletedByProductId'), '')),
"productDeletedAt" = COALESCE(
"productDeletedAt",
CASE
WHEN NULLIF(attributes->>'_productDeletedAt', '') ~ '^\d{4}-\d{2}-\d{2}T'
THEN (attributes->>'_productDeletedAt')::timestamp(3)
ELSE NULL
END
)
WHERE "deletedAt" IS NOT NULL;
UPDATE "ProductSKU"
SET attributes = attributes
- '_originalSkuCode'
- '_deletedCodeAt'
- '_deletedByProductId'
- '_productDeletedAt'
WHERE attributes ? '_originalSkuCode'
OR attributes ? '_deletedCodeAt'
OR attributes ? '_deletedByProductId'
OR attributes ? '_productDeletedAt';
ALTER TABLE "ProductSKU"
DROP CONSTRAINT IF EXISTS "ProductSKU_soft_delete_identity_valid",
ADD CONSTRAINT "ProductSKU_soft_delete_identity_valid"
CHECK (
(
"deletedAt" IS NULL
AND "originalSkuCode" IS NULL
AND "deletedCodeAt" IS NULL
AND "deletedByProductId" IS NULL
AND "productDeletedAt" IS NULL
)
OR
(
"deletedAt" IS NOT NULL
AND NULLIF(btrim(COALESCE("originalSkuCode", '')), '') IS NOT NULL
AND "originalSkuCode" = upper(btrim("originalSkuCode"))
AND ("deletedByProductId" IS NULL OR NULLIF(btrim("deletedByProductId"), '') IS NOT NULL)
)
);
ALTER TABLE "ProductSKU"
DROP CONSTRAINT IF EXISTS "ProductSKU_no_soft_delete_lifecycle_attributes",
ADD CONSTRAINT "ProductSKU_no_soft_delete_lifecycle_attributes"
CHECK (
NOT (
attributes ? '_originalSkuCode'
OR attributes ? '_deletedCodeAt'
OR attributes ? '_deletedByProductId'
OR attributes ? '_productDeletedAt'
)
);
CREATE INDEX IF NOT EXISTS "ProductSKU_originalSkuCode_deletedAt_idx"
ON "ProductSKU" ("originalSkuCode", "deletedAt");

View File

@ -0,0 +1,98 @@
-- Tighten external read-only views to the documented field contract.
-- Do not expose extra internal columns or use internal row ids as business codes.
CREATE SCHEMA IF NOT EXISTS external;
DROP VIEW IF EXISTS external.materials_v1;
CREATE VIEW external.materials_v1 AS
SELECT
m."id" AS material_id,
m."code" AS material_code,
m."name" AS material_name,
c."name" AS category_name,
m."thumbnailUrl" AS thumbnail_url,
m."textureUrl" AS texture_url,
m."normalMapUrl" AS normal_map_url,
m."color" AS base_color,
m."metalness" AS metalness,
m."roughness" AS roughness,
m."opacity" AS opacity,
CASE WHEN m."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status
FROM "Material" m
JOIN "MaterialCategory" c ON c."id" = m."categoryId";
CREATE OR REPLACE VIEW external.product_bom_v1 AS
SELECT
'PANEL' AS item_type,
p."productId" AS product_id,
p."skuId" AS sku_id,
p."id" AS item_id,
p."code" AS item_code,
p."name" AS item_name,
1::numeric AS quantity,
'piece' AS unit,
m."id" AS material_id,
m."code" AS material_code,
m."name" AS material_name,
jsonb_build_object(
'length', p."length",
'width', p."width",
'thickness', p."thickness",
'unit', 'mm'
) AS dimensions,
NULL::numeric AS unit_cost,
NULL::numeric AS total_cost,
p."createdAt" AS created_at,
p."updatedAt" AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(product."modelNumber", '')), '') IS NOT NULL
THEN upper(btrim(product."modelNumber"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key
FROM "Panel" p
JOIN "Product" product ON product."id" = p."productId"
LEFT JOIN "ProductSKU" sku ON sku."id" = p."skuId"
LEFT JOIN "PanelMaterial" pm ON pm."panelId" = p."id"
LEFT JOIN "Material" m ON m."id" = pm."materialId"
WHERE product."deletedAt" IS NULL
AND (sku."id" IS NULL OR sku."deletedAt" IS NULL)
UNION ALL
SELECT
'HARDWARE' AS item_type,
ph."productId" AS product_id,
ph."skuId" AS sku_id,
ph."id" AS item_id,
NULLIF(btrim(COALESCE(h."code", ph."sourceBlockName", '')), '') AS item_code,
COALESCE(h."name", ph."sourceBlockName", '未匹配五金') AS item_name,
1::numeric AS quantity,
'piece' AS unit,
NULL::text AS material_id,
NULL::text AS material_code,
NULL::text AS material_name,
h."dimensions" AS dimensions,
NULL::numeric AS unit_cost,
NULL::numeric AS total_cost,
ph."createdAt" AS created_at,
ph."updatedAt" AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(product."modelNumber", '')), '') IS NOT NULL
THEN upper(btrim(product."modelNumber"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key
FROM "ProductHardware" ph
JOIN "Product" product ON product."id" = ph."productId"
LEFT JOIN "ProductSKU" sku ON sku."id" = ph."skuId"
LEFT JOIN "Hardware" h ON h."id" = ph."hardwareId"
WHERE product."deletedAt" IS NULL
AND (sku."id" IS NULL OR sku."deletedAt" IS NULL);

View File

@ -0,0 +1,112 @@
-- Align replayed migrations with the current Prisma schema surface.
-- This migration is intentionally structural and idempotent: it closes legacy
-- migration gaps before the project forms a baseline/repair plan for existing
-- non-empty databases that do not yet have _prisma_migrations history.
ALTER TABLE "Hardware"
ADD COLUMN IF NOT EXISTS "geoWidth" DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS "geoHeight" DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS "geoDepth" DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS "geoVolume" DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS "defaultHeadDirection" TEXT,
ADD COLUMN IF NOT EXISTS "installTip" TEXT,
ADD COLUMN IF NOT EXISTS "isComposite" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "accessories" JSONB;
ALTER TABLE "Product"
DROP COLUMN IF EXISTS "collaborators";
ALTER TABLE "AssemblyStep"
ALTER COLUMN "partIds" DROP DEFAULT,
ALTER COLUMN "hardwareIds" DROP DEFAULT;
DROP INDEX IF EXISTS "ProductPatentRecord_patentGroupKey_idx";
DROP INDEX IF EXISTS "ProductSKU_originalSkuCode_deletedAt_idx";
DROP INDEX IF EXISTS "ProductSKU_sourcePreSkuId_key";
CREATE UNIQUE INDEX IF NOT EXISTS "ProductSKU_sourcePreSkuId_key"
ON "ProductSKU"("sourcePreSkuId");
CREATE INDEX IF NOT EXISTS "ActivityLog_createdAt_idx" ON "ActivityLog"("createdAt");
CREATE INDEX IF NOT EXISTS "Hardware_geoWidth_geoHeight_geoDepth_idx" ON "Hardware"("geoWidth", "geoHeight", "geoDepth");
CREATE INDEX IF NOT EXISTS "ShareLink_expiresAt_idx" ON "ShareLink"("expiresAt");
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'ProductSKU_sourceAssetVariantIds_gin_idx'
) AND NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'ProductSKU_sourceAssetVariantIds_idx'
) THEN
ALTER INDEX "ProductSKU_sourceAssetVariantIds_gin_idx"
RENAME TO "ProductSKU_sourceAssetVariantIds_idx";
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'ProductHardware_hardwareId_fkey'
AND table_name = 'ProductHardware'
) THEN
ALTER TABLE "ProductHardware"
DROP CONSTRAINT "ProductHardware_hardwareId_fkey";
END IF;
ALTER TABLE "ProductHardware"
ADD CONSTRAINT "ProductHardware_hardwareId_fkey"
FOREIGN KEY ("hardwareId") REFERENCES "Hardware"("id")
ON DELETE SET NULL
ON UPDATE CASCADE;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'Substrate_systemNameOptionId_fkey'
AND table_name = 'Substrate'
) THEN
ALTER TABLE "Substrate"
DROP CONSTRAINT "Substrate_systemNameOptionId_fkey";
END IF;
ALTER TABLE "Substrate"
ADD CONSTRAINT "Substrate_systemNameOptionId_fkey"
FOREIGN KEY ("systemNameOptionId") REFERENCES "SubstrateSystemNameOption"("id")
ON DELETE SET NULL
ON UPDATE CASCADE;
END $$;
ALTER TABLE "AssetFamily" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "AssetSeries" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "AssetSkuSyncEvent" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "AssetVariant" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "AssistedAssemblyAiRender" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "DraftGroup" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "IntakeNodePermissionOverride" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "IntakeNonIntegratedProductProfile" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "IntakeNonIntegratedSkuItem" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "IntakeReferenceLibraryItem" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "PackagedItem" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "PlanningDraft" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "PlanningTemplate" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "PlanningWorkspace" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "PreSku" ALTER COLUMN "componentCodes" DROP DEFAULT;
ALTER TABLE "PreSku" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "PreSkuBasicInfo" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "PreSkuPackagingPlan" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "ProductPatentRecord" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "SpuNumberCounter" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "Substrate" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "SystemConfig" ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "WarehouseLocation" ALTER COLUMN "updatedAt" DROP DEFAULT;

View File

@ -0,0 +1,51 @@
CREATE TABLE "MasterDataQualityDecision" (
"id" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"targetBusinessKey" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'APPROVED_INCOMPLETE',
"missingFields" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"reason" TEXT NOT NULL,
"decidedBy" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolvedBy" TEXT,
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MasterDataQualityDecision_pkey" PRIMARY KEY ("id"),
CONSTRAINT "MasterDataQualityDecision_target_not_blank" CHECK (
NULLIF(btrim("targetType"), '') IS NOT NULL
AND NULLIF(btrim("targetId"), '') IS NOT NULL
),
CONSTRAINT "MasterDataQualityDecision_status_allowed" CHECK (
"status" IN ('APPROVED_INCOMPLETE', 'RESOLVED')
),
CONSTRAINT "MasterDataQualityDecision_reason_not_blank" CHECK (
NULLIF(btrim("reason"), '') IS NOT NULL
),
CONSTRAINT "MasterDataQualityDecision_decidedBy_not_blank" CHECK (
NULLIF(btrim("decidedBy"), '') IS NOT NULL
),
CONSTRAINT "MasterDataQualityDecision_resolved_state_valid" CHECK (
("status" <> 'RESOLVED' AND "resolvedAt" IS NULL AND "resolvedBy" IS NULL)
OR
("status" = 'RESOLVED' AND "resolvedAt" IS NOT NULL AND NULLIF(btrim(COALESCE("resolvedBy", '')), '') IS NOT NULL)
)
);
CREATE INDEX "MasterDataQualityDecision_targetType_targetId_idx"
ON "MasterDataQualityDecision"("targetType", "targetId");
CREATE INDEX "MasterDataQualityDecision_targetBusinessKey_idx"
ON "MasterDataQualityDecision"("targetBusinessKey");
CREATE INDEX "MasterDataQualityDecision_status_idx"
ON "MasterDataQualityDecision"("status");
CREATE INDEX "MasterDataQualityDecision_decidedAt_idx"
ON "MasterDataQualityDecision"("decidedAt");
CREATE UNIQUE INDEX "MasterDataQualityDecision_open_target_key"
ON "MasterDataQualityDecision"("targetType", "targetId")
WHERE "status" = 'APPROVED_INCOMPLETE';

View File

@ -0,0 +1,8 @@
ALTER TABLE "ProductSKU"
DROP CONSTRAINT IF EXISTS "ProductSKU_price_cost_stock_non_negative",
ADD CONSTRAINT "ProductSKU_price_cost_stock_non_negative"
CHECK (
"price" >= 0
AND ("costPrice" IS NULL OR "costPrice" >= 0)
AND "stock" >= 0
);

View File

@ -0,0 +1,138 @@
CREATE OR REPLACE FUNCTION public.text_array_all_trimmed_non_blank(items text[])
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$
SELECT COALESCE(
bool_and(NULLIF(btrim(item), '') IS NOT NULL),
true
)
FROM unnest(COALESCE(items, ARRAY[]::text[])) AS item
$$;
ALTER TABLE "AssetSeries"
DROP CONSTRAINT IF EXISTS "AssetSeries_identity_valid",
ADD CONSTRAINT "AssetSeries_identity_valid"
CHECK (
NULLIF(btrim(code), '') IS NOT NULL
AND code = btrim(code)
AND NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND "sortOrder" >= 0
AND (description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
);
ALTER TABLE "AssetSeries"
DROP CONSTRAINT IF EXISTS "AssetSeries_metadata_object",
ADD CONSTRAINT "AssetSeries_metadata_object"
CHECK (
metadata IS NULL OR jsonb_typeof(metadata) IN ('object', 'null')
);
ALTER TABLE "AssetFamily"
DROP CONSTRAINT IF EXISTS "AssetFamily_identity_valid",
ADD CONSTRAINT "AssetFamily_identity_valid"
CHECK (
NULLIF(btrim(code), '') IS NOT NULL
AND code = btrim(code)
AND NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND public.text_array_all_trimmed_non_blank("connectionRoles")
AND (description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND ("rejectionReason" IS NULL OR NULLIF(btrim("rejectionReason"), '') IS NOT NULL)
);
ALTER TABLE "AssetFamily"
DROP CONSTRAINT IF EXISTS "AssetFamily_metadata_object",
ADD CONSTRAINT "AssetFamily_metadata_object"
CHECK (
metadata IS NULL OR jsonb_typeof(metadata) IN ('object', 'null')
);
ALTER TABLE "AssetFamily"
DROP CONSTRAINT IF EXISTS "AssetFamily_lifecycle_state_valid",
ADD CONSTRAINT "AssetFamily_lifecycle_state_valid"
CHECK (
(("submittedAt" IS NULL) = ("submittedById" IS NULL))
AND (("reviewedAt" IS NULL) = ("reviewedById" IS NULL))
AND (("rejectedAt" IS NULL) = ("rejectedById" IS NULL))
AND (("deletedAt" IS NULL) = ("deletedById" IS NULL))
AND (status <> 'PENDING_REVIEW'::"AssetStatus" OR "submittedAt" IS NOT NULL)
AND (status <> 'AVAILABLE'::"AssetStatus" OR "reviewedAt" IS NOT NULL)
AND (
status <> 'REJECTED'::"AssetStatus"
OR (
"rejectedAt" IS NOT NULL
AND NULLIF(btrim(COALESCE("rejectionReason", '')), '') IS NOT NULL
)
)
);
ALTER TABLE "AssetVariant"
DROP CONSTRAINT IF EXISTS "AssetVariant_identity_valid",
ADD CONSTRAINT "AssetVariant_identity_valid"
CHECK (
NULLIF(btrim(code), '') IS NOT NULL
AND code = btrim(code)
AND NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
AND NULLIF(btrim("identityKey"), '') IS NOT NULL
AND "identityKey" = btrim("identityKey")
AND NULLIF(btrim("modelUrl"), '') IS NOT NULL
AND "modelUrl" = btrim("modelUrl")
AND ("thumbnailUrl" IS NULL OR NULLIF(btrim("thumbnailUrl"), '') IS NOT NULL)
AND ("sizeLabel" IS NULL OR NULLIF(btrim("sizeLabel"), '') IS NOT NULL)
AND (barcode IS NULL OR NULLIF(btrim(barcode), '') IS NOT NULL)
AND ("supplierName" IS NULL OR NULLIF(btrim("supplierName"), '') IS NOT NULL)
AND ("supplierCode" IS NULL OR NULLIF(btrim("supplierCode"), '') IS NOT NULL)
AND ("versionNo" IS NULL OR NULLIF(btrim("versionNo"), '') IS NOT NULL)
AND ("rejectionReason" IS NULL OR NULLIF(btrim("rejectionReason"), '') IS NOT NULL)
);
ALTER TABLE "AssetVariant"
DROP CONSTRAINT IF EXISTS "AssetVariant_json_shape_valid",
ADD CONSTRAINT "AssetVariant_json_shape_valid"
CHECK (
jsonb_typeof(dimensions) = 'object'
AND jsonb_typeof("mountPoints") = 'array'
AND ("assemblyBasis" IS NULL OR jsonb_typeof("assemblyBasis") IN ('object', 'null'))
AND ("compatibilityRules" IS NULL OR jsonb_typeof("compatibilityRules") IN ('object', 'null'))
AND ("namingFields" IS NULL OR jsonb_typeof("namingFields") IN ('object', 'null'))
AND ("businessData" IS NULL OR jsonb_typeof("businessData") IN ('object', 'null'))
);
ALTER TABLE "AssetVariant"
DROP CONSTRAINT IF EXISTS "AssetVariant_cost_weight_valid",
ADD CONSTRAINT "AssetVariant_cost_weight_valid"
CHECK (
("weightKg" IS NULL OR "weightKg" >= 0)
AND ("lossFactor" IS NULL OR "lossFactor" >= 0)
AND ("costPrice" IS NULL OR "costPrice" >= 0)
AND ("estimatedCostPrice" IS NULL OR "estimatedCostPrice" >= 0)
);
ALTER TABLE "AssetVariant"
DROP CONSTRAINT IF EXISTS "AssetVariant_lifecycle_state_valid",
ADD CONSTRAINT "AssetVariant_lifecycle_state_valid"
CHECK (
(("submittedAt" IS NULL) = ("submittedById" IS NULL))
AND (("reviewedAt" IS NULL) = ("reviewedById" IS NULL))
AND (("rejectedAt" IS NULL) = ("rejectedById" IS NULL))
AND (("deletedAt" IS NULL) = ("deletedById" IS NULL))
AND (status <> 'PENDING_REVIEW'::"AssetStatus" OR "submittedAt" IS NOT NULL)
AND (status <> 'AVAILABLE'::"AssetStatus" OR "reviewedAt" IS NOT NULL)
AND (
status <> 'REJECTED'::"AssetStatus"
OR (
"rejectedAt" IS NOT NULL
AND NULLIF(btrim(COALESCE("rejectionReason", '')), '') IS NOT NULL
)
)
);
ALTER TABLE "AssetVariant"
DROP CONSTRAINT IF EXISTS "AssetVariant_inheritance_not_self",
ADD CONSTRAINT "AssetVariant_inheritance_not_self"
CHECK (
"inheritedFromVariantId" IS NULL OR "inheritedFromVariantId" <> id
);

View File

@ -0,0 +1,85 @@
CREATE OR REPLACE FUNCTION public.text_array_all_trimmed_non_blank(items text[])
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$
SELECT COALESCE(
bool_and(NULLIF(btrim(item), '') IS NOT NULL),
true
)
FROM unnest(COALESCE(items, ARRAY[]::text[])) AS item
$$;
ALTER TABLE "DraftGroup"
DROP CONSTRAINT IF EXISTS "DraftGroup_identity_valid",
ADD CONSTRAINT "DraftGroup_identity_valid"
CHECK (
NULLIF(btrim(code), '') IS NOT NULL
AND code = btrim(code)
AND NULLIF(btrim(name), '') IS NOT NULL
AND name = btrim(name)
);
ALTER TABLE "DraftGroup"
DROP CONSTRAINT IF EXISTS "DraftGroup_summary_object",
ADD CONSTRAINT "DraftGroup_summary_object"
CHECK (
summary IS NOT NULL
AND jsonb_typeof(summary) = 'object'
);
ALTER TABLE "DraftGroup"
DROP CONSTRAINT IF EXISTS "DraftGroup_optional_text_not_blank",
ADD CONSTRAINT "DraftGroup_optional_text_not_blank"
CHECK (
(description IS NULL OR NULLIF(btrim(description), '') IS NOT NULL)
AND (notes IS NULL OR NULLIF(btrim(notes), '') IS NOT NULL)
);
ALTER TABLE "PreSku"
DROP CONSTRAINT IF EXISTS "PreSku_identity_valid",
ADD CONSTRAINT "PreSku_identity_valid"
CHECK (
sequence > 0
AND NULLIF(btrim("defaultName"), '') IS NOT NULL
AND "defaultName" = btrim("defaultName")
AND NULLIF(btrim("sourceCandidateKey"), '') IS NOT NULL
AND "sourceCandidateKey" = btrim("sourceCandidateKey")
);
ALTER TABLE "PreSku"
DROP CONSTRAINT IF EXISTS "PreSku_process_snapshots_shape_valid",
ADD CONSTRAINT "PreSku_process_snapshots_shape_valid"
CHECK (
jsonb_typeof("familySelection") = 'object'
AND jsonb_typeof("variantSelection") = 'object'
AND jsonb_typeof("previewNodes") = 'array'
AND jsonb_typeof("displaySummary") = 'object'
AND jsonb_typeof("bomPreview") = 'array'
AND ("recommendedHardware" IS NULL OR jsonb_typeof("recommendedHardware") IN ('array', 'null'))
AND ("explosionPreview" IS NULL OR jsonb_typeof("explosionPreview") IN ('array', 'null'))
AND jsonb_typeof("installStepPreview") = 'array'
AND ("packagingSnapshot" IS NULL OR jsonb_typeof("packagingSnapshot") IN ('object', 'null'))
);
ALTER TABLE "PreSku"
DROP CONSTRAINT IF EXISTS "PreSku_weight_valid",
ADD CONSTRAINT "PreSku_weight_valid"
CHECK (
"weightPreview" IS NULL OR "weightPreview" >= 0
);
ALTER TABLE "PreSku"
DROP CONSTRAINT IF EXISTS "PreSku_component_codes_valid",
ADD CONSTRAINT "PreSku_component_codes_valid"
CHECK (
public.text_array_all_trimmed_non_blank("componentCodes")
);
ALTER TABLE "PreSku"
DROP CONSTRAINT IF EXISTS "PreSku_optional_text_not_blank",
ADD CONSTRAINT "PreSku_optional_text_not_blank"
CHECK (
("customName" IS NULL OR NULLIF(btrim("customName"), '') IS NOT NULL)
AND (notes IS NULL OR NULLIF(btrim(notes), '') IS NOT NULL)
);

View File

@ -0,0 +1,15 @@
ALTER TABLE "ProductSKU"
DROP CONSTRAINT IF EXISTS "ProductSKU_no_formal_truth_attributes",
ADD CONSTRAINT "ProductSKU_no_formal_truth_attributes"
CHECK (
NOT (
attributes ? 'skuName'
OR attributes ? 'model'
OR attributes ? 'specCode'
OR attributes ? 'barcode'
OR attributes ? 'category'
OR attributes ? 'color'
OR attributes ? 'material'
OR attributes ? 'size'
)
);

View File

@ -0,0 +1,30 @@
-- Keep the formal SKU asset profile lifecycle copy aligned with ProductSKU.
-- The service layer already updates ProductSkuAssetProfile.skuDeletedAt on the
-- normal soft-delete / restore path; this trigger closes direct SQL or future
-- repository write gaps so the active barcode pool cannot drift.
UPDATE "ProductSkuAssetProfile" profile
SET "skuDeletedAt" = sku."deletedAt"
FROM "ProductSKU" sku
WHERE profile."skuId" = sku."id"
AND profile."skuDeletedAt" IS DISTINCT FROM sku."deletedAt";
CREATE OR REPLACE FUNCTION "sync_product_sku_asset_profile_deleted_at"()
RETURNS TRIGGER AS $$
BEGIN
UPDATE "ProductSkuAssetProfile"
SET "skuDeletedAt" = NEW."deletedAt"
WHERE "skuId" = NEW."id"
AND "skuDeletedAt" IS DISTINCT FROM NEW."deletedAt";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS "ProductSKU_sync_asset_profile_deleted_at"
ON "ProductSKU";
CREATE TRIGGER "ProductSKU_sync_asset_profile_deleted_at"
AFTER INSERT OR UPDATE OF "deletedAt" ON "ProductSKU"
FOR EACH ROW
EXECUTE FUNCTION "sync_product_sku_asset_profile_deleted_at"();

View File

@ -0,0 +1,24 @@
-- Soft-deleted formal SKUs must keep enough structured tombstone data to make
-- barcode release, restore conflict checks and product-cascade restore auditable.
UPDATE "ProductSKU"
SET "deletedCodeAt" = COALESCE("deletedCodeAt", "deletedAt")
WHERE "deletedAt" IS NOT NULL
AND "deletedCodeAt" IS NULL;
ALTER TABLE "ProductSKU"
DROP CONSTRAINT IF EXISTS "ProductSKU_soft_delete_tombstone_valid",
ADD CONSTRAINT "ProductSKU_soft_delete_tombstone_valid"
CHECK (
"deletedAt" IS NULL
OR (
"deletedCodeAt" IS NOT NULL
AND (
("deletedByProductId" IS NULL AND "productDeletedAt" IS NULL)
OR (
NULLIF(btrim(COALESCE("deletedByProductId", '')), '') IS NOT NULL
AND "productDeletedAt" IS NOT NULL
)
)
)
);

View File

@ -0,0 +1,82 @@
-- Master data quality decisions are audit records, not placeholders.
-- A staged incomplete approval must carry a real business reason and actor.
ALTER TABLE "MasterDataQualityDecision"
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_reason_not_placeholder",
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_decidedBy_not_placeholder",
DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_resolvedBy_not_placeholder",
ADD CONSTRAINT "MasterDataQualityDecision_reason_not_placeholder"
CHECK (
lower(regexp_replace(btrim("reason"), '\s+', '', 'g')) NOT IN (
'unknown',
'unk',
'n/a',
'na',
'null',
'none',
'todo',
'tbd',
'default',
'test',
'测试',
'待补',
'待补齐',
'待确认',
'未知',
'默认',
'默认值',
'暂无'
)
AND regexp_replace(btrim("reason"), '\s+', '', 'g') !~ '(待补|待确认|默认值)'
),
ADD CONSTRAINT "MasterDataQualityDecision_decidedBy_not_placeholder"
CHECK (
lower(regexp_replace(btrim("decidedBy"), '\s+', '', 'g')) NOT IN (
'unknown',
'unk',
'n/a',
'na',
'null',
'none',
'todo',
'tbd',
'default',
'test',
'测试',
'待补',
'待补齐',
'待确认',
'未知',
'默认',
'默认值',
'暂无'
)
AND regexp_replace(btrim("decidedBy"), '\s+', '', 'g') !~ '(待补|待确认|默认值)'
),
ADD CONSTRAINT "MasterDataQualityDecision_resolvedBy_not_placeholder"
CHECK (
"resolvedBy" IS NULL
OR (
lower(regexp_replace(btrim("resolvedBy"), '\s+', '', 'g')) NOT IN (
'unknown',
'unk',
'n/a',
'na',
'null',
'none',
'todo',
'tbd',
'default',
'test',
'测试',
'待补',
'待补齐',
'待确认',
'未知',
'默认',
'默认值',
'暂无'
)
AND regexp_replace(btrim("resolvedBy"), '\s+', '', 'g') !~ '(待补|待确认|默认值)'
)
);

View File

@ -1,5 +1,5 @@
-- CreateTable
CREATE TABLE "PackagingPlan" (
CREATE TABLE IF NOT EXISTS "PackagingPlan" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"name" TEXT NOT NULL,
@ -17,7 +17,21 @@ CREATE TABLE "PackagingPlan" (
);
-- CreateIndex
CREATE INDEX "PackagingPlan_productId_idx" ON "PackagingPlan"("productId");
CREATE INDEX IF NOT EXISTS "PackagingPlan_productId_idx" ON "PackagingPlan"("productId");
-- AddForeignKey
ALTER TABLE "PackagingPlan" ADD CONSTRAINT "PackagingPlan_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'PackagingPlan_productId_fkey'
AND table_name = 'PackagingPlan'
) THEN
ALTER TABLE "PackagingPlan"
ADD CONSTRAINT "PackagingPlan_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id")
ON DELETE CASCADE
ON UPDATE CASCADE;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@ -3,22 +3,53 @@
* 用于填充测试数据和演示数据
*/
import { PrismaClient, FeedbackStatus, FeedbackPriority, ActivityType } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import type { PrismaClient } from '@prisma/client';
import { FeedbackStatus, FeedbackPriority, ActivityType } from '@prisma/client';
import { hash } from 'bcryptjs';
import { STANDARD_FINISH_CATALOG } from '../src/lib/standard-finish-catalog';
import { buildStandardFinishMaterialData } from '../src/lib/standard-finish-sync';
import 'dotenv/config';
// Prisma 7 需要使用适配器
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const SEED_CONFIRM_TOKEN = 'reset-local-seed';
let prisma: PrismaClient | null = null;
function assertLocalDestructiveSeedAllowed() {
if (!process.argv.includes(`--confirm=${SEED_CONFIRM_TOKEN}`)) {
throw new Error(`拒绝执行 destructive seed:必须显式追加 --confirm=${SEED_CONFIRM_TOKEN}。`);
}
if (process.env.NODE_ENV === 'production') {
throw new Error('拒绝执行 destructive seed:NODE_ENV=production。');
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('拒绝执行 destructive seed:DATABASE_URL 未配置。');
}
const parsed = new URL(databaseUrl);
if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) {
throw new Error('拒绝执行 destructive seed:只允许本机数据库。');
}
}
function buildDemoSkuAssetProfile(input: {
color?: string;
size?: string;
material?: string;
variant?: string;
}) {
return {
size: input.size ? { label: input.size } : {},
color: input.color ?? null,
material: input.material ?? null,
specName: input.variant ?? null,
syncedAt: new Date(),
lockedReasons: {},
sourceVariants: [],
};
}
async function main() {
assertLocalDestructiveSeedAllowed();
({ prisma } = await import('../src/lib/prisma'));
console.log('🌱 开始填充数据库...');
// 清理现有数据
@ -521,7 +552,10 @@ async function main() {
skuCode: `SF-001-${color.substring(0, 2)}-${size.substring(0, 2)}`,
productId: sofa.id,
ownerId: designers[0].id,
attributes: { color, size, material: '布艺' },
attributes: {},
assetProfile: {
create: buildDemoSkuAssetProfile({ color, size, material: '布艺' }),
},
price: size === '双人座' ? 2999 : 3999,
costPrice: size === '双人座' ? 1500 : 2000,
stock: Math.floor(Math.random() * 50) + 10,
@ -545,7 +579,10 @@ async function main() {
skuCode: `${product.modelNumber}-V${j}`,
productId: product.id,
ownerId: designers[i % designers.length].id,
attributes: { variant: `变体${j}` },
attributes: {},
assetProfile: {
create: buildDemoSkuAssetProfile({ variant: `变体${j}` }),
},
price: 1000 + Math.random() * 4000,
costPrice: 500 + Math.random() * 2000,
stock: Math.floor(Math.random() * 100),
@ -781,6 +818,5 @@ main()
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
await prisma?.$disconnect();
});

View File

@ -4,7 +4,27 @@
* 运行: npx tsx scripts/add-more-skus.ts
*/
import { prisma } from '../src/lib/prisma'
const CONFIRM_TOKEN = 'add-local-demo-skus'
type PrismaSingleton = typeof import('../src/lib/prisma')['prisma']
let prisma: PrismaSingleton
function assertLocalScriptAllowed() {
if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) {
throw new Error(`add-more-skus 只允许显式本地补数:请追加 --confirm=${CONFIRM_TOKEN}。`)
}
if (process.env.NODE_ENV === 'production') {
throw new Error('add-more-skus 禁止在 NODE_ENV=production 下执行。')
}
const databaseUrl = process.env.DATABASE_URL || ''
if (!databaseUrl) {
throw new Error('add-more-skus 必须先配置 DATABASE_URL。')
}
const host = new URL(databaseUrl).hostname
if (!['localhost', '127.0.0.1', '::1'].includes(host)) {
throw new Error('add-more-skus 只允许连接本机数据库。')
}
}
// 示例模型 URL(使用公开的 GLTF 模型)
const sampleModels = [
@ -32,6 +52,8 @@ const sizes = ['小号', '中号', '大号', 'S', 'M', 'L', 'XL']
const materials = ['实木', '板材', '金属', '布艺', '皮革', '玻璃']
async function addMoreSkus() {
assertLocalScriptAllowed()
;({ prisma } = await import('../src/lib/prisma'))
console.log('开始为产品添加更多 SKU...\n')
// 获取所有用户作为潜在负责人
@ -81,11 +103,12 @@ async function addMoreSkus() {
const material = materials[Math.floor(Math.random() * materials.length)]
const owner = users[Math.floor(Math.random() * users.length)]
const skuCode = `${product.modelNumber}-${color.substring(0, 2)}-${size}`
const skuCode = `${product.modelNumber}-${color.substring(0, 2)}-${size}`.trim().toUpperCase()
const normalizedSkuCode = skuCode.trim().toUpperCase()
// 检查 SKU 编码是否已存在
const existing = await prisma.productSKU.findUnique({
where: { skuCode }
where: { skuCode: normalizedSkuCode }
})
if (existing) {
@ -144,7 +167,13 @@ async function addMoreSkus() {
console.log(`${p.modelNumber.padEnd(12)} ${p.name.padEnd(20)} ${p._count.skus} 个 SKU`)
})
await prisma.$disconnect()
}
addMoreSkus().catch(console.error)
addMoreSkus()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma?.$disconnect()
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,442 @@
const fs = require('node:fs')
const crypto = require('node:crypto')
const path = require('node:path')
const { Pool } = require('pg')
const { loadDatabaseUrl, describeDatabaseUrl, getDatabaseIdentity } = require('./diagnose-prisma-migration-baseline')
const {
REQUIRED_MIGRATIONS,
applyMissingMigrationFiles,
classifyAppliedMigrationPaths,
validateRequiredMigrationCatalog,
} = require('./master-data-required-migrations')
const {
getRequiredMigrationReadiness,
} = require('./verify-master-data-foundation')
const ROOT_DIR = path.resolve(__dirname, '..')
const CONFIRM_TOKEN = 'apply-master-data-foundation-migrations'
const APPLY_PHASES = new Set(['finalize', 'bootstrap-repair'])
function parseArgs(argv = process.argv.slice(2)) {
const options = {
apply: false,
confirm: '',
expectedDatabaseIdentity: '',
expectedBackupSha256: '',
outputPath: '',
phase: 'finalize',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--apply') {
options.apply = true
continue
}
if (arg.startsWith('--confirm=')) {
options.confirm = arg.slice('--confirm='.length)
continue
}
if (arg === '--expected-database-identity') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --expected-database-identity <identity>。')
}
options.expectedDatabaseIdentity = value
index += 1
continue
}
if (arg.startsWith('--expected-database-identity=')) {
options.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length)
continue
}
if (arg === '--expected-backup-sha256') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --expected-backup-sha256 <sha256>。')
}
options.expectedBackupSha256 = value
index += 1
continue
}
if (arg.startsWith('--expected-backup-sha256=')) {
options.expectedBackupSha256 = arg.slice('--expected-backup-sha256='.length)
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <json-file>。')
}
options.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputPath = arg.slice('--output='.length)
if (!options.outputPath) {
throw new Error('缺少 --output <json-file>。')
}
continue
}
if (arg === '--phase') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --phase <finalize|bootstrap-repair>。')
}
if (!APPLY_PHASES.has(value)) {
throw new Error(`未知 phase:${value}`)
}
options.phase = value
index += 1
continue
}
if (arg.startsWith('--phase=')) {
const value = arg.slice('--phase='.length)
if (!APPLY_PHASES.has(value)) {
throw new Error(`未知 phase:${value}`)
}
options.phase = value
continue
}
throw new Error(`未知参数:${arg}`)
}
return options
}
function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
if (!outputPath) return ''
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return absolutePath
}
function migrationHistoryReady(migrationHistory = {}) {
return (
migrationHistory.tableExists === true &&
migrationHistory.unfinishedMigrationRows === 0 &&
migrationHistory.rolledBackMigrationRows === 0
)
}
function normalizeSha256(value) {
return String(value || '').trim().toLowerCase()
}
function getMigrationCatalogByPath(migrations = REQUIRED_MIGRATIONS) {
return new Map(migrations.map((migration) => [migration.path, migration]))
}
function targetRelationsReady(migrationReadiness = {}) {
return !Array.isArray(migrationReadiness.targetRelations) ||
migrationReadiness.targetRelations.every((relation) => relation.exists === true)
}
function getFailedDataChecks(readiness = {}) {
if (!Array.isArray(readiness.checks)) return []
return readiness.checks.flatMap((migration) => {
if (!targetRelationsReady(migration) || !Array.isArray(migration.dataChecks)) return []
return migration.dataChecks
.filter((check) => check.matches === false)
.map((check) => ({
path: migration.path,
kind: migration.kind,
name: check.name,
expectedValue: check.expectedValue,
}))
})
}
function getBootstrapRepairPlan(readiness = {}, migrations = REQUIRED_MIGRATIONS) {
const catalogByPath = getMigrationCatalogByPath(migrations)
const candidates = []
const blockers = []
for (const migrationReadiness of readiness.checks || []) {
if (migrationReadiness.kind !== 'structure' || migrationReadiness.ready === true) {
continue
}
const migration = catalogByPath.get(migrationReadiness.path)
if (!migration) {
blockers.push({
key: 'bootstrapRepair.catalogMissing',
path: migrationReadiness.path,
message: '结构修复阶段发现 readiness 里存在未登记的治理迁移,不能继续。'
})
continue
}
const targetReady = targetRelationsReady(migrationReadiness)
const hasRepairSql = Boolean(migration.repairSql)
if (!targetReady || hasRepairSql) {
candidates.push({
path: migration.path,
strategy: targetReady ? 'repair-sql' : 'migration-sql',
})
continue
}
blockers.push({
key: 'bootstrapRepair.unrepairableStructureMigration',
path: migration.path,
message: '结构迁移目标已存在但仍未就绪,且未声明 repairSql;不能用 bootstrap 阶段盲目执行。'
})
}
return { candidates, blockers }
}
function getBackupFingerprint(env = process.env, rootDir = ROOT_DIR) {
const rawPath = env.DATABASE_BACKUP_FILE || env.DB_BACKUP_FILE || ''
if (!rawPath) {
return { provided: false, path: '', exists: false, sha256: '' }
}
const absolutePath = path.resolve(rootDir, rawPath)
if (!fs.existsSync(absolutePath)) {
return { provided: true, path: absolutePath, exists: false, sha256: '' }
}
const bytes = fs.readFileSync(absolutePath)
return {
provided: true,
path: absolutePath,
exists: true,
sizeBytes: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
}
}
function buildFoundationRequiredMigrationApplyPlan(readiness, options = {}) {
const phase = options.phase || 'finalize'
const blockers = []
const failedDataChecks = getFailedDataChecks(readiness)
const bootstrapRepairPlan = getBootstrapRepairPlan(readiness)
if (!migrationHistoryReady(readiness.migrationHistory)) {
blockers.push({
key: 'migrationHistory.notReady',
message: '治理迁移/护栏真实应用前,必须先补齐 _prisma_migrations 历史且无失败/回滚记录。',
})
}
if (phase === 'finalize' && readiness.pendingStructureMigrationCount > 0) {
blockers.push({
key: 'pendingStructureMigrationCount',
value: readiness.pendingStructureMigrationCount,
message: '仍有结构迁移未就绪,不能先应用治理护栏;必须先完成结构修复和 baseline resolve。',
})
}
if (phase === 'finalize' && failedDataChecks.length > 0) {
blockers.push({
key: 'dataChecks.notReady',
value: failedDataChecks.length,
message: '治理迁移应用前仍有历史数据不满足数据检查,必须先清洗真实主数据,不能靠约束迁移硬压过去。',
evidence: failedDataChecks,
})
}
if (phase === 'bootstrap-repair') {
blockers.push(...bootstrapRepairPlan.blockers)
if (bootstrapRepairPlan.candidates.length === 0) {
blockers.push({
key: 'bootstrapRepair.noWork',
message: '当前没有可由 bootstrap-repair 阶段处理的结构迁移或历史回填。'
})
}
}
if (options.apply && options.confirm !== CONFIRM_TOKEN) {
blockers.push({
key: 'confirm',
message: `真实执行必须显式传入 --confirm=${CONFIRM_TOKEN}。`,
})
}
if (options.apply && !options.expectedDatabaseIdentity) {
blockers.push({
key: 'expectedDatabaseIdentity.missing',
message: '治理迁移真实执行必须显式传入 --expected-database-identity,避免误连错库。',
})
} else if (
options.apply &&
options.actualDatabaseIdentity &&
options.expectedDatabaseIdentity !== options.actualDatabaseIdentity
) {
blockers.push({
key: 'expectedDatabaseIdentity.mismatch',
message: '命令行指定的目标库身份和当前 DATABASE_URL 不一致,禁止应用治理迁移。',
})
}
if (options.apply && !options.expectedBackupSha256) {
blockers.push({
key: 'expectedBackupSha256.missing',
message: '治理迁移真实执行必须显式传入 --expected-backup-sha256,避免在没有确认备份的情况下写库。',
})
} else if (options.apply && options.backupFingerprint?.sha256) {
if (normalizeSha256(options.expectedBackupSha256) !== normalizeSha256(options.backupFingerprint.sha256)) {
blockers.push({
key: 'expectedBackupSha256.mismatch',
message: '命令行指定的备份 sha256 和当前 DATABASE_BACKUP_FILE 不一致,禁止应用治理迁移。',
})
}
}
if (options.apply && options.backupFingerprint?.provided !== true) {
blockers.push({
key: 'backupFingerprint.missing',
message: '治理迁移真实执行前必须通过 DATABASE_BACKUP_FILE 或 DB_BACKUP_FILE 指定当前数据库备份。',
})
} else if (options.apply && options.backupFingerprint?.exists !== true) {
blockers.push({
key: 'backupFingerprint.notFound',
message: '命令指定的数据库备份文件不存在,禁止应用治理迁移。',
})
}
return {
mode: 'master-data-foundation-required-migration-apply-plan',
phase,
dryRun: !options.apply,
readyToApply: options.apply === true && blockers.length === 0,
confirmToken: CONFIRM_TOKEN,
migrationHistory: readiness.migrationHistory,
migrationCount: readiness.migrationCount,
readyMigrationCount: readiness.readyMigrationCount,
pendingMigrationCount: readiness.pendingMigrationCount,
pendingStructureMigrationCount: readiness.pendingStructureMigrationCount,
pendingIdempotentMigrationCount: readiness.pendingIdempotentMigrationCount,
pendingStructureMigrationPaths: readiness.pendingStructureMigrationPaths,
pendingIdempotentMigrationPaths: readiness.pendingIdempotentMigrationPaths,
failedDataChecks,
bootstrapRepairCandidates: bootstrapRepairPlan.candidates,
blockers,
actualDatabaseIdentity: options.actualDatabaseIdentity || '',
expectedDatabaseIdentity: options.expectedDatabaseIdentity || '',
backupFingerprint: options.backupFingerprint || null,
commands: [
{
name: 'apply-bootstrap-structure-and-backfill-repairs',
command: `node scripts/apply-master-data-foundation-migrations.js --phase=bootstrap-repair --apply --confirm=${CONFIRM_TOKEN} --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`,
purpose: '在 migration history 已补齐后,只应用缺失结构和可证明的 repairSql 回填,不提前应用最终治理护栏。',
dangerous: true,
},
{
name: 'apply-required-foundation-migrations',
command: `node scripts/apply-master-data-foundation-migrations.js --apply --confirm=${CONFIRM_TOKEN} --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>`,
purpose: '在结构修复和 baseline resolve 完成后,事务性应用治理迁移、视图、约束和触发器。',
dangerous: true,
},
{
name: 'verify-readiness',
command: 'npm run db:verify-master-data:readiness',
purpose: '应用后重新验证迁移历史、结构化主数据、外部只读契约和数据库护栏。',
dangerous: false,
},
],
}
}
function getMigrationsByPaths(paths, migrations = REQUIRED_MIGRATIONS) {
const pathSet = new Set(paths)
return migrations.filter((migration) => pathSet.has(migration.path))
}
function getUnreadyPathsAfterApply(after, appliedPaths) {
const appliedPathSet = new Set(appliedPaths)
return (after.checks || [])
.filter((migration) => appliedPathSet.has(migration.path) && migration.ready !== true)
.map((migration) => migration.path)
}
async function runApply(options = parseArgs(), env = process.env) {
validateRequiredMigrationCatalog()
const databaseUrl = loadDatabaseUrl(ROOT_DIR, env)
const actualDatabaseIdentity = getDatabaseIdentity(databaseUrl)
const backupFingerprint = getBackupFingerprint(env)
const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 })
const client = await pool.connect()
try {
const before = await getRequiredMigrationReadiness(client)
const plan = buildFoundationRequiredMigrationApplyPlan(before, {
...options,
actualDatabaseIdentity,
backupFingerprint,
})
const reportBase = {
...plan,
database: describeDatabaseUrl(databaseUrl),
checkedAt: new Date().toISOString(),
applied: null,
after: null,
}
if (!plan.readyToApply) {
return reportBase
}
await client.query('BEGIN')
try {
const targetMigrations = options.phase === 'bootstrap-repair'
? getMigrationsByPaths(plan.bootstrapRepairCandidates.map((candidate) => candidate.path))
: REQUIRED_MIGRATIONS
const appliedMigrationPaths = await applyMissingMigrationFiles(client, targetMigrations)
const after = await getRequiredMigrationReadiness(client)
if (options.phase === 'bootstrap-repair') {
const unreadyAppliedPaths = getUnreadyPathsAfterApply(after, appliedMigrationPaths)
if (unreadyAppliedPaths.length > 0) {
throw new Error(
`bootstrap-repair 应用后仍有目标迁移未就绪,已回滚本次事务:${unreadyAppliedPaths.join(', ')}`,
)
}
} else if (!after.fullyReady) {
throw new Error('治理迁移应用后仍未达到 fullyReady,已回滚本次事务。')
}
await client.query('COMMIT')
return {
...reportBase,
applied: {
phase: options.phase,
migrationCount: appliedMigrationPaths.length,
...classifyAppliedMigrationPaths(appliedMigrationPaths),
migrationPaths: appliedMigrationPaths,
},
after,
}
} catch (error) {
await client.query('ROLLBACK')
throw error
}
} finally {
client.release()
await pool.end()
}
}
async function main() {
const options = parseArgs()
const report = await runApply(options)
writeJsonReport(options.outputPath, report)
console.log(JSON.stringify(report, null, 2))
if (options.apply && !report.readyToApply) {
process.exit(1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
CONFIRM_TOKEN,
getBootstrapRepairPlan,
buildFoundationRequiredMigrationApplyPlan,
getBackupFingerprint,
getFailedDataChecks,
getMigrationsByPaths,
getUnreadyPathsAfterApply,
migrationHistoryReady,
normalizeSha256,
parseArgs,
runApply,
writeJsonReport,
}

View File

@ -0,0 +1,757 @@
const fs = require('node:fs')
const crypto = require('node:crypto')
const path = require('node:path')
const { Pool } = require('pg')
const {
buildCurrentEvidence,
buildEvidenceFingerprint,
databaseIdentityFromUrl,
getAllowedDecisions,
loadDatabaseUrl,
} = require('./plan-pre-sku-formalization-repair')
const {
getPreSkuFormalizationLifecycleMismatchSamples,
} = require('./verify-master-data-foundation')
const ROOT_DIR = path.resolve(__dirname, '..')
const APPLY_CONFIRMATION = 'I_UNDERSTAND_THIS_UPDATES_PRE_SKU_LIFECYCLE'
const EXECUTION_PLAN_MODE = 'pre-sku-formalization-repair-execution-plan'
const EXECUTION_PLAN_TOP_LEVEL_KEYS = new Set([
'mode',
'generatedAt',
'databaseIdentity',
'templateInstructions',
'candidateCount',
'confirmation',
'confirmedBy',
'decisions',
'outputFile',
])
const EXECUTION_DECISION_KEYS = new Set([
'preSkuId',
'mismatchReason',
'draftGroupId',
'draftGroupCode',
'sequence',
'decision',
'reason',
'formalSkuId',
'allowedDecisions',
'currentEvidence',
'evidenceFingerprint',
])
const SUPPORTED_DECISIONS = new Set([
'revert-pre-sku-status',
'link-existing-product-sku',
'archive-invalid-pre-sku',
])
const DISALLOWED_AUDIT_VALUES = new Set([
'unknown',
'unk',
'n/a',
'na',
'null',
'none',
'todo',
'tbd',
'default',
'test',
'测试',
'待补',
'待补齐',
'待确认',
'未知',
'默认',
'默认值',
'暂无',
])
function parseArgs(argv = process.argv.slice(2)) {
const args = {
template: false,
apply: false,
planPath: '',
outputPath: '',
confirm: '',
expectedDatabaseIdentity: '',
expectedBackupSha256: '',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--apply') {
args.apply = true
continue
}
if (arg === '--template') {
args.template = true
continue
}
if (arg === '--plan') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --plan <json-file>。')
args.planPath = value
index += 1
continue
}
if (arg.startsWith('--plan=')) {
args.planPath = arg.slice('--plan='.length)
if (!args.planPath) throw new Error('缺少 --plan <json-file>。')
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --output <json-file>。')
args.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
args.outputPath = arg.slice('--output='.length)
if (!args.outputPath) throw new Error('缺少 --output <json-file>。')
continue
}
if (arg === '--confirm') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --confirm <token>。')
args.confirm = value
index += 1
continue
}
if (arg.startsWith('--confirm=')) {
args.confirm = arg.slice('--confirm='.length)
continue
}
if (arg === '--expected-database-identity') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --expected-database-identity <identity>。')
}
args.expectedDatabaseIdentity = value
index += 1
continue
}
if (arg.startsWith('--expected-database-identity=')) {
args.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length)
continue
}
if (arg === '--expected-backup-sha256') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --expected-backup-sha256 <sha256>。')
}
args.expectedBackupSha256 = value
index += 1
continue
}
if (arg.startsWith('--expected-backup-sha256=')) {
args.expectedBackupSha256 = arg.slice('--expected-backup-sha256='.length)
continue
}
throw new Error(`未知参数:${arg}`)
}
if (args.template && args.apply) {
throw new Error('--template 只允许生成待确认计划,不能和 --apply 同时使用。')
}
if (args.template && args.planPath) {
throw new Error('--template 直接读取当前错位样本生成计划,不能同时提供 --plan。')
}
if (!args.template && !args.planPath) throw new Error('必须提供 --plan <json-file>。')
if (!args.apply && (args.confirm || args.expectedDatabaseIdentity || args.expectedBackupSha256)) {
throw new Error('--confirm / --expected-* 只允许和 --apply 一起使用。')
}
return args
}
function readText(value) {
return typeof value === 'string' ? value.trim() : ''
}
function isDisallowedAuditTextValue(value) {
const compact = readText(value).replace(/\s+/g, '').toLowerCase()
return DISALLOWED_AUDIT_VALUES.has(compact) || /待补|待确认|默认值/.test(compact)
}
function assertValidAuditText(value, scope) {
const text = readText(value)
if (!text) throw new Error(`${scope} 必须填写。`)
if (/[\u0000-\u001F\u007F]/.test(text)) {
throw new Error(`${scope} 包含控制字符,不能写入审计计划。`)
}
if (isDisallowedAuditTextValue(text)) {
throw new Error(`${scope} 不能填写占位值或默认值:${text}。`)
}
}
function readJsonPlan(planPath, rootDir = ROOT_DIR) {
const absolutePath = path.resolve(rootDir, planPath)
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'))
}
function writeJsonReport(outputPath, data, rootDir = ROOT_DIR) {
if (!outputPath) return ''
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
return absolutePath
}
function getBackupFingerprint(env = process.env, rootDir = ROOT_DIR) {
const rawPath = env.DATABASE_BACKUP_FILE || env.DB_BACKUP_FILE || ''
if (!rawPath) return { provided: false, path: '', exists: false, sha256: '' }
const absolutePath = path.resolve(rootDir, rawPath)
if (!fs.existsSync(absolutePath)) {
return { provided: true, path: absolutePath, exists: false, sha256: '' }
}
const bytes = fs.readFileSync(absolutePath)
return {
provided: true,
path: absolutePath,
exists: true,
sizeBytes: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
}
}
function normalizeSha256(value) {
return String(value || '').trim().toLowerCase()
}
function assertApplyExecutionEvidence(args, options = {}) {
if (!args.apply) return { actualDatabaseIdentity: '', backupFingerprint: null }
const actualDatabaseIdentity = databaseIdentityFromUrl(options.databaseUrl)
const backupFingerprint = getBackupFingerprint(options.env, options.rootDir)
const blockers = []
if (args.confirm !== APPLY_CONFIRMATION) blockers.push('confirm')
if (!args.expectedDatabaseIdentity) {
blockers.push('expected-database-identity')
} else if (args.expectedDatabaseIdentity !== actualDatabaseIdentity) {
blockers.push('expected-database-identity-mismatch')
}
if (!args.expectedBackupSha256) {
blockers.push('expected-backup-sha256')
} else if (
backupFingerprint.sha256 &&
normalizeSha256(args.expectedBackupSha256) !== normalizeSha256(backupFingerprint.sha256)
) {
blockers.push('expected-backup-sha256-mismatch')
}
if (backupFingerprint.provided !== true) {
blockers.push('backup-file-missing')
} else if (backupFingerprint.exists !== true) {
blockers.push('backup-file-not-found')
}
if (blockers.length > 0) {
throw new Error(`真正落库必须校验确认口令、目标库身份和数据库备份:${blockers.join(', ')}。`)
}
return { actualDatabaseIdentity, backupFingerprint }
}
function assertNoUnknownExecutionPlanKeys(plan) {
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
throw new Error('准 SKU 生命周期执行计划必须是 JSON 对象。')
}
const unknownKeys = Object.keys(plan).filter((key) => !EXECUTION_PLAN_TOP_LEVEL_KEYS.has(key))
if (unknownKeys.length > 0) {
throw new Error(`准 SKU 生命周期执行计划存在不允许的顶层字段:${unknownKeys.join(', ')}。`)
}
}
function buildExecutionPlanTemplate(samples = [], options = {}) {
const rows = Array.isArray(samples) ? samples : []
return {
mode: EXECUTION_PLAN_MODE,
generatedAt: new Date().toISOString(),
databaseIdentity: options.databaseIdentity || '',
templateInstructions: [
'这是待业务确认的执行计划模板,不会自动落库。',
'必须填写 confirmedBy、每个决策的 decision 和 reason;不能填写 UNKNOWN/待补齐/默认值。',
'如选择 link-existing-product-sku,必须填写 formalSkuId。',
`真正落库还必须设置 confirmation="${APPLY_CONFIRMATION}",并在命令中提供数据库身份和备份 SHA。`,
],
candidateCount: rows.length,
confirmation: '',
confirmedBy: '',
decisions: rows.map((row) => {
const currentEvidence = buildCurrentEvidence(row)
return {
preSkuId: row.preSkuId,
mismatchReason: row.mismatchReason,
draftGroupId: row.draftGroupId,
draftGroupCode: row.draftGroupCode,
sequence: row.sequence,
decision: '',
reason: '',
formalSkuId: '',
allowedDecisions: getAllowedDecisions(row),
currentEvidence,
evidenceFingerprint: buildEvidenceFingerprint(currentEvidence),
}
}),
}
}
function validateExecutionPlanShape(plan) {
const blockers = []
assertNoUnknownExecutionPlanKeys(plan)
if (plan.mode !== EXECUTION_PLAN_MODE) blockers.push('mode')
if (typeof plan.confirmedBy !== 'string' || !readText(plan.confirmedBy)) {
blockers.push('confirmedBy')
} else if (isDisallowedAuditTextValue(plan.confirmedBy)) {
blockers.push('confirmedBy.placeholder')
}
if (!Array.isArray(plan.decisions)) blockers.push('decisions')
const decisionBlockers = []
const seenPreSkuIds = new Set()
const decisions = Array.isArray(plan.decisions) ? plan.decisions : []
decisions.forEach((decision, index) => {
const issues = []
if (!decision || typeof decision !== 'object' || Array.isArray(decision)) {
decisionBlockers.push({ index, issues: ['decision'] })
return
}
const unknownKeys = Object.keys(decision).filter((key) => !EXECUTION_DECISION_KEYS.has(key))
if (unknownKeys.length > 0) issues.push(`unknownKeys:${unknownKeys.join(',')}`)
const preSkuId = readText(decision.preSkuId)
if (!preSkuId) issues.push('preSkuId')
if (preSkuId && seenPreSkuIds.has(preSkuId)) issues.push('duplicatePreSkuId')
seenPreSkuIds.add(preSkuId)
if (!SUPPORTED_DECISIONS.has(readText(decision.decision))) issues.push('decision')
if (!readText(decision.reason)) {
issues.push('reason')
} else if (isDisallowedAuditTextValue(decision.reason)) {
issues.push('reason.placeholder')
}
if (readText(decision.decision) === 'link-existing-product-sku' && !readText(decision.formalSkuId)) {
issues.push('formalSkuId')
}
if (!decision.currentEvidence || typeof decision.currentEvidence !== 'object') {
issues.push('currentEvidence')
} else if (decision.evidenceFingerprint !== buildEvidenceFingerprint(decision.currentEvidence)) {
issues.push('evidenceFingerprint')
}
if (issues.length > 0) decisionBlockers.push({ index, preSkuId, issues })
})
return {
mode: 'validate-pre-sku-formalization-repair-execution-plan-shape',
valid: blockers.length === 0 && decisionBlockers.length === 0,
committed: false,
blockerCount: blockers.length + decisionBlockers.length,
blockers,
decisionBlockers,
decisionCount: decisions.length,
}
}
function validateDecisionAgainstCurrent(decision, currentByPreSkuId, index) {
const currentRow = currentByPreSkuId.get(readText(decision.preSkuId))
if (!currentRow) {
return {
index,
preSkuId: readText(decision.preSkuId),
decision: readText(decision.decision),
ok: false,
readyToApply: false,
reason: 'current-mismatch-not-found',
}
}
const currentEvidence = buildCurrentEvidence(currentRow)
const currentFingerprint = buildEvidenceFingerprint(currentEvidence)
const evidenceMatched =
JSON.stringify(currentEvidence) === JSON.stringify(decision.currentEvidence)
const fingerprintMatched = currentFingerprint === decision.evidenceFingerprint
const blockers = []
if (!evidenceMatched || !fingerprintMatched) blockers.push('staleCurrentEvidence')
if (currentRow.mismatchReason !== 'formalized_without_product_sku') {
blockers.push('unsupportedMismatchReason')
}
if (!SUPPORTED_DECISIONS.has(readText(decision.decision))) blockers.push('unsupportedDecision')
return {
index,
preSkuId: readText(decision.preSkuId),
decision: readText(decision.decision),
ok: blockers.length === 0,
readyToApply: blockers.length === 0,
reason: blockers.length === 0 ? 'current-evidence-matched' : blockers.join(','),
evidenceFingerprintMatched: fingerprintMatched,
mismatchReason: currentRow.mismatchReason,
}
}
function validateExecutionPlanFreshness(plan, currentSamples = [], options = {}) {
const shape = validateExecutionPlanShape(plan)
const blockers = shape.blockers.map((key) => ({ key, source: 'plan-shape' }))
shape.decisionBlockers.forEach((blocker) => {
blockers.push({
key: 'decision-shape',
source: 'plan-shape',
decisionIndex: blocker.index,
issues: blocker.issues,
})
})
const currentByPreSkuId = new Map(
(Array.isArray(currentSamples) ? currentSamples : [])
.filter((row) => typeof row?.preSkuId === 'string' && row.preSkuId.length > 0)
.map((row) => [row.preSkuId, row]),
)
const decisionChecks = (Array.isArray(plan.decisions) ? plan.decisions : []).map(
(decision, index) => validateDecisionAgainstCurrent(decision, currentByPreSkuId, index),
)
decisionChecks
.filter((check) => !check.ok)
.forEach((check) => {
blockers.push({
key: 'decision-not-current',
source: 'database',
decisionIndex: check.index,
preSkuId: check.preSkuId,
reason: check.reason,
})
})
const actualDatabaseIdentity = options.databaseIdentity || ''
if (actualDatabaseIdentity && options.expectedDatabaseIdentity && actualDatabaseIdentity !== options.expectedDatabaseIdentity) {
blockers.push({
key: 'databaseIdentityMismatch',
source: 'database',
message: '当前数据库身份与预期数据库身份不一致。',
})
}
return {
mode: 'validate-pre-sku-formalization-repair-execution-plan',
valid: blockers.length === 0,
committed: false,
requiresDatabase: true,
databaseIdentity: actualDatabaseIdentity,
decisionCount: decisionChecks.length,
readyDecisionCount: decisionChecks.filter((check) => check.readyToApply).length,
staleDecisionCount: decisionChecks.filter((check) => !check.ok).length,
blockerCount: blockers.length,
blockers,
decisionChecks,
}
}
function assertValidationReadyForApply(validation, plan) {
if (!validation.valid) {
throw new Error(
`准 SKU 生命周期修复计划未通过当前证据校验:${validation.blockers.map((item) => item.key).join(', ')}`,
)
}
if (plan.confirmation !== APPLY_CONFIRMATION) {
throw new Error(`真正落库必须在计划文件中设置 confirmation="${APPLY_CONFIRMATION}"。`)
}
assertValidAuditText(plan.confirmedBy, '准 SKU 生命周期修复计划 confirmedBy')
}
function hasValidPackagingBoxes(value) {
if (!Array.isArray(value)) return false
return value.length > 0
}
async function syncDraftGroupStatusAfterPreSkuLifecycleChange(client, draftGroupId) {
const result = await client.query(`
SELECT
pre_sku."status"::text AS status,
packaging_plan."boxes" AS boxes
FROM "PreSku" pre_sku
LEFT JOIN "PreSkuPackagingPlan" packaging_plan
ON packaging_plan."preSkuId" = pre_sku."id"
WHERE pre_sku."draftGroupId" = $1
`, [draftGroupId])
const rows = result.rows
const nonArchived = rows.filter((row) => row.status !== 'ARCHIVED')
let nextStatus = 'ARCHIVED'
if (nonArchived.length > 0 && nonArchived.every((row) => row.status === 'FORMALIZED')) {
nextStatus = 'FORMALIZED'
} else if (nonArchived.length > 0) {
const remaining = nonArchived.filter((row) => row.status !== 'FORMALIZED')
nextStatus = remaining.every((row) => hasValidPackagingBoxes(row.boxes))
? 'READY_FOR_INTAKE'
: 'PACKAGING'
}
await client.query(`
UPDATE "DraftGroup"
SET "status" = $2::"DraftGroupStatus",
"updatedAt" = NOW()
WHERE "id" = $1
`, [draftGroupId, nextStatus])
return nextStatus
}
async function assertNoActiveFormalSkuForPreSku(client, preSkuId) {
const result = await client.query(`
SELECT COUNT(*)::integer AS row_count
FROM "ProductSKU"
WHERE "sourcePreSkuId" = $1
AND "deletedAt" IS NULL
`, [preSkuId])
if ((result.rows[0]?.row_count ?? 0) > 0) {
throw new Error(`准 SKU ${preSkuId} 已存在有效正式 SKU 关系,不能执行该生命周期修复。`)
}
}
async function applyRevertPreSkuStatus(client, decision) {
await assertNoActiveFormalSkuForPreSku(client, decision.preSkuId)
const result = await client.query(`
UPDATE "PreSku"
SET "status" = 'PREVIEW_CONFIRMED'::"PreSkuStatus",
"updatedAt" = NOW()
WHERE "id" = $1
AND "status" = 'FORMALIZED'::"PreSkuStatus"
RETURNING "id", "draftGroupId", "status"::text AS status
`, [decision.preSkuId])
const row = result.rows[0]
if (!row) throw new Error(`准 SKU ${decision.preSkuId} 当前状态不允许回退。`)
const draftGroupStatus = await syncDraftGroupStatusAfterPreSkuLifecycleChange(
client,
row.draftGroupId,
)
return {
preSkuId: row.id,
decision: decision.decision,
status: row.status,
draftGroupStatus,
}
}
async function applyLinkExistingProductSku(client, decision) {
const skuResult = await client.query(`
SELECT
sku."id",
sku."sourcePreSkuId",
sku."deletedAt",
product."deletedAt" AS "productDeletedAt"
FROM "ProductSKU" sku
JOIN "Product" product ON product."id" = sku."productId"
WHERE sku."id" = $1
FOR UPDATE OF sku, product
`, [decision.formalSkuId])
const sku = skuResult.rows[0]
if (!sku) throw new Error(`找不到正式 SKU:${decision.formalSkuId}`)
if (sku.deletedAt || sku.productDeletedAt) {
throw new Error(`正式 SKU ${decision.formalSkuId} 或所属产品已删除,不能补链。`)
}
if (sku.sourcePreSkuId && sku.sourcePreSkuId !== decision.preSkuId) {
throw new Error(`正式 SKU ${decision.formalSkuId} 已关联其他准 SKU,不能补链。`)
}
await client.query(`
UPDATE "ProductSKU"
SET "sourcePreSkuId" = $2,
"updatedAt" = NOW()
WHERE "id" = $1
`, [decision.formalSkuId, decision.preSkuId])
const preSkuResult = await client.query(`
UPDATE "PreSku"
SET "status" = 'FORMALIZED'::"PreSkuStatus",
"updatedAt" = NOW()
WHERE "id" = $1
RETURNING "id", "draftGroupId", "status"::text AS status
`, [decision.preSkuId])
const preSku = preSkuResult.rows[0]
const draftGroupStatus = await syncDraftGroupStatusAfterPreSkuLifecycleChange(
client,
preSku.draftGroupId,
)
return {
preSkuId: preSku.id,
formalSkuId: decision.formalSkuId,
decision: decision.decision,
status: preSku.status,
draftGroupStatus,
}
}
async function applyArchiveInvalidPreSku(client, decision) {
await assertNoActiveFormalSkuForPreSku(client, decision.preSkuId)
const relationResult = await client.query(`
SELECT
(SELECT COUNT(*)::integer FROM "PreSkuLocationBinding" WHERE "preSkuId" = $1) AS "locationBindingCount",
(
SELECT COUNT(*)::integer
FROM "PackagedItem" packaged_item
JOIN "PreSkuPackagingPlan" packaging_plan
ON packaging_plan."id" = packaged_item."preSkuPackagingPlanId"
WHERE packaging_plan."preSkuId" = $1
) AS "packagedItemCount"
`, [decision.preSkuId])
const relationCounts = relationResult.rows[0] || {}
if ((relationCounts.locationBindingCount ?? 0) > 0 || (relationCounts.packagedItemCount ?? 0) > 0) {
throw new Error(`准 SKU ${decision.preSkuId} 仍有库位或包装成品关系,不能直接归档。`)
}
const result = await client.query(`
UPDATE "PreSku"
SET "status" = 'ARCHIVED'::"PreSkuStatus",
"updatedAt" = NOW()
WHERE "id" = $1
RETURNING "id", "draftGroupId", "status"::text AS status
`, [decision.preSkuId])
const row = result.rows[0]
if (!row) throw new Error(`找不到准 SKU:${decision.preSkuId}`)
const draftGroupStatus = await syncDraftGroupStatusAfterPreSkuLifecycleChange(
client,
row.draftGroupId,
)
return {
preSkuId: row.id,
decision: decision.decision,
status: row.status,
draftGroupStatus,
}
}
async function applyDecision(client, decision) {
if (decision.decision === 'revert-pre-sku-status') {
return applyRevertPreSkuStatus(client, decision)
}
if (decision.decision === 'link-existing-product-sku') {
return applyLinkExistingProductSku(client, decision)
}
if (decision.decision === 'archive-invalid-pre-sku') {
return applyArchiveInvalidPreSku(client, decision)
}
throw new Error(`不支持的准 SKU 生命周期修复动作:${decision.decision}`)
}
function findRemainingTargetMismatches(decisions = [], currentSamples = []) {
const targetPreSkuIds = new Set(
(Array.isArray(decisions) ? decisions : [])
.map((decision) => readText(decision?.preSkuId))
.filter(Boolean),
)
return (Array.isArray(currentSamples) ? currentSamples : [])
.filter((row) => targetPreSkuIds.has(readText(row?.preSkuId)))
.map((row) => ({
preSkuId: readText(row.preSkuId),
mismatchReason: readText(row.mismatchReason),
draftGroupCode: readText(row.draftGroupCode),
linkedFormalSkuId: readText(row.linkedFormalSkuId),
}))
}
function assertNoRemainingTargetMismatches(decisions, currentSamples) {
const remaining = findRemainingTargetMismatches(decisions, currentSamples)
if (remaining.length > 0) {
throw new Error(
`准 SKU 生命周期修复后仍存在错位,已回滚:${remaining
.map((item) => `${item.preSkuId}:${item.mismatchReason}`)
.join(', ')}。`,
)
}
return {
checkedPreSkuIds: (Array.isArray(decisions) ? decisions : [])
.map((decision) => readText(decision?.preSkuId))
.filter(Boolean),
remainingMismatchCount: 0,
}
}
async function main() {
const args = parseArgs()
const databaseUrl = loadDatabaseUrl()
if (!databaseUrl) {
throw new Error('DATABASE_URL 未配置,无法校验准 SKU 生命周期修复计划。')
}
const executionEvidence = assertApplyExecutionEvidence(args, { databaseUrl })
const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 })
const client = await pool.connect()
try {
const samples = await getPreSkuFormalizationLifecycleMismatchSamples(client)
if (args.template) {
const template = buildExecutionPlanTemplate(samples, {
databaseIdentity: databaseIdentityFromUrl(databaseUrl),
})
const outputFile = writeJsonReport(args.outputPath, template)
console.log(JSON.stringify({ ...template, outputFile }, null, 2))
return
}
const plan = readJsonPlan(args.planPath)
const validation = validateExecutionPlanFreshness(plan, samples, {
databaseIdentity: databaseIdentityFromUrl(databaseUrl),
expectedDatabaseIdentity: args.expectedDatabaseIdentity,
})
if (!args.apply) {
const report = {
...validation,
dryRun: true,
committed: false,
rolledBack: true,
}
const outputFile = writeJsonReport(args.outputPath, report)
console.log(JSON.stringify({ ...report, outputFile }, null, 2))
if (!validation.valid) process.exitCode = 1
return
}
assertValidationReadyForApply(validation, plan)
await client.query('BEGIN')
const appliedResults = []
for (const decision of plan.decisions) {
appliedResults.push(await applyDecision(client, decision))
}
const remainingSamples = await getPreSkuFormalizationLifecycleMismatchSamples(client)
const postApplyVerification = assertNoRemainingTargetMismatches(
plan.decisions,
remainingSamples,
)
await client.query('COMMIT')
const report = {
mode: 'apply-pre-sku-formalization-repair',
committed: true,
rolledBack: false,
confirmedBy: readText(plan.confirmedBy),
decisionCount: plan.decisions.length,
appliedResults,
postApplyVerification,
executionEvidence,
}
const outputFile = writeJsonReport(args.outputPath, report)
console.log(JSON.stringify({ ...report, outputFile }, null, 2))
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined)
throw error
} finally {
client.release()
await pool.end()
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
APPLY_CONFIRMATION,
EXECUTION_PLAN_MODE,
parseArgs,
readText,
isDisallowedAuditTextValue,
assertValidAuditText,
readJsonPlan,
writeJsonReport,
getBackupFingerprint,
normalizeSha256,
assertApplyExecutionEvidence,
assertNoUnknownExecutionPlanKeys,
buildExecutionPlanTemplate,
validateExecutionPlanShape,
validateDecisionAgainstCurrent,
validateExecutionPlanFreshness,
assertValidationReadyForApply,
findRemainingTargetMismatches,
assertNoRemainingTargetMismatches,
syncDraftGroupStatusAfterPreSkuLifecycleChange,
applyDecision,
}

View File

@ -0,0 +1,320 @@
const fs = require('node:fs')
const crypto = require('node:crypto')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const {
describeDatabaseUrl,
getDatabaseIdentity,
loadDatabaseUrl,
} = require('./diagnose-prisma-migration-baseline')
const ROOT_DIR = path.resolve(__dirname, '..')
const PRISMA_CLI = path.join(ROOT_DIR, 'node_modules/prisma/build/index.js')
const CONFIRM_TOKEN = 'apply-prisma-baseline-resolve'
function parseArgs(argv = process.argv.slice(2)) {
const options = {
apply: false,
confirm: '',
preflightPath: '',
migrationId: '',
expectedDatabaseIdentity: '',
expectedBackupSha256: '',
outputPath: '',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--apply') {
options.apply = true
continue
}
if (arg.startsWith('--confirm=')) {
options.confirm = arg.slice('--confirm='.length)
continue
}
if (arg === '--preflight') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --preflight <json-file>。')
options.preflightPath = value
index += 1
continue
}
if (arg.startsWith('--preflight=')) {
options.preflightPath = arg.slice('--preflight='.length)
if (!options.preflightPath) throw new Error('缺少 --preflight <json-file>。')
continue
}
if (arg === '--migration-id') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --migration-id <id>。')
options.migrationId = value
index += 1
continue
}
if (arg.startsWith('--migration-id=')) {
options.migrationId = arg.slice('--migration-id='.length)
if (!options.migrationId) throw new Error('缺少 --migration-id <id>。')
continue
}
if (arg === '--expected-database-identity') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --expected-database-identity <identity>。')
options.expectedDatabaseIdentity = value
index += 1
continue
}
if (arg.startsWith('--expected-database-identity=')) {
options.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length)
continue
}
if (arg === '--expected-backup-sha256') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --expected-backup-sha256 <sha256>。')
options.expectedBackupSha256 = value
index += 1
continue
}
if (arg.startsWith('--expected-backup-sha256=')) {
options.expectedBackupSha256 = arg.slice('--expected-backup-sha256='.length)
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new Error('缺少 --output <json-file>。')
options.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputPath = arg.slice('--output='.length)
if (!options.outputPath) throw new Error('缺少 --output <json-file>。')
continue
}
throw new Error(`未知参数:${arg}`)
}
return options
}
function readJson(filePath, rootDir = ROOT_DIR) {
const absolutePath = path.resolve(rootDir, filePath)
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'))
}
function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
if (!outputPath) return ''
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return absolutePath
}
function databaseIdentityFromDescription(description = {}) {
return [
description.protocol || '',
String(description.username || '').toLowerCase(),
String(description.host || '').toLowerCase(),
description.port || '',
String(description.databaseName || '').toLowerCase(),
String(description.schema || '').toLowerCase(),
].join('|')
}
function getBackupFingerprintFromPreflight(preflight = {}) {
const backupPath = preflight.backupEvidence?.path || ''
if (!backupPath) return { provided: false, path: '', exists: false, sha256: '' }
if (!fs.existsSync(backupPath)) return { provided: true, path: backupPath, exists: false, sha256: '' }
const bytes = fs.readFileSync(backupPath)
return {
provided: true,
path: backupPath,
exists: true,
sizeBytes: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
}
}
function migrationDirectoryExists(migrationId, rootDir = ROOT_DIR) {
if (!migrationId) return false
const migrationPath = path.join(rootDir, 'prisma', 'migrations', migrationId)
return fs.existsSync(migrationPath) && fs.statSync(migrationPath).isDirectory()
}
function buildBaselineResolvePlan(preflight, options = {}) {
const blockers = []
if (preflight?.recommendedAction !== 'baseline-resolve-candidate') {
blockers.push({
key: 'recommendedAction',
message: '只有 preflight 明确为 baseline-resolve-candidate 时,才允许补迁移历史。',
})
}
if (preflight?.currentSchemaDiffHasSql) {
blockers.push({
key: 'currentSchemaDiff.notEmpty',
message: '当前库到 schema.prisma 仍有结构 diff,不能只补迁移历史。',
})
}
if (preflight?.migrationDiff?.status !== 0) {
blockers.push({
key: 'migrationDiff.notEmpty',
message: 'migrations 重放结果和当前库不等价,不能 baseline resolve。',
})
}
if (preflight?.migrationSchemaDiff?.status !== 0) {
blockers.push({
key: 'migrationSchemaDiff.notEmpty',
message: 'migrations 重放结果和 schema.prisma 不等价,不能 baseline resolve。',
})
}
if (!options.migrationId) {
blockers.push({ key: 'migrationId.missing', message: '必须指定单个 --migration-id。' })
} else if (!migrationDirectoryExists(options.migrationId, options.rootDir)) {
blockers.push({ key: 'migrationId.notFound', message: '指定的 migration id 不在 prisma/migrations 中。' })
}
if (options.apply && options.confirm !== CONFIRM_TOKEN) {
blockers.push({ key: 'confirm', message: `真实执行必须显式传入 --confirm=${CONFIRM_TOKEN}。` })
}
const expectedPreflightIdentity = databaseIdentityFromDescription(preflight?.database)
if (options.apply && !options.actualDatabaseIdentity) {
blockers.push({
key: 'databaseIdentity.missing',
message: '真实执行必须读取当前 DATABASE_URL 并和 preflight 数据库身份比对。',
})
} else if (
options.apply &&
expectedPreflightIdentity &&
options.actualDatabaseIdentity !== expectedPreflightIdentity
) {
blockers.push({
key: 'databaseIdentity.mismatch',
message: '当前 DATABASE_URL 指向的数据库和 preflight 报告不一致,禁止 baseline resolve。',
})
}
if (options.apply && !options.expectedDatabaseIdentity) {
blockers.push({
key: 'expectedDatabaseIdentity.missing',
message: '真实执行必须显式传入 --expected-database-identity,避免误连错库。',
})
} else if (
options.apply &&
options.actualDatabaseIdentity &&
options.expectedDatabaseIdentity !== options.actualDatabaseIdentity
) {
blockers.push({
key: 'expectedDatabaseIdentity.mismatch',
message: '命令行指定的目标库身份和当前 DATABASE_URL 不一致,禁止 baseline resolve。',
})
}
if (options.apply && options.backupFingerprint?.provided !== true) {
blockers.push({
key: 'backupFingerprint.missing',
message: '真实执行必须能从 preflight 报告重新定位备份文件并计算指纹。',
})
} else if (options.apply && options.backupFingerprint?.exists !== true) {
blockers.push({
key: 'backupFingerprint.notFound',
message: 'preflight 记录的备份文件不存在,必须重新备份和重新 preflight。',
})
}
if (
options.apply &&
options.backupFingerprint?.sha256 &&
preflight?.backupEvidence?.sha256 &&
options.backupFingerprint.sha256 !== preflight.backupEvidence.sha256
) {
blockers.push({
key: 'backupFingerprint.sha256Mismatch',
message: '备份文件当前 SHA-256 和 preflight 记录不一致,必须重新备份和重新 preflight。',
})
}
if (options.apply && !options.expectedBackupSha256) {
blockers.push({
key: 'expectedBackupSha256.missing',
message: '真实执行必须显式传入 --expected-backup-sha256,避免错用备份文件。',
})
} else if (
options.apply &&
options.backupFingerprint?.sha256 &&
options.expectedBackupSha256 !== options.backupFingerprint.sha256
) {
blockers.push({
key: 'expectedBackupSha256.mismatch',
message: '命令行指定的备份 SHA-256 和当前备份文件不一致,禁止 baseline resolve。',
})
}
return {
mode: 'prisma-baseline-resolve-apply-plan',
dryRun: !options.apply,
readyToApply: options.apply === true && blockers.length === 0,
confirmToken: CONFIRM_TOKEN,
migrationId: options.migrationId || '',
database: preflight?.database || null,
expectedDatabaseIdentity: options.expectedDatabaseIdentity || '',
actualDatabaseIdentity: options.actualDatabaseIdentity || '',
backupFingerprint: options.backupFingerprint || null,
blockers,
commands: [
{
name: 'resolve-one-migration',
command: 'npm run db:foundation:baseline-resolve -- --apply --confirm=apply-prisma-baseline-resolve --preflight <fresh-preflight.json> --migration-id <migration_id> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
purpose: '仅在结构等价且证据新鲜时,对单个 migration id 执行 Prisma baseline resolve。',
dangerous: true,
},
{
name: 'verify-readiness',
command: 'npm run db:verify-master-data:readiness',
purpose: '每次 baseline resolve 后重新验证迁移历史和剩余阻断项。',
dangerous: false,
},
],
}
}
function runPrismaMigrateResolve(migrationId) {
return spawnSync(process.execPath, [PRISMA_CLI, 'migrate', 'resolve', '--applied', migrationId], {
cwd: ROOT_DIR,
encoding: 'utf8',
stdio: 'inherit',
windowsHide: true,
})
}
async function main() {
const options = parseArgs()
const preflight = readJson(options.preflightPath || '')
const databaseUrl = loadDatabaseUrl(ROOT_DIR)
const actualDatabaseIdentity = getDatabaseIdentity(databaseUrl)
const backupFingerprint = getBackupFingerprintFromPreflight(preflight)
const plan = buildBaselineResolvePlan(preflight, {
...options,
actualDatabaseIdentity,
backupFingerprint,
})
writeJsonReport(options.outputPath, plan)
console.log(JSON.stringify(plan, null, 2))
if (!plan.readyToApply) {
process.exit(1)
}
const resolved = runPrismaMigrateResolve(options.migrationId)
if (resolved.status !== 0) {
process.exit(resolved.status || 1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
buildBaselineResolvePlan,
databaseIdentityFromDescription,
getBackupFingerprintFromPreflight,
parseArgs,
writeJsonReport,
}

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,24 @@
import 'dotenv/config'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { prisma } from '../src/lib/prisma'
import { assetCenterService } from '../src/server/services/asset-center.service'
import {
ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL,
assetVariantThumbnailService,
} from '../src/server/services/asset-variant-thumbnail.service'
const CONFIRM_TOKEN = 'backfill-asset-variant-thumbnails'
const ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL = '/asset-thumbnails/placeholder.svg'
type PrismaSingleton = typeof import('../src/lib/prisma')['prisma']
type AssetCenterService = typeof import('../src/server/services/asset-center.service')['assetCenterService']
type AssetVariantThumbnailService = typeof import('../src/server/services/asset-variant-thumbnail.service')['assetVariantThumbnailService']
let prisma: PrismaSingleton
let assetCenterService: AssetCenterService
let assetVariantThumbnailService: AssetVariantThumbnailService
interface BackfillOptions {
renderBaseUrl: string
force: boolean
concurrency: number
limit: number | null
apply: boolean
}
interface BackfillTask {
@ -62,6 +69,60 @@ function resolveOptions(): BackfillOptions {
parsedLimit !== null && Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.floor(parsedLimit)
: null,
apply: readFlag('--apply'),
}
}
function databaseIdentity(databaseUrl: string): string {
const parsed = new URL(databaseUrl)
return [
parsed.protocol.replace(/:$/, ''),
parsed.username.toLowerCase(),
parsed.hostname.toLowerCase(),
parsed.port,
parsed.pathname.replace(/^\//, '').toLowerCase(),
(parsed.searchParams.get('schema') || parsed.searchParams.get('currentSchema') || '').toLowerCase(),
].join('|')
}
function backupSha256(): string {
const rawPath = process.env.DATABASE_BACKUP_FILE || process.env.DB_BACKUP_FILE || ''
if (!rawPath) {
throw new Error('资产缩略图回填真实写库必须通过 DATABASE_BACKUP_FILE 或 DB_BACKUP_FILE 指定当前数据库备份。')
}
const absolutePath = path.resolve(process.cwd(), rawPath)
if (!fs.existsSync(absolutePath)) {
throw new Error('资产缩略图回填指定的数据库备份文件不存在。')
}
return crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex')
}
function assertApplyAllowed() {
if (!process.argv.includes('--apply')) {
throw new Error('资产缩略图回填默认禁止写库/生成文件;确认目标环境后请显式追加 --apply。')
}
if (readOption('--confirm') !== CONFIRM_TOKEN) {
throw new Error(`资产缩略图回填真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`)
}
const databaseUrl = process.env.DATABASE_URL || ''
if (!databaseUrl) {
throw new Error('资产缩略图回填真实写库必须先配置 DATABASE_URL。')
}
const expectedDatabaseIdentity = readOption('--expected-database-identity')
if (!expectedDatabaseIdentity) {
throw new Error(
`资产缩略图回填真实写库必须显式传入 --expected-database-identity=${databaseIdentity(databaseUrl)}。`,
)
}
if (expectedDatabaseIdentity !== databaseIdentity(databaseUrl)) {
throw new Error('资产缩略图回填目标库身份和当前 DATABASE_URL 不一致,禁止写库。')
}
const expectedBackupSha256 = readOption('--expected-backup-sha256')
if (!expectedBackupSha256) {
throw new Error('资产缩略图回填真实写库必须显式传入 --expected-backup-sha256。')
}
if (expectedBackupSha256 !== backupSha256()) {
throw new Error('资产缩略图回填备份 SHA-256 和当前备份文件不一致,禁止写库。')
}
}
@ -131,6 +192,15 @@ async function runWithConcurrency<T>(
async function main() {
const options = resolveOptions()
assertApplyAllowed()
const [prismaModule, assetCenterModule, thumbnailModule] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/asset-center.service'),
import('../src/server/services/asset-variant-thumbnail.service'),
])
prisma = prismaModule.prisma
assetCenterService = assetCenterModule.assetCenterService
assetVariantThumbnailService = thumbnailModule.assetVariantThumbnailService
console.log('开始批量补生成资产缩略图。')
console.log(JSON.stringify(options, null, 2))
@ -194,6 +264,6 @@ main()
process.exitCode = 1
})
.finally(async () => {
await assetVariantThumbnailService.shutdown()
await prisma.$disconnect()
await assetVariantThumbnailService?.shutdown()
await prisma?.$disconnect()
})

View File

@ -1,9 +1,75 @@
import 'dotenv/config'
import { prisma } from '../src/lib/prisma'
import { readSkuCadDataFromAttributes } from '../src/lib/sku-cad-data'
import { ensureSkuStructuralData } from '../src/server/services/sku-cad-import.helper'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
const CONFIRM_TOKEN = 'backfill-sku-structural-data'
function readOption(name: string): string {
const prefix = `${name}=`
const inline = process.argv.find((arg) => arg.startsWith(prefix))
if (inline) return inline.slice(prefix.length)
const index = process.argv.indexOf(name)
if (index === -1) return ''
const value = process.argv[index + 1] || ''
return value.startsWith('--') ? '' : value
}
function databaseIdentity(databaseUrl: string): 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('|')
}
function backupSha256(): string {
const backupPath = process.env.DATABASE_BACKUP_FILE || process.env.DB_BACKUP_FILE || ''
if (!backupPath) return ''
const absolutePath = path.resolve(process.cwd(), backupPath)
if (!fs.existsSync(absolutePath)) return ''
return crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex')
}
function assertApplyGuards() {
if (!process.argv.includes('--apply')) return
if (readOption('--confirm') !== CONFIRM_TOKEN) {
throw new Error(`SKU 结构数据回填真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`)
}
const databaseUrl = process.env.DATABASE_URL || ''
if (!databaseUrl) {
throw new Error('SKU 结构数据回填真实写库必须先配置 DATABASE_URL。')
}
const expectedDatabaseIdentity = readOption('--expected-database-identity')
if (!expectedDatabaseIdentity) {
throw new Error('SKU 结构数据回填真实写库必须显式传入 --expected-database-identity。')
}
if (expectedDatabaseIdentity !== databaseIdentity(databaseUrl)) {
throw new Error('SKU 结构数据回填目标库身份和当前 DATABASE_URL 不一致,禁止写库。')
}
const expectedBackupSha256 = readOption('--expected-backup-sha256')
if (!expectedBackupSha256) {
throw new Error('SKU 结构数据回填真实写库必须显式传入 --expected-backup-sha256。')
}
if (expectedBackupSha256 !== backupSha256()) {
throw new Error('SKU 结构数据回填备份 sha256 和当前 DATABASE_BACKUP_FILE 不一致,禁止写库。')
}
}
async function main() {
assertApplyGuards()
const apply = process.argv.includes('--apply')
const [{ prisma }, { readSkuCadDataFromAttributes }, { ensureSkuStructuralData }] =
await Promise.all([
import('../src/lib/prisma'),
import('../src/lib/sku-cad-data'),
import('../src/server/services/sku-cad-import.helper'),
])
const skus = await prisma.productSKU.findMany({
where: { deletedAt: null },
select: {
@ -16,43 +82,56 @@ async function main() {
})
let scanned = 0
let candidates = 0
let filled = 0
let skipped = 0
for (const sku of skus) {
scanned += 1
const cadData = readSkuCadDataFromAttributes(
(sku.attributes ?? null) as Record<string, unknown> | null
)
try {
for (const sku of skus) {
scanned += 1
const cadData = readSkuCadDataFromAttributes(
(sku.attributes ?? null) as Record<string, unknown> | null
)
if (!cadData) {
skipped += 1
continue
if (!cadData) {
skipped += 1
continue
}
const existingPanelsCount = await prisma.panel.count({
where: { skuId: sku.id },
})
if (existingPanelsCount > 0) {
skipped += 1
continue
}
candidates += 1
if (!apply) {
console.log(`[backfill] dry-run candidate ${sku.skuCode}`)
continue
}
await ensureSkuStructuralData({
productId: sku.productId,
skuId: sku.id,
cadSourceData: cadData,
})
filled += 1
console.log(`[backfill] filled ${sku.skuCode}`)
}
const existingPanelsCount = await prisma.panel.count({
where: { skuId: sku.id },
})
if (existingPanelsCount > 0) {
skipped += 1
continue
}
await ensureSkuStructuralData({
productId: sku.productId,
skuId: sku.id,
cadSourceData: cadData,
})
filled += 1
console.log(`[backfill] filled ${sku.skuCode}`)
} finally {
await prisma.$disconnect()
}
console.log(
JSON.stringify(
{
apply,
scanned,
candidates,
filled,
skipped,
},
@ -67,6 +146,3 @@ main()
console.error(error)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -1,15 +1,75 @@
import 'dotenv/config'
import { prisma } from '../src/lib/prisma'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { hasSkuCadCompatibilityData } from '../src/lib/sku-cad-data'
function hasSkuCadData(attributes: unknown): boolean {
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
return false
const CONFIRM_TOKEN = 'cleanup-legacy-product-structure'
type PrismaSingleton = typeof import('../src/lib/prisma')['prisma']
let prisma: PrismaSingleton
function readOption(name: string): string {
const inline = process.argv.find((arg) => arg.startsWith(`${name}=`))
if (inline) return inline.slice(name.length + 1).trim()
const index = process.argv.findIndex((arg) => arg === name)
const value = index >= 0 ? process.argv[index + 1] : ''
return value && !value.startsWith('--') ? value.trim() : ''
}
function databaseIdentity(databaseUrl: string): string {
const parsed = new URL(databaseUrl)
return [
parsed.protocol.replace(/:$/, ''),
parsed.username.toLowerCase(),
parsed.hostname.toLowerCase(),
parsed.port,
parsed.pathname.replace(/^\//, '').toLowerCase(),
(parsed.searchParams.get('schema') || parsed.searchParams.get('currentSchema') || '').toLowerCase(),
].join('|')
}
function backupSha256(): string {
const rawPath = process.env.DATABASE_BACKUP_FILE || process.env.DB_BACKUP_FILE || ''
if (!rawPath) {
throw new Error('legacy cleanup apply 必须通过 DATABASE_BACKUP_FILE 或 DB_BACKUP_FILE 指定当前数据库备份。')
}
const absolutePath = path.resolve(process.cwd(), rawPath)
if (!fs.existsSync(absolutePath)) {
throw new Error('legacy cleanup apply 指定的数据库备份文件不存在。')
}
return crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex')
}
function assertApplyAllowed() {
if (!process.argv.includes('--apply')) return
if (readOption('--confirm') !== CONFIRM_TOKEN) {
throw new Error(`legacy cleanup apply 必须显式传入 --confirm=${CONFIRM_TOKEN}。`)
}
const databaseUrl = process.env.DATABASE_URL || ''
if (!databaseUrl) {
throw new Error('legacy cleanup apply 必须先配置 DATABASE_URL。')
}
const expectedDatabaseIdentity = readOption('--expected-database-identity')
if (!expectedDatabaseIdentity) {
throw new Error('legacy cleanup apply 必须显式传入 --expected-database-identity。')
}
if (expectedDatabaseIdentity !== databaseIdentity(databaseUrl)) {
throw new Error('legacy cleanup apply 的目标库身份和当前 DATABASE_URL 不一致。')
}
const expectedBackupSha256 = readOption('--expected-backup-sha256')
if (!expectedBackupSha256) {
throw new Error('legacy cleanup apply 必须显式传入 --expected-backup-sha256。')
}
if (expectedBackupSha256 !== backupSha256()) {
throw new Error('legacy cleanup apply 的备份 SHA-256 和当前备份文件不一致。')
}
return Object.prototype.hasOwnProperty.call(attributes, '_skuCadData')
}
async function main() {
const apply = process.argv.includes('--apply')
assertApplyAllowed()
;({ prisma } = await import('../src/lib/prisma'))
const products = await prisma.product.findMany({
where: { deletedAt: null },
@ -46,7 +106,7 @@ async function main() {
}> = []
for (const product of products) {
const activeSkuWithCadCount = product.skus.filter((sku) => hasSkuCadData(sku.attributes)).length
const activeSkuWithCadCount = product.skus.filter((sku) => hasSkuCadCompatibilityData(sku.attributes)).length
if (activeSkuWithCadCount <= 0) continue
const [panelCount, holeCount, hardwareCount] = await Promise.all([

View File

@ -3,12 +3,38 @@
* 创建演示数据脚本
* 用于快速生成测试产品和装配步骤
*/
const DEMO_CONFIRM_TOKEN = 'reset-local-demo'
let prisma = null
let pool = null
const { PrismaClient } = require('@prisma/client')
function assertLocalDestructiveDemoAllowed() {
if (!process.argv.includes(`--confirm=${DEMO_CONFIRM_TOKEN}`)) {
throw new Error(`拒绝执行 destructive demo seed:必须显式追加 --confirm=${DEMO_CONFIRM_TOKEN}。`)
}
if (process.env.NODE_ENV === 'production') {
throw new Error('拒绝执行 destructive demo seed:NODE_ENV=production。')
}
const databaseUrl = process.env.DATABASE_URL || ''
if (!databaseUrl) {
throw new Error('拒绝执行 destructive demo seed:DATABASE_URL 未配置。')
}
const parsed = new URL(databaseUrl)
if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) {
throw new Error('拒绝执行 destructive demo seed:只允许本机数据库。')
}
}
const prisma = new PrismaClient()
function createPrismaClient() {
const { PrismaClient } = require('@prisma/client')
const { PrismaPg } = require('@prisma/adapter-pg')
const { Pool } = require('pg')
pool = new Pool({ connectionString: process.env.DATABASE_URL })
prisma = new PrismaClient({ adapter: new PrismaPg(pool) })
}
async function createDemoData() {
assertLocalDestructiveDemoAllowed()
createPrismaClient()
console.log('🚀 开始创建演示数据...')
try {
@ -207,7 +233,8 @@ async function createDemoData() {
console.error('❌ 创建演示数据失败:', error)
throw error
} finally {
await prisma.$disconnect()
await prisma?.$disconnect()
await pool?.end()
}
}

View File

@ -0,0 +1,399 @@
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const {
describeDatabaseUrl,
loadDatabaseUrl,
} = require('./diagnose-prisma-migration-baseline')
const {
checkCommandAvailable,
checkDockerAvailability,
resolvePostgresTool,
verifyBackupFile,
} = require('./preflight-database-foundation-change')
const ROOT_DIR = path.resolve(__dirname, '..')
function sanitizeFileName(value) {
return String(value || 'database')
.replace(/[^A-Za-z0-9_.-]+/g, '_')
.replace(/^_+|_+$/g, '')
|| 'database'
}
function timestampForFile(date = new Date()) {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
}
function buildBackupFilePath({ databaseName, outputDir, now = new Date() }) {
const safeDatabaseName = sanitizeFileName(databaseName)
return path.resolve(outputDir, `${safeDatabaseName}-${timestampForFile(now)}.dump`)
}
function parsePostgresUrl(databaseUrl) {
const parsed = new URL(databaseUrl)
return {
host: parsed.hostname,
port: parsed.port,
databaseName: parsed.pathname.replace(/^\//, ''),
username: decodeURIComponent(parsed.username || ''),
password: decodeURIComponent(parsed.password || ''),
}
}
function buildPgDumpArgs({ databaseUrl, outputFile }) {
const parsed = parsePostgresUrl(databaseUrl)
const args = [
'--format=custom',
'--no-owner',
'--no-privileges',
'--file',
outputFile,
]
if (parsed.host) args.push('--host', parsed.host)
if (parsed.port) args.push('--port', parsed.port)
if (parsed.username) args.push('--username', parsed.username)
if (parsed.databaseName) args.push('--dbname', parsed.databaseName)
return {
args,
env: parsed.password ? { PGPASSWORD: parsed.password } : {},
}
}
function resolveDockerDatabaseHost(host) {
if (['localhost', '127.0.0.1', '::1'].includes(String(host || '').toLowerCase())) {
return process.platform === 'win32' ? 'host.docker.internal' : 'localhost'
}
return host
}
function buildDockerVolumeArg(hostDir) {
return `${path.resolve(hostDir)}:/backup`
}
function buildDockerEnvFlagArgs(envVars = {}) {
return Object.keys(envVars)
.filter((key) => envVars[key])
.flatMap((key) => ['--env', key])
}
function buildDockerPgDumpArgs({ databaseUrl, outputFile }) {
const parsed = parsePostgresUrl(databaseUrl)
const containerOutputFile = `/backup/${path.basename(outputFile)}`
const args = [
'pg_dump',
'--format=custom',
'--no-owner',
'--no-privileges',
'--file',
containerOutputFile,
]
const host = resolveDockerDatabaseHost(parsed.host)
if (host) args.push('--host', host)
if (parsed.port) args.push('--port', parsed.port)
if (parsed.username) args.push('--username', parsed.username)
if (parsed.databaseName) args.push('--dbname', parsed.databaseName)
return {
args,
env: parsed.password ? { PGPASSWORD: parsed.password } : {},
host,
containerOutputFile,
volumeArg: buildDockerVolumeArg(path.dirname(outputFile)),
}
}
function parseBackupCliArgs(argv = process.argv.slice(2), env = process.env) {
let outputDir = env.DATABASE_BACKUP_DIR || env.DB_BACKUP_DIR || path.join(ROOT_DIR, '.database-backups')
let useDocker = env.POSTGRES_BACKUP_USE_DOCKER === '1'
let allowDockerPull = env.POSTGRES_BACKUP_ALLOW_DOCKER_PULL === '1'
let dockerImage = env.POSTGRES_DOCKER_IMAGE || 'postgres:16'
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--output-dir') {
outputDir = argv[index + 1] || ''
index += 1
continue
}
if (arg.startsWith('--output-dir=')) {
outputDir = arg.slice('--output-dir='.length)
continue
}
if (arg === '--use-docker') {
useDocker = true
continue
}
if (arg === '--allow-docker-pull') {
allowDockerPull = true
continue
}
if (arg === '--docker-image') {
dockerImage = argv[index + 1] || ''
index += 1
continue
}
if (arg.startsWith('--docker-image=')) {
dockerImage = arg.slice('--docker-image='.length)
continue
}
throw new Error(`未知参数:${arg}`)
}
if (!outputDir) {
throw new Error('备份输出目录不能为空。')
}
if (useDocker && !dockerImage) {
throw new Error('Docker 备份镜像不能为空。')
}
return {
allowDockerPull,
dockerImage,
outputDir: path.resolve(ROOT_DIR, outputDir),
useDocker,
}
}
function createBackup({
databaseUrl,
outputDir = path.join(ROOT_DIR, '.database-backups'),
env = process.env,
useDocker = false,
allowDockerPull = false,
dockerImage = env.POSTGRES_DOCKER_IMAGE || 'postgres:16',
}) {
const pgDump = checkCommandAvailable('pg_dump', ['--version'], env)
if (!pgDump.available) {
if (useDocker) {
return createBackupWithDocker({
allowDockerPull,
databaseUrl,
dockerImage,
env,
outputDir,
})
}
throw new Error([
'pg_dump 不可用,无法生成 PostgreSQL 标准备份。',
`当前检测入口:${pgDump.executable}(${pgDump.configuredBy})。`,
'可通过 PG_DUMP_PATH、POSTGRES_BIN_DIR 或 PG_BIN_DIR 指定 PostgreSQL client 工具目录。',
].join('\n'))
}
const database = describeDatabaseUrl(databaseUrl)
const outputFile = buildBackupFilePath({
databaseName: database.databaseName,
outputDir,
})
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
const pgDumpCommand = buildPgDumpArgs({ databaseUrl, outputFile })
const pgDumpTool = resolvePostgresTool('pg_dump', env)
const result = spawnSync(pgDumpTool.executable, pgDumpCommand.args, {
cwd: ROOT_DIR,
encoding: 'utf8',
env: {
...process.env,
...pgDumpCommand.env,
},
windowsHide: true,
})
if (result.status !== 0) {
throw new Error([
'pg_dump 执行失败。',
result.stderr ? `stderr=${result.stderr.trim()}` : '',
result.error ? `error=${result.error.message}` : '',
].filter(Boolean).join('\n'))
}
const sizeBytes = fs.existsSync(outputFile) ? fs.statSync(outputFile).size : 0
if (sizeBytes <= 0) {
throw new Error('pg_dump 完成但备份文件为空,拒绝把它当作可用备份。')
}
const pgRestore = checkCommandAvailable('pg_restore', ['--version'], env)
const verification = verifyBackupFile({
usable: true,
path: outputFile,
}, {
pgRestore,
}, env)
if (!verification.ok) {
throw new Error(verification.message)
}
return {
outputFile,
sizeBytes,
database,
pgDump: {
executable: pgDumpTool.executable,
configuredBy: pgDumpTool.configuredBy,
},
backupVerification: verification,
}
}
function assertDockerBackupReady({ dockerImage, allowDockerPull, env }) {
const docker = checkDockerAvailability({ ...env, POSTGRES_DOCKER_IMAGE: dockerImage })
if (!docker.available) {
throw new Error('Docker 不可用,无法使用 Docker 兜底生成 PostgreSQL 备份。')
}
if (!docker.postgresImageAvailable && !allowDockerPull) {
throw new Error([
`Docker 镜像 ${dockerImage} 不存在。`,
'为避免隐式下载镜像,默认拒绝继续。',
'请先准备镜像,或显式传入 --allow-docker-pull。',
].join('\n'))
}
return docker
}
function createBackupWithDocker({
databaseUrl,
outputDir = path.join(ROOT_DIR, '.database-backups'),
env = process.env,
dockerImage = 'postgres:16',
allowDockerPull = false,
}) {
const docker = assertDockerBackupReady({ dockerImage, allowDockerPull, env })
const database = describeDatabaseUrl(databaseUrl)
const outputFile = buildBackupFilePath({
databaseName: database.databaseName,
outputDir,
})
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
const dockerPgDump = buildDockerPgDumpArgs({ databaseUrl, outputFile })
const dockerArgs = [
'run',
'--rm',
'-v',
dockerPgDump.volumeArg,
...buildDockerEnvFlagArgs(dockerPgDump.env),
dockerImage,
...dockerPgDump.args,
]
const result = spawnSync('docker', dockerArgs, {
cwd: ROOT_DIR,
encoding: 'utf8',
env: {
...process.env,
...env,
...dockerPgDump.env,
},
windowsHide: true,
})
if (result.status !== 0) {
throw new Error([
'Docker pg_dump 执行失败。',
result.stderr ? `stderr=${result.stderr.trim()}` : '',
result.error ? `error=${result.error.message}` : '',
].filter(Boolean).join('\n'))
}
const sizeBytes = fs.existsSync(outputFile) ? fs.statSync(outputFile).size : 0
if (sizeBytes <= 0) {
throw new Error('Docker pg_dump 完成但备份文件为空,拒绝把它当作可用备份。')
}
const verifyArgs = [
'run',
'--rm',
'-v',
dockerPgDump.volumeArg,
dockerImage,
'pg_restore',
'--list',
dockerPgDump.containerOutputFile,
]
const verification = spawnSync('docker', verifyArgs, {
cwd: ROOT_DIR,
encoding: 'utf8',
env: {
...process.env,
...env,
},
windowsHide: true,
})
if (verification.status !== 0) {
throw new Error([
'Docker pg_restore --list 校验失败,不能把该备份作为迁移兜底。',
verification.stderr ? `stderr=${verification.stderr.trim()}` : '',
verification.error ? `error=${verification.error.message}` : '',
].filter(Boolean).join('\n'))
}
return {
outputFile,
sizeBytes,
database,
pgDump: {
executable: 'docker',
configuredBy: '--use-docker',
dockerImage,
databaseHost: dockerPgDump.host,
postgresImageAvailableBeforeRun: docker.postgresImageAvailable,
},
backupVerification: {
attempted: true,
ok: true,
status: verification.status,
stdoutPreview: String(verification.stdout ?? '').slice(0, 500),
stderr: String(verification.stderr ?? '').trim(),
tooling: {
executable: 'docker',
configuredBy: '--use-docker',
},
message: '备份文件可被 Docker pg_restore --list 读取。',
},
}
}
function main() {
const databaseUrl = loadDatabaseUrl()
if (!databaseUrl) {
throw new Error('DATABASE_URL 未配置,无法备份数据库。')
}
const cli = parseBackupCliArgs()
const report = createBackup({
databaseUrl,
allowDockerPull: cli.allowDockerPull,
dockerImage: cli.dockerImage,
outputDir: cli.outputDir,
useDocker: cli.useDocker,
})
console.log(JSON.stringify({
mode: 'postgres-backup',
ok: true,
outputFile: report.outputFile,
sizeBytes: report.sizeBytes,
database: report.database,
pgDump: report.pgDump,
backupVerification: report.backupVerification,
}, null, 2))
}
if (require.main === module) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
module.exports = {
buildBackupFilePath,
buildDockerEnvFlagArgs,
buildDockerPgDumpArgs,
buildDockerVolumeArg,
buildPgDumpArgs,
createBackup,
createBackupWithDocker,
parsePostgresUrl,
parseBackupCliArgs,
resolveDockerDatabaseHost,
sanitizeFileName,
timestampForFile,
}

View File

@ -0,0 +1,540 @@
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const { Pool } = require('pg')
const ROOT_DIR = path.resolve(__dirname, '..')
const PRISMA_CLI = path.join(ROOT_DIR, 'node_modules/prisma/build/index.js')
function loadDatabaseUrl(rootDir = ROOT_DIR, env = process.env) {
return loadEnvValue('DATABASE_URL', rootDir, env)
}
function loadShadowDatabaseUrl(rootDir = ROOT_DIR, env = process.env) {
return loadEnvValue('SHADOW_DATABASE_URL', rootDir, env)
}
function loadEnvValue(name, rootDir = ROOT_DIR, env = process.env) {
if (env[name]) return env[name]
const envPath = path.join(rootDir, '.env')
if (!fs.existsSync(envPath)) return ''
const content = fs.readFileSync(envPath, 'utf8')
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex === -1) continue
const key = trimmed.slice(0, separatorIndex).trim()
if (key !== name) continue
let value = trimmed.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return value
}
return ''
}
function describeDatabaseUrl(databaseUrl) {
try {
const parsed = new URL(databaseUrl)
return {
protocol: parsed.protocol.replace(/:$/, ''),
username: parsed.username || '',
host: parsed.hostname,
port: parsed.port || '',
databaseName: parsed.pathname.replace(/^\//, ''),
schema: parsed.searchParams.get('schema') || parsed.searchParams.get('currentSchema') || '',
}
} catch {
return {
protocol: '',
username: '',
host: '',
port: '',
databaseName: '',
schema: '',
}
}
}
function getDatabaseIdentity(databaseUrl) {
const description = describeDatabaseUrl(databaseUrl)
return [
description.protocol,
description.username.toLowerCase(),
description.host.toLowerCase(),
description.port,
description.databaseName.toLowerCase(),
description.schema.toLowerCase(),
].join('|')
}
function getDatabasePhysicalIdentity(databaseUrl) {
const description = describeDatabaseUrl(databaseUrl)
return [
description.protocol,
description.host.toLowerCase(),
description.port,
description.databaseName.toLowerCase(),
description.schema.toLowerCase(),
].join('|')
}
function validateShadowDatabaseUrl(shadowDatabaseUrl, databaseUrl) {
if (!shadowDatabaseUrl) {
return {
ok: false,
reason: 'SHADOW_DATABASE_URL 未配置,无法只读证明 migrations 与当前数据库结构是否等价。',
}
}
const shadow = describeDatabaseUrl(shadowDatabaseUrl)
if (!shadow.databaseName) {
return {
ok: false,
reason: 'SHADOW_DATABASE_URL 格式无效,无法识别数据库名。',
}
}
if (databaseUrl && getDatabasePhysicalIdentity(shadowDatabaseUrl) === getDatabasePhysicalIdentity(databaseUrl)) {
return {
ok: false,
reason: 'SHADOW_DATABASE_URL 指向了目标数据库本身,禁止用真实库作为 shadow 库。',
}
}
if (!/(shadow|test|tmp|temp)/i.test(shadow.databaseName)) {
return {
ok: false,
reason: 'SHADOW_DATABASE_URL 的数据库名必须包含 shadow/test/tmp/temp,避免误用真实业务库。',
}
}
return {
ok: true,
reason: '',
}
}
function listMigrationDirectories(rootDir = ROOT_DIR) {
const migrationsDir = path.join(rootDir, 'prisma/migrations')
if (!fs.existsSync(migrationsDir)) return []
return fs
.readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
}
function runPrismaCommand(args) {
const result = spawnSync(process.execPath, [PRISMA_CLI, ...args], {
cwd: ROOT_DIR,
encoding: 'utf8',
windowsHide: true,
})
return {
ok: result.status === 0,
status: result.status,
stdout: String(result.stdout ?? '').trim(),
stderr: String(result.stderr ?? '').trim(),
error: result.error ? String(result.error.message ?? result.error) : '',
}
}
function runPrismaCommandWithEnv(args, env = process.env) {
const result = spawnSync(process.execPath, [PRISMA_CLI, ...args], {
cwd: ROOT_DIR,
encoding: 'utf8',
env: { ...process.env, ...env },
windowsHide: true,
})
return {
ok: result.status === 0,
status: result.status,
stdout: String(result.stdout ?? '').trim(),
stderr: String(result.stderr ?? '').trim(),
error: result.error ? String(result.error.message ?? result.error) : '',
}
}
function runMigrationDiffWithShadow(shadowDatabaseUrl, databaseUrl = '') {
const shadowValidation = validateShadowDatabaseUrl(shadowDatabaseUrl, databaseUrl)
if (!shadowValidation.ok) {
return {
skipped: true,
ok: false,
status: null,
stdout: '',
stderr: '',
error: '',
reason: shadowValidation.reason,
}
}
const env = {
SHADOW_DATABASE_URL: shadowDatabaseUrl,
}
if (databaseUrl) env.DATABASE_URL = databaseUrl
return {
skipped: false,
...runPrismaCommandWithEnv([
'migrate',
'diff',
'--from-migrations',
'prisma/migrations',
'--to-config-datasource',
'--script',
'--exit-code',
], env),
reason: '',
}
}
function runMigrationSchemaDiffWithShadow(shadowDatabaseUrl, databaseUrl = '') {
const shadowValidation = validateShadowDatabaseUrl(shadowDatabaseUrl, databaseUrl)
if (!shadowValidation.ok) {
return {
skipped: true,
ok: false,
status: null,
stdout: '',
stderr: '',
error: '',
reason: shadowValidation.reason,
}
}
const env = {
SHADOW_DATABASE_URL: shadowDatabaseUrl,
}
if (databaseUrl) env.DATABASE_URL = databaseUrl
return {
skipped: false,
...runPrismaCommandWithEnv([
'migrate',
'diff',
'--from-migrations',
'prisma/migrations',
'--to-schema',
'prisma/schema.prisma',
'--script',
'--exit-code',
], env),
reason: '',
}
}
function runCurrentSchemaDiff(env = process.env) {
return runPrismaCommandWithEnv([
'migrate',
'diff',
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'--script',
'--exit-code',
])
}
function hasSqlDiff(stdout) {
return /\b(?:CREATE|ALTER|DROP)\s+(?:TYPE|TABLE|INDEX|CONSTRAINT|COLUMN|FOREIGN\s+KEY)\b/i.test(
String(stdout ?? ''),
)
}
function parsePendingPrismaMigrations(stdout) {
const lines = String(stdout ?? '').split(/\r?\n/)
const startIndex = lines.findIndex((line) =>
line.includes('Following migrations have not yet been applied:'),
)
if (startIndex === -1) return []
const pending = []
for (let index = startIndex + 1; index < lines.length; index += 1) {
const line = lines[index].trim()
if (!line) continue
if (line.startsWith('To apply migrations')) break
if (line.startsWith('Loaded Prisma config')) break
pending.push(line)
}
return pending
}
async function getMigrationHistoryStatus(databaseUrl) {
if (!databaseUrl) {
return {
tableExists: false,
totalMigrationRows: 0,
appliedMigrationRows: 0,
unfinishedMigrationRows: 0,
rolledBackMigrationRows: 0,
error: 'DATABASE_URL 未配置。',
}
}
const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 })
const client = await pool.connect()
try {
const tableResult = await client.query("SELECT to_regclass('_prisma_migrations') AS relation_name")
const tableExists = tableResult.rows[0]?.relation_name !== null
if (!tableExists) {
return {
tableExists: false,
totalMigrationRows: 0,
appliedMigrationRows: 0,
unfinishedMigrationRows: 0,
rolledBackMigrationRows: 0,
error: '',
}
}
const result = await client.query(`
SELECT
COUNT(*)::integer AS total_migration_rows,
COUNT(*) FILTER (WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL)::integer AS applied_migration_rows,
COUNT(*) FILTER (WHERE finished_at IS NULL AND rolled_back_at IS NULL)::integer AS unfinished_migration_rows,
COUNT(*) FILTER (WHERE rolled_back_at IS NOT NULL)::integer AS rolled_back_migration_rows
FROM _prisma_migrations
`)
const row = result.rows[0] ?? {}
return {
tableExists: true,
totalMigrationRows: row.total_migration_rows ?? 0,
appliedMigrationRows: row.applied_migration_rows ?? 0,
unfinishedMigrationRows: row.unfinished_migration_rows ?? 0,
rolledBackMigrationRows: row.rolled_back_migration_rows ?? 0,
error: '',
}
} finally {
client.release()
await pool.end()
}
}
function buildBaselineDiagnosis({
database,
migrationDirectories,
prismaValidate,
prismaMigrateStatus,
currentSchemaDiff,
migrationDiff,
migrationSchemaDiff,
migrationHistory,
}) {
const pendingMigrations = parsePendingPrismaMigrations(prismaMigrateStatus.stdout)
const blockers = []
if (!prismaValidate.ok) {
blockers.push({
key: 'prismaValidate',
message: 'Prisma schema 校验失败,不能继续讨论迁移基线。',
})
}
if (!migrationHistory.tableExists) {
blockers.push({
key: 'migrationHistory.tableExists',
message: '目标库缺少 _prisma_migrations,当前结构没有 Prisma 官方迁移历史。',
})
}
if (pendingMigrations.length > 0) {
blockers.push({
key: 'pendingMigrations',
value: pendingMigrations.length,
message: 'Prisma 官方迁移状态显示仍有未应用 migration。',
})
}
if (migrationHistory.unfinishedMigrationRows > 0) {
blockers.push({
key: 'migrationHistory.unfinishedMigrationRows',
value: migrationHistory.unfinishedMigrationRows,
message: '存在未完成 migration 记录。',
})
}
if (migrationHistory.rolledBackMigrationRows > 0) {
blockers.push({
key: 'migrationHistory.rolledBackMigrationRows',
value: migrationHistory.rolledBackMigrationRows,
message: '存在已回滚 migration 记录。',
})
}
if (currentSchemaDiff && hasSqlDiff(currentSchemaDiff.stdout)) {
blockers.push({
key: 'currentSchemaDiff.notEmpty',
message: '当前数据库结构和 schema.prisma 不一致,必须先补齐结构或完成迁移。',
})
} else if (currentSchemaDiff && !currentSchemaDiff.ok) {
blockers.push({
key: 'currentSchemaDiff.error',
message: 'Prisma 当前库到 schema 的 diff 执行失败,不能证明当前库结构匹配 schema。',
})
}
if (migrationDiff?.skipped) {
blockers.push({
key: 'migrationDiff.skipped',
message: migrationDiff.reason,
})
} else if (migrationDiff && migrationDiff.status === 2) {
blockers.push({
key: 'migrationDiff.notEmpty',
message: 'Prisma diff 显示 migrations 结果和当前数据库结构不一致,不能 baseline resolve。',
})
} else if (migrationDiff && !migrationDiff.ok) {
blockers.push({
key: 'migrationDiff.error',
message: 'Prisma diff 执行失败,不能证明当前数据库结构等价于 migrations。',
})
}
if (migrationSchemaDiff?.skipped) {
blockers.push({
key: 'migrationSchemaDiff.skipped',
message: migrationSchemaDiff.reason,
})
} else if (migrationSchemaDiff && migrationSchemaDiff.status === 2) {
blockers.push({
key: 'migrationSchemaDiff.notEmpty',
message: 'Prisma diff 显示 migrations 重放结果和 schema.prisma 不一致,迁移链本身还不能表达当前代码结构。',
})
} else if (migrationSchemaDiff && !migrationSchemaDiff.ok) {
blockers.push({
key: 'migrationSchemaDiff.error',
message: 'Prisma migrations 到 schema 的 diff 执行失败,不能证明迁移链可重放到当前结构。',
})
}
const baselineRequired = !migrationHistory.tableExists && pendingMigrations.length > 0
return {
mode: 'prisma-migration-baseline-diagnosis',
readyForHardFoundation: blockers.length === 0,
baselineRequired,
database,
migrationDirectoryCount: migrationDirectories.length,
pendingMigrationCount: pendingMigrations.length,
pendingMigrations,
migrationHistory,
prismaValidate: {
ok: prismaValidate.ok,
status: prismaValidate.status,
stdoutPreview: prismaValidate.stdout.slice(0, 240),
stderr: prismaValidate.stderr,
error: prismaValidate.error,
},
prismaMigrateStatus: {
ok: prismaMigrateStatus.ok,
status: prismaMigrateStatus.status,
stdoutPreview: prismaMigrateStatus.stdout.slice(0, 500),
stderr: prismaMigrateStatus.stderr,
error: prismaMigrateStatus.error,
},
currentSchemaDiff: currentSchemaDiff
? {
ok: currentSchemaDiff.ok,
status: currentSchemaDiff.status,
hasSqlDiff: hasSqlDiff(currentSchemaDiff.stdout),
stdoutPreview: currentSchemaDiff.stdout.slice(0, 500),
stderr: currentSchemaDiff.stderr,
error: currentSchemaDiff.error,
}
: null,
migrationDiff: migrationDiff
? {
skipped: migrationDiff.skipped,
ok: migrationDiff.ok,
status: migrationDiff.status,
stdoutPreview: migrationDiff.stdout.slice(0, 500),
stderr: migrationDiff.stderr,
error: migrationDiff.error,
reason: migrationDiff.reason,
}
: null,
migrationSchemaDiff: migrationSchemaDiff
? {
skipped: migrationSchemaDiff.skipped,
ok: migrationSchemaDiff.ok,
status: migrationSchemaDiff.status,
hasSqlDiff: hasSqlDiff(migrationSchemaDiff.stdout),
stdoutPreview: migrationSchemaDiff.stdout.slice(0, 500),
stderr: migrationSchemaDiff.stderr,
error: migrationSchemaDiff.error,
reason: migrationSchemaDiff.reason,
}
: null,
blockers,
recommendedNextSteps: baselineRequired
? [
'先备份目标数据库,确认这是本地/准生产/生产哪一类库。',
'逐项确认当前数据库结构是否已经等价于历史 migration 结果,不能直接批量 resolve。',
'需要配置只用于 diff 的 SHADOW_DATABASE_URL,先证明 migrations 结果和当前库结构差异。',
'如果当前库到 schema.prisma 的 diff 非空,先补齐结构,不能只补迁移历史。',
'如果结构缺失,先走 npm run db:foundation:migrate 计划;真实执行必须带目标库身份和备份 SHA-256。',
'如果结构已由历史 db push 或手工 SQL 形成,先形成 baseline 方案,再用 npm run db:foundation:baseline-resolve 逐项补迁移历史。',
'baseline 或 deploy 完成后,运行 npm run db:verify-master-data:readiness。',
]
: [
'先处理 blockers,再运行 npm run db:verify-master-data:readiness。',
],
}
}
async function main() {
const databaseUrl = loadDatabaseUrl()
const shadowDatabaseUrl = loadShadowDatabaseUrl()
const database = describeDatabaseUrl(databaseUrl)
const migrationDirectories = listMigrationDirectories()
const prismaValidate = runPrismaCommand(['validate'])
const prismaMigrateStatus = runPrismaCommand(['migrate', 'status'])
const currentSchemaDiff = runCurrentSchemaDiff()
const migrationDiff = runMigrationDiffWithShadow(shadowDatabaseUrl, databaseUrl)
const migrationSchemaDiff = runMigrationSchemaDiffWithShadow(shadowDatabaseUrl, databaseUrl)
const migrationHistory = await getMigrationHistoryStatus(databaseUrl)
const report = buildBaselineDiagnosis({
database,
migrationDirectories,
prismaValidate,
prismaMigrateStatus,
currentSchemaDiff,
migrationDiff,
migrationSchemaDiff,
migrationHistory,
})
console.log(JSON.stringify(report, null, 2))
if (!report.readyForHardFoundation) {
process.exit(1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
buildBaselineDiagnosis,
describeDatabaseUrl,
getDatabaseIdentity,
getDatabasePhysicalIdentity,
listMigrationDirectories,
loadDatabaseUrl,
loadShadowDatabaseUrl,
parsePendingPrismaMigrations,
hasSqlDiff,
getMigrationHistoryStatus,
runCurrentSchemaDiff,
runPrismaCommand,
runPrismaCommandWithEnv,
runMigrationDiffWithShadow,
runMigrationSchemaDiffWithShadow,
validateShadowDatabaseUrl,
}

View File

@ -60,7 +60,7 @@ 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-employee-directory.ts'
ExecStart=/bin/bash -lc 'cd "$APP_ROOT" && ./node_modules/.bin/tsx scripts/sync-employee-directory.ts --apply'
StandardOutput=journal
StandardError=journal
EOF

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,695 @@
const CORE_MASTER_DATA_TRUTH_SOURCES = [
{
id: 'asset-series',
layer: 'asset-center-source',
primaryRelations: ['AssetSeries'],
compatibilitySnapshots: ['AssetSeries.metadata'],
notTruthSources: ['page filter state', 'draft group display text'],
writeBoundary: ['src/server/repositories/asset-center.repository.ts'],
requiredGuardNames: [
'AssetSeries_code_key',
'AssetSeries_code_normalized',
'AssetSeries_identity_valid',
'AssetSeries_metadata_object',
],
},
{
id: 'asset-family',
layer: 'asset-center-source',
primaryRelations: ['AssetFamily', 'AssetFamilySeriesCompatibility'],
compatibilitySnapshots: ['AssetFamily.metadata', 'PreSku.familySelection'],
notTruthSources: ['PreSku.familySelection', 'asset card display text'],
writeBoundary: [
'src/server/repositories/asset-center.repository.ts',
'src/server/services/asset-center.service.ts',
],
requiredGuardNames: [
'AssetFamily_code_key',
'AssetFamily_code_normalized',
'AssetFamily_identity_valid',
'AssetFamily_metadata_object',
'AssetFamily_lifecycle_state_valid',
'AssetFamilySeriesCompatibility_assetFamilyId_assetSeriesId_key',
],
},
{
id: 'asset-variant',
layer: 'asset-center-source',
primaryRelations: ['AssetVariant'],
compatibilitySnapshots: [
'AssetVariant.businessData',
'AssetVariant.businessData.cadData',
'AssetVariant.dimensions',
],
notTruthSources: [
'ProductSKU.attributes.assetSkuSnapshot',
'PreSku.variantSelection',
'AssetVariant.businessData.cadData as formal SKU truth',
],
writeBoundary: [
'src/server/repositories/asset-center.repository.ts',
'src/server/services/asset-center.service.ts',
],
requiredGuardNames: [
'AssetVariant_code_key',
'AssetVariant_assetFamilyId_identityKey_key',
'AssetVariant_barcode_active_key',
'AssetVariant_identity_valid',
'AssetVariant_json_shape_valid',
'AssetVariant_cost_weight_valid',
'AssetVariant_lifecycle_state_valid',
'AssetVariant_inheritance_not_self',
],
},
{
id: 'material',
layer: 'reference-source',
primaryRelations: ['Material', 'Substrate'],
compatibilitySnapshots: ['Material.thumbnailUrl', 'Material.textureUrl'],
notTruthSources: ['BOM option label', 'localStorage'],
writeBoundary: [
'src/server/repositories/material.repository.ts',
'src/server/services/asset-center.service.ts',
'src/server/services/open-platform-demo-seed.service.ts',
],
requiredGuardNames: [
'Material_code_key',
'Material_code_normalized',
'Substrate_code_key',
'Substrate_code_normalized',
],
},
{
id: 'hardware',
layer: 'reference-source',
primaryRelations: ['Hardware'],
compatibilitySnapshots: ['Hardware.specifications', 'Hardware.mountPoints'],
notTruthSources: ['ProductHardware.match text', 'CAD block name only'],
writeBoundary: [
'src/server/repositories/hardware.repository.ts',
'src/server/services/hardware-write-ops.ts',
],
requiredGuardNames: ['Hardware_code_key', 'Hardware_code_normalized'],
},
{
id: 'common-component',
layer: 'reference-source',
primaryRelations: ['CommonComponent'],
compatibilitySnapshots: ['CommonComponent.specifications', 'CommonComponent.mountPoints'],
notTruthSources: ['component card title', 'assistant assembly node label'],
writeBoundary: ['src/server/repositories/common-component.repository.ts'],
requiredGuardNames: ['CommonComponent_code_key', 'CommonComponent_code_normalized'],
},
{
id: 'draft-group',
layer: 'process',
primaryRelations: ['DraftGroup'],
compatibilitySnapshots: ['DraftGroup.summary'],
notTruthSources: ['formal Product', 'ProductSKU'],
writeBoundary: [
'src/server/repositories/draft-group.repository.ts',
'src/server/services/intake.service.ts',
'src/server/services/open-platform-demo-seed.service.ts',
'scripts/apply-pre-sku-formalization-repair.js',
],
requiredGuardNames: [
'DraftGroup_code_key',
'DraftGroup_code_normalized',
'DraftGroup_identity_valid',
'DraftGroup_summary_object',
'DraftGroup_optional_text_not_blank',
],
},
{
id: 'pre-sku',
layer: 'process',
primaryRelations: ['PreSku', 'PreSkuBasicInfo'],
compatibilitySnapshots: [
'PreSku.displaySummary',
'PreSku.familySelection',
'PreSku.variantSelection',
'PreSku.bomPreview',
],
notTruthSources: ['ProductSKU.attributes', 'draft card display summary'],
writeBoundary: [
'src/server/repositories/draft-group.repository.ts',
'src/server/services/intake.service.ts',
'scripts/master-data-required-migrations.js',
'scripts/apply-pre-sku-formalization-repair.js',
],
requiredGuardNames: [
'PreSku_draftGroupId_sequence_key',
'PreSkuBasicInfo_preSkuId_key',
'PreSkuBasicInfo_formalSkuCode_non_empty_key',
'PreSkuBasicInfo_barcode_non_empty_key',
'PreSkuBasicInfo_formalSkuCode_normalized',
'PreSkuBasicInfo_barcode_normalized',
'PreSkuBasicInfo_model_normalized',
'PreSkuBasicInfo_versionNo_normalized',
'PreSkuBasicInfo_attributeCode_normalized',
'PreSkuBasicInfo_drawingId_normalized',
'PreSkuBasicInfo_source_allowed',
'PreSkuBasicInfo_optional_text_not_blank',
'PreSku_identity_valid',
'PreSku_process_snapshots_shape_valid',
'PreSku_weight_valid',
'PreSku_component_codes_valid',
'PreSku_optional_text_not_blank',
],
},
{
id: 'formal-product',
layer: 'formal-result',
primaryRelations: ['Product'],
compatibilitySnapshots: ['legacy upload payload'],
notTruthSources: ['DraftGroup.name', 'PreSku.displaySummary', 'Date.now fallback'],
writeBoundary: [
'src/server/repositories/product.repository.ts',
'src/server/repositories/product-upload.repository.ts',
'src/server/repositories/sku-quality-inspection.repository.ts',
'src/server/services/intake.service.ts',
'src/server/services/open-platform-demo-seed.service.ts',
],
requiredGuardNames: [
'Product_modelNumber_key',
'Product_modelNumber_normalized',
'Product_name_not_blank',
],
},
{
id: 'formal-sku',
layer: 'formal-result',
primaryRelations: ['ProductSKU', 'ProductSkuAssetProfile'],
compatibilitySnapshots: ['ProductSKU.attributes', 'ProductSKU.transferHistory'],
notTruthSources: [
'attributes.skuName',
'attributes.assetSkuSnapshot',
'attributes.bomPreview',
'PreSku.bomPreview',
'AssetVariant.estimatedCostPrice as formal SKU cost',
'external.product_bom_v1.unit_cost',
'external.product_bom_v1.total_cost',
],
writeBoundary: [
'src/server/repositories/product.repository.ts',
'src/server/repositories/handover.repository.ts',
'src/server/repositories/product-upload.repository.ts',
'src/server/repositories/sku.repository.ts',
'src/server/repositories/sku-quality-inspection.repository.ts',
'src/server/services/sku-command.service.ts',
'src/server/services/asset-sku-sync.service.ts',
'src/server/services/intake.service.ts',
'src/server/services/sku-cad-import.helper.ts',
'scripts/master-data-required-migrations.js',
'scripts/apply-pre-sku-formalization-repair.js',
],
requiredGuardNames: [
'ProductSKU_skuCode_key',
'ProductSKU_active_skuCode_normalized_key',
'ProductSKU_sourcePreSkuId_key',
'ProductSKU_price_cost_stock_non_negative',
'ProductSKU_skuCode_normalized',
'ProductSKU_active_skuCode_not_deleted_marker',
'ProductSKU_active_skuCode_no_deleted_marker_ci',
'ProductSKU_soft_delete_identity_valid',
'ProductSKU_no_soft_delete_lifecycle_attributes',
'ProductSKU_soft_delete_tombstone_valid',
'ProductSKU_no_formal_truth_attributes',
'ProductSKU_sync_asset_profile_deleted_at',
'ProductSkuAssetProfile_skuId_key',
'ProductSkuAssetProfile_barcode_active_non_empty_key',
'ProductSkuAssetProfile_barcode_normalized',
'ProductSkuAssetProfile_model_normalized',
'ProductSkuAssetProfile_specCode_normalized',
'ProductSkuAssetProfile_primary_source_required',
'ProductSkuAssetProfile_source_variant_ids_required',
'ProductSkuAssetProfile_primary_source_in_source_ids',
'ProductSkuAssetProfile_optional_text_not_blank',
],
},
{
id: 'formal-bom-structure',
layer: 'formal-result',
primaryRelations: ['Panel', 'PanelMaterial', 'ProductHardware', 'Hardware'],
compatibilitySnapshots: [
'Panel.assemblySemantic',
'ProductHardware.transform',
'ProductHardware.sourceSignature',
],
notTruthSources: [
'PreSku.bomPreview as confirmed BOM',
'PreSku.displaySummary totals',
'reference image estimator',
'REFERENCE_ESTIMATOR_*',
'reference-image-cost-catalog',
'external.product_bom_v1.unit_cost',
'external.product_bom_v1.total_cost',
],
writeBoundary: [
'src/server/repositories/panel.repository.ts',
'src/server/repositories/product-upload.repository.ts',
'src/server/repositories/product.repository.ts',
'src/server/repositories/product-hardware.repository.ts',
'src/server/repositories/hardware.repository.ts',
'src/server/services/hardware-matcher.runtime.ts',
'src/server/services/intake.service.ts',
'src/server/services/sku-cad-import.helper.ts',
],
requiredGuardNames: [
'Panel_product_sku_code_normalized_key',
'Panel_identity_dimensions_valid',
'Panel_assemblySemantic_object',
'Panel_optional_text_not_blank',
'PanelMaterial_relation_ids_not_blank',
'ProductHardware_match_status_allowed',
'ProductHardware_match_confidence_valid',
'ProductHardware_match_method_allowed',
'ProductHardware_quantities_geometry_valid',
'ProductHardware_transform_object',
'ProductHardware_sourceSignature_object',
'ProductHardware_optional_text_not_blank',
'Hardware_code_key',
],
},
{
id: 'non-integrated-product',
layer: 'intake-process',
primaryRelations: ['IntakeNonIntegratedProduct', 'IntakeNonIntegratedProductProfile'],
compatibilitySnapshots: ['IntakeNonIntegratedProduct.payload'],
notTruthSources: ['payload.openingReference', 'review sheet text', 'approval summary'],
writeBoundary: [
'src/server/repositories/intake-non-integrated-product.repository.ts',
'src/server/services/intake-non-integrated-product.service.ts',
'src/server/services/jst-sku-stock-sync.service.ts',
'scripts/master-data-required-migrations.js',
'scripts/apply-master-data-cleanup-plan.js',
],
requiredGuardNames: [
'IntakeNonIntegratedProduct_spu_brand_key',
'IntakeNonIntegratedProduct_spu_brand_normalized_key',
'IntakeNonIntegratedProductProfile_productId_key',
'IntakeProductProfile_developmentKind_allowed',
],
},
{
id: 'non-integrated-sku-item',
layer: 'intake-process',
primaryRelations: ['IntakeNonIntegratedSkuItem'],
compatibilitySnapshots: ['IntakeNonIntegratedProduct.payload.skuItems'],
notTruthSources: ['payload.skuItems', 'spreadsheet row order'],
writeBoundary: [
'src/server/repositories/intake-non-integrated-product.repository.ts',
'src/server/services/intake-non-integrated-product.service.ts',
'src/server/services/jst-sku-stock-sync.service.ts',
'scripts/master-data-required-migrations.js',
'scripts/apply-master-data-cleanup-plan.js',
],
requiredGuardNames: [
'IntakeNonIntegratedSkuItem_productId_skuItemId_key',
'IntakeNonIntegratedSkuItem_skuCode_non_empty_key',
'IntakeNonIntegratedSkuItem_barcode_non_empty_key',
'IntakeNonIntegratedSkuItem_quality_state_required_fields',
],
},
{
id: 'master-data-quality-decision',
layer: 'quality-audit',
primaryRelations: ['MasterDataQualityDecision'],
compatibilitySnapshots: ['cleanup plan json'],
notTruthSources: ['local cleanup template', 'chat confirmation text'],
writeBoundary: ['scripts/apply-master-data-cleanup-plan.js'],
requiredGuardNames: [
'MasterDataQualityDecision_open_target_key',
'MasterDataQualityDecision_target_not_blank',
'MasterDataQualityDecision_status_allowed',
'MasterDataQualityDecision_reason_not_blank',
'MasterDataQualityDecision_decidedBy_not_blank',
'MasterDataQualityDecision_reason_not_placeholder',
'MasterDataQualityDecision_decidedBy_not_placeholder',
'MasterDataQualityDecision_resolvedBy_not_placeholder',
'MasterDataQualityDecision_resolved_state_valid',
],
},
{
id: 'product-patent',
layer: 'reference-source',
primaryRelations: ['ProductPatentRecord'],
compatibilitySnapshots: ['ProductPatentRecord.annualFees'],
notTruthSources: ['localStorage', 'patent timestamp draft id'],
writeBoundary: ['src/server/services/product-patent.service.ts'],
requiredGuardNames: ['ProductPatentRecord_patentGroupKey_key'],
},
{
id: 'intake-reference-library',
layer: 'reference-source',
primaryRelations: ['IntakeReferenceLibraryItem'],
compatibilitySnapshots: ['IntakeReferenceLibraryItem.payload'],
notTruthSources: ['localStorage', 'frontend row id', 'database id as itemKey'],
writeBoundary: [
'src/server/services/intake-reference-library.service.ts',
'src/server/services/spu-number-reservation.service.ts',
],
requiredGuardNames: [
'IntakeReferenceLibraryItem_libraryType_itemKey_key',
'IntakeReferenceLibraryItem_libraryType_code_non_empty_key',
'IntakeReferenceLibraryItem_libraryType_itemKey_normalized_key',
'IntakeReferenceLibraryItem_code_required',
],
},
{
id: 'spu-number-counter',
layer: 'identity-counter',
primaryRelations: ['SpuNumberCounter'],
compatibilitySnapshots: ['SPU_RULE payload display fields'],
notTruthSources: ['frontend reserve cache', 'standalone reservation API'],
writeBoundary: [
'src/server/services/spu-number-reservation.service.ts',
'scripts/master-data-required-migrations.js',
],
requiredGuardNames: [
'SpuNumberCounter_productCategory_key',
'SpuNumberCounter_productCategory_normalized_key',
'SpuNumberCounter_prefix_key',
'SpuNumberCounter_lastUsedIndex_non_negative',
],
},
{
id: 'people-org',
layer: 'permission-foundation',
primaryRelations: ['User', 'Department', 'EmployeeDirectory', 'Team', 'EmployeeLookupCache'],
compatibilitySnapshots: [
'EmployeeDirectory.rawSnapshot',
'Department.rawSnapshot',
'EmployeeLookupCache.result',
],
notTruthSources: ['login session cache', 'manual text department path'],
writeBoundary: [
'src/server/repositories/user.repository.ts',
'src/server/repositories/auth.repository.ts',
'src/server/repositories/department.repository.ts',
'src/server/repositories/employee-directory.repository.ts',
'src/server/repositories/team.repository.ts',
'src/server/services/department.service.ts',
'src/server/services/dingtalk-auth.service.ts',
'src/server/services/external-employee-directory.service.ts',
'src/server/services/open-platform-demo-seed.service.ts',
'src/server/services/system-init.service.ts',
'src/server/services/team.service.ts',
],
requiredGuardNames: [
'User_email_normalized_key',
'User_name_normalized_key',
'Department_name_normalized_key',
'Department_externalId_normalized_key',
'EmployeeDirectory_dingtalkUserId_normalized_key',
'EmployeeDirectory_jobNumber_normalized_key',
'Team_identity_not_blank',
'EmployeeLookupCache_identity_not_blank',
],
},
{
id: 'warehouse-packaging',
layer: 'warehouse-result',
primaryRelations: [
'WarehouseLocation',
'PackagedItem',
'PackagingPlan',
'PreSkuPackagingPlan',
'BrandMaterialBinding',
],
compatibilitySnapshots: [
'PackagingPlan.boxes',
'PackagedItem.payload',
'PreSkuPackagingPlan.boxes',
'PreSkuPackagingPlan.costSummary',
'BrandMaterialBinding.fileName',
],
notTruthSources: [
'preview packaging text',
'draft packaging snapshot',
'draft packaging snapshot cost',
'PackagedItem.payload.costSummary',
],
writeBoundary: [
'src/server/repositories/warehouse.repository.ts',
'src/server/repositories/packaging.repository.ts',
'src/server/repositories/draft-group.repository.ts',
'src/server/services/intake.service.ts',
'src/server/services/open-platform-demo-seed.service.ts',
'src/server/services/pre-sku-packaging.service.ts',
],
requiredGuardNames: [
'WarehouseLocation_code_key',
'WarehouseLocation_active_position_key',
'PackagedItem_code_key',
'PackagedItem_code_normalized',
'PackagedItem_status_allowed',
'PackagedItem_name_not_blank',
'PackagedItem_quantities_valid',
'PackagedItem_dimensions_valid',
'PackagedItem_payload_object',
'PackagingPlan_boxes_array',
'PackagingPlan_identity_counts_valid',
'PackagingPlan_totals_valid',
'PackagingPlan_optional_text_not_blank',
'PreSkuPackagingPlan_boxes_array',
'PreSkuPackagingPlan_status_allowed',
'PreSkuPackagingPlan_costSummary_object',
'PreSkuPackagingPlan_processDraft_object',
'PreSkuPackagingPlan_optional_text_not_blank',
'BrandMaterialBinding_brand_material_normalized_key',
'BrandMaterialBinding_materialType_allowed',
'BrandMaterialBinding_identity_not_blank',
'BrandMaterialBinding_file_metadata_consistent',
],
},
{
id: 'reference-taxonomy',
layer: 'reference-source',
primaryRelations: [
'ProductCategory',
'MaterialCategory',
'HardwareCategory',
'CommonComponentCategory',
'SubstrateCategory',
'SubstrateEnvironmentGrade',
'SubstrateSurfaceOption',
'SubstrateSystemNameOption',
'SubstrateThicknessSpec',
],
compatibilitySnapshots: [
'ProductCategory.description',
'HardwareCategory.description',
'SubstrateThicknessSpec.thicknessMm',
],
notTruthSources: ['UI select options', 'spreadsheet category text', 'payload productCategory'],
writeBoundary: [
'src/server/repositories/material.repository.ts',
'src/server/repositories/hardware.repository.ts',
'src/server/repositories/common-component.repository.ts',
'src/server/services/asset-center.service.ts',
'src/server/services/hardware-write-ops.ts',
'src/server/services/open-platform-demo-seed.service.ts',
],
requiredGuardNames: [
'ProductCategory_name_normalized_key',
'MaterialCategory_name_normalized_key',
'HardwareCategory_name_normalized_key',
'CommonComponentCategory_name_normalized_key',
'SubstrateCategory_name_normalized_key',
'SubstrateEnvironmentGrade_name_normalized_key',
'SubstrateSurfaceOption_name_normalized_key',
'SubstrateSystemNameOption_name_normalized_key',
'SubstrateThicknessSpec_name_normalized_key',
],
},
{
id: 'reference-bindings',
layer: 'reference-source',
primaryRelations: [
'HardwareMapping',
'SubstrateFinish',
'PanelMaterial',
'AssetVariantLocationBinding',
'PreSkuLocationBinding',
],
compatibilitySnapshots: ['HardwareMapping.signatureHash', 'PanelMaterial.materialId'],
notTruthSources: ['CAD block text only', 'panel material label', 'warehouse display position'],
writeBoundary: [
'src/server/repositories/hardware.repository.ts',
'src/server/repositories/material.repository.ts',
'src/server/repositories/panel.repository.ts',
'src/server/repositories/warehouse.repository.ts',
'src/server/services/hardware-matcher.runtime.ts',
'src/server/services/hardware-write-ops.ts',
],
requiredGuardNames: [
'HardwareMapping_match_key_exactly_one',
'HardwareMapping_usage_source_valid',
'SubstrateFinish_relation_ids_not_blank',
'PanelMaterial_relation_ids_not_blank',
'AssetVariantLocationBinding_relation_ids_not_blank',
'PreSkuLocationBinding_relation_ids_not_blank',
],
},
{
id: 'asset-sku-sync-event',
layer: 'sync-event',
primaryRelations: ['AssetSkuSyncEvent'],
compatibilitySnapshots: ['AssetSkuSyncEvent.payload', 'AssetSkuSyncEvent.progress'],
notTruthSources: ['Redis task state', 'frontend sync badge'],
writeBoundary: ['src/server/services/asset-sku-sync.service.ts'],
requiredGuardNames: [
'AssetSkuSyncEvent_reason_allowed',
'AssetSkuSyncEvent_counts_non_negative',
'AssetSkuSyncEvent_completed_has_processed_at',
],
},
{
id: 'collaboration-access',
layer: 'permission-foundation',
primaryRelations: ['ProductCollaborator', 'ShareLink'],
compatibilitySnapshots: ['ShareLink.expiresAt', 'ShareLink.viewCount'],
notTruthSources: ['URL token text', 'approval participant summary', 'business type alone'],
writeBoundary: [
'src/server/repositories/handover.repository.ts',
'src/server/repositories/product.repository.ts',
'src/server/services/product-collaborator.service.ts',
'src/server/repositories/share.repository.ts',
],
requiredGuardNames: [
'ProductCollaborator_identity_not_blank',
'ProductCollaborator_role_allowed',
'ShareLink_token_not_blank',
'ShareLink_viewCount_non_negative',
],
},
{
id: 'approval-workflow',
layer: 'process',
primaryRelations: ['ApprovalWorkflow', 'ApprovalWorkflowFieldChange'],
compatibilitySnapshots: ['ApprovalWorkflow.summary', 'ApprovalWorkflow.fieldMatrix'],
notTruthSources: ['approval summary as product profile', 'field change oldValue/newValue as current truth'],
writeBoundary: ['src/server/services/approval-workflow.service.ts'],
requiredGuardNames: [
'ApprovalWorkflow_approvalNumber_normalized',
'ApprovalWorkflow_title_not_blank',
'ApprovalWorkflow_participant_user_ids_valid',
'ApprovalWorkflowFieldChange_fieldKey_not_blank',
],
},
{
id: 'planning',
layer: 'process',
primaryRelations: ['PlanningTemplate', 'PlanningWorkspace', 'PlanningDraft'],
compatibilitySnapshots: [
'PlanningTemplate.slots',
'PlanningWorkspace.slotSelections',
'PlanningDraft.selectedAssets',
],
notTruthSources: ['planning preview node as asset source', 'planning draft as formal SKU'],
writeBoundary: ['src/server/repositories/planning.repository.ts'],
requiredGuardNames: [
'PlanningTemplate_code_key',
'PlanningTemplate_slots_array',
'PlanningWorkspace_slotSelections_object',
'PlanningDraft_previewNodes_array',
],
},
{
id: 'system-config-feedback-audit',
layer: 'configuration-and-audit',
primaryRelations: [
'SystemConfig',
'ProviderConfig',
'ThemeConfig',
'IntakeNodePermissionOverride',
'AssistedAssemblyAiRender',
'Feedback',
'ActivityLog',
],
compatibilitySnapshots: [
'SystemConfig.value',
'ProviderConfig.extra',
'ThemeConfig.homeBackground',
'AssistedAssemblyAiRender.workspaceSnapshot',
'Feedback.comments',
'ActivityLog.metadata',
],
notTruthSources: ['environment variable fallback', 'browser UI state', 'toast text'],
writeBoundary: [
'src/server/repositories/auth.repository.ts',
'src/server/repositories/feedback.repository.ts',
'src/server/repositories/handover.repository.ts',
'src/server/repositories/log.repository.ts',
'src/server/repositories/sku.repository.ts',
'src/server/repositories/theme.repository.ts',
'src/server/services/assisted-assembly-ai-render.service.ts',
'src/server/services/approval-workflow.service.ts',
'src/server/services/department.service.ts',
'src/server/services/feedback-workflow.helper.ts',
'src/server/services/intake-node-permission-settings.service.ts',
'src/server/services/product-collaborator.service.ts',
'src/server/services/system-config.service.ts',
'src/server/services/provider-config.service.ts',
'src/server/services/feedback-workflow.service.ts',
'src/server/services/logger.service.ts',
'src/server/services/team.service.ts',
],
requiredGuardNames: [
'SystemConfig_key_valid',
'ProviderConfig_identity_valid',
'ProviderConfig_active_service_key',
'ThemeConfig_name_normalized_key',
'ThemeConfig_default_key',
'IntakeNodePermissionOverride_identity_valid',
'AssistedAssemblyAiRender_identity_valid',
'Feedback_identity_valid',
'ActivityLog_identity_valid',
],
},
{
id: 'legacy-cad-structure',
layer: 'legacy-compatibility',
primaryRelations: ['Panel', 'Hole', 'ProductHardware', 'AssemblyStep', 'AssemblyVersion'],
compatibilitySnapshots: [
'Panel.assemblySemantic',
'ProductHardware.transform',
'AssemblyStep.transform',
'AssemblyVersion.snapshot',
],
notTruthSources: ['3D highlight state', 'assistant assembly preview text'],
writeBoundary: [
'src/server/repositories/assembly.repository.ts',
'src/server/repositories/panel.repository.ts',
'src/server/repositories/product-upload.repository.ts',
'src/server/repositories/product.repository.ts',
'src/server/repositories/hole.repository.ts',
'src/server/repositories/product-hardware.repository.ts',
'src/server/services/intake.service.ts',
'src/server/services/sku-cad-import.helper.ts',
],
requiredGuardNames: [
'Panel_product_sku_code_normalized_key',
'Panel_identity_dimensions_valid',
'Panel_assemblySemantic_object',
'Panel_optional_text_not_blank',
'Hole_geometry_quality',
'Hole_type_allowed',
'Hole_optional_text_not_blank',
'ProductHardware_match_status_allowed',
'ProductHardware_match_confidence_valid',
'ProductHardware_match_method_allowed',
'ProductHardware_quantities_geometry_valid',
'ProductHardware_transform_object',
'ProductHardware_sourceSignature_object',
'ProductHardware_optional_text_not_blank',
'AssemblyStep_identity_order_valid',
'AssemblyVersion_snapshot_array',
],
},
]
module.exports = {
CORE_MASTER_DATA_TRUTH_SOURCES,
}

View File

@ -3,9 +3,27 @@
* 用于验证索引优化效果
*/
import { PrismaClient } from '@prisma/client';
const CONFIRM_TOKEN = 'run-local-performance-test'
type PrismaSingleton = typeof import('../src/lib/prisma')['prisma']
const prisma = new PrismaClient();
let prisma: PrismaSingleton
function assertLocalReadTestAllowed() {
if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) {
throw new Error(`performance-test 只允许显式本地读库测试:请追加 --confirm=${CONFIRM_TOKEN}。`)
}
if (process.env.NODE_ENV === 'production') {
throw new Error('performance-test 禁止在 NODE_ENV=production 下执行。')
}
const databaseUrl = process.env.DATABASE_URL || ''
if (!databaseUrl) {
throw new Error('performance-test 必须先配置 DATABASE_URL。')
}
const host = new URL(databaseUrl).hostname
if (!['localhost', '127.0.0.1', '::1'].includes(host)) {
throw new Error('performance-test 只允许连接本机数据库,避免对生产库制造读压力。')
}
}
interface TestResult {
name: string;
@ -26,6 +44,8 @@ async function measureQuery(name: string, queryFn: () => Promise<unknown>): Prom
}
async function main() {
assertLocalReadTestAllowed()
;({ prisma } = await import('../src/lib/prisma'))
console.log('🚀 开始性能测试...\n');
const results: TestResult[] = [];
@ -365,8 +385,8 @@ async function main() {
main()
.catch((e) => {
console.error('❌ 性能测试失败:', e);
process.exit(1);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
await prisma?.$disconnect();
});

View File

@ -0,0 +1,456 @@
const fs = require('node:fs')
const crypto = require('node:crypto')
const path = require('node:path')
const { Pool } = require('pg')
const {
getPreSkuFormalizationLifecycleMismatchSamples,
} = require('./verify-master-data-foundation')
const ROOT_DIR = path.resolve(__dirname, '..')
const REPAIR_CURRENT_EVIDENCE_KEYS = [
'mismatchReason',
'preSkuId',
'draftGroupId',
'draftGroupCode',
'sequence',
'status',
'linkedFormalSkuId',
'linkedSkuCode',
'linkedSkuDeletedAt',
'linkedProductId',
'linkedProductDeletedAt',
]
const REPAIR_PLAN_TOP_LEVEL_KEYS = new Set([
'mode',
'readOnly',
'applySupported',
'generatedAt',
'databaseIdentity',
'candidateCount',
'reasonCounts',
'evidencePolicy',
'candidates',
'globalGuards',
'outputFile',
])
function parseArgs(argv = process.argv.slice(2)) {
const options = {
outputPath: '',
planPath: '',
validatePlan: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <json-file>。')
}
options.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputPath = arg.slice('--output='.length)
if (!options.outputPath) {
throw new Error('缺少 --output <json-file>。')
}
continue
}
if (arg === '--plan') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --plan <json-file>。')
}
options.planPath = value
index += 1
continue
}
if (arg.startsWith('--plan=')) {
options.planPath = arg.slice('--plan='.length)
if (!options.planPath) {
throw new Error('缺少 --plan <json-file>。')
}
continue
}
if (arg === '--validate-plan') {
options.validatePlan = true
continue
}
throw new Error(`未知参数:${arg}。该脚本只生成只读计划,不支持 apply。`)
}
if (options.validatePlan && !options.planPath) {
throw new Error('--validate-plan 必须同时提供 --plan <json-file>。')
}
if (options.planPath && !options.validatePlan) {
throw new Error('--plan 只允许和 --validate-plan 一起使用。')
}
return options
}
function loadDatabaseUrl(rootDir = ROOT_DIR, env = process.env) {
if (env.DATABASE_URL) return env.DATABASE_URL
const envPath = path.join(rootDir, '.env')
if (!fs.existsSync(envPath)) return ''
const content = fs.readFileSync(envPath, 'utf8')
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex <= 0) continue
if (trimmed.slice(0, separatorIndex).trim() !== 'DATABASE_URL') continue
let value = trimmed.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return value
}
return ''
}
function databaseIdentityFromUrl(databaseUrl) {
if (!databaseUrl) return ''
const parsed = new URL(databaseUrl)
const schema = parsed.searchParams.get('schema') || ''
return [
parsed.protocol.replace(/:$/, '').toLowerCase(),
decodeURIComponent(parsed.username || '').toLowerCase(),
parsed.hostname.toLowerCase(),
parsed.port || '5432',
parsed.pathname.replace(/^\//, '').toLowerCase(),
schema.toLowerCase(),
].join('|')
}
function writeJsonReport(outputPath, data, rootDir = ROOT_DIR) {
if (!outputPath) return ''
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
return absolutePath
}
function readRepairPlan(planPath, rootDir = ROOT_DIR) {
if (!planPath) {
throw new Error('缺少 --plan <json-file>。')
}
const absolutePath = path.resolve(rootDir, planPath)
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'))
}
function getAllowedDecisions(row) {
const reason = String(row?.mismatchReason ?? '')
if (reason === 'formalized_without_product_sku') {
return [
{
decision: 'revert-pre-sku-status',
description: '确认从未生成过正式 ProductSKU 后,将准 SKU 从 FORMALIZED 回退到 PREVIEW_CONFIRMED。',
requires: ['业务负责人确认', '审计记录', '回退后重跑 readiness'],
},
{
decision: 'link-existing-product-sku',
description: '确认正式 ProductSKU 已通过其他链路生成后,用专用服务补齐 ProductSKU.sourcePreSkuId 关系。',
requires: ['正式 SKU ID', '正式 SKU 未删除状态确认', '编码和条码冲突检查'],
},
{
decision: 'archive-invalid-pre-sku',
description: '确认该准 SKU 已作废后,将准 SKU 归档,并同步草稿组整体状态。',
requires: ['作废原因', '业务负责人确认', '关联包装/库位/清单影响检查'],
},
]
}
return [
{
decision: 'manual-investigation-required',
description: '未知错位原因,先人工定位来源链路,再补专用修复动作。',
requires: ['错位原因复核', '专用修复方案'],
},
]
}
function buildCurrentEvidence(row) {
return REPAIR_CURRENT_EVIDENCE_KEYS.reduce((evidence, key) => {
evidence[key] = Object.prototype.hasOwnProperty.call(row ?? {}, key) ? row[key] : null
return evidence
}, {})
}
function buildEvidenceFingerprint(evidence) {
return crypto
.createHash('sha256')
.update(JSON.stringify(evidence))
.digest('hex')
}
function buildPreSkuFormalizationRepairPlan(samples = [], options = {}) {
const rows = Array.isArray(samples) ? samples : []
return {
mode: 'pre-sku-formalization-repair-plan',
readOnly: true,
applySupported: false,
generatedAt: new Date().toISOString(),
databaseIdentity: options.databaseIdentity || '',
candidateCount: rows.length,
reasonCounts: rows.reduce((counts, row) => {
const reason = String(row?.mismatchReason || 'unknown')
counts[reason] = (counts[reason] ?? 0) + 1
return counts
}, {}),
evidencePolicy: {
requiredCurrentEvidenceKeys: REPAIR_CURRENT_EVIDENCE_KEYS,
staleEvidenceRule:
'专用修复入口执行前必须重新读取当前行,并逐项匹配 currentEvidence 与 evidenceFingerprint;任一字段不一致即判定计划过期。',
},
candidates: rows.map((row) => {
const currentEvidence = buildCurrentEvidence(row)
return {
mismatchReason: row.mismatchReason,
preSkuId: row.preSkuId,
draftGroupId: row.draftGroupId,
draftGroupCode: row.draftGroupCode,
sequence: row.sequence,
status: row.status,
linkedFormalSkuId: row.linkedFormalSkuId,
linkedSkuCode: row.linkedSkuCode,
linkedSkuDeletedAt: row.linkedSkuDeletedAt,
linkedProductId: row.linkedProductId,
linkedProductDeletedAt: row.linkedProductDeletedAt,
currentEvidence,
evidenceFingerprint: buildEvidenceFingerprint(currentEvidence),
repairPreconditions: [
'目标数据库身份必须匹配本计划 databaseIdentity;真实写库前还必须匹配授权备份 SHA。',
'执行前重新查询到的 currentEvidence 必须和计划完全一致。',
'执行前必须重新确认草稿组、准 SKU、正式 SKU、包装和库位影响。',
],
allowedDecisions: getAllowedDecisions(row),
}
}),
globalGuards: [
'该计划只读,不执行 UPDATE/INSERT/DELETE。',
'不能在通用主数据清洗脚本里处理准 SKU 生命周期错位。',
'任何真实修复都必须先备份、写入审计原因,并在专用服务中同时校验草稿组、准 SKU、正式 SKU、包装和库位影响。',
'专用修复入口必须拒绝 currentEvidence 或 evidenceFingerprint 已过期的计划。',
'修复后必须重跑 db:verify-master-data:readiness。',
],
}
}
function assertNoUnknownRepairPlanTopLevelKeys(plan) {
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
throw new Error('准 SKU 正式化修复计划必须是 JSON 对象。')
}
const unknownKeys = Object.keys(plan).filter((key) => !REPAIR_PLAN_TOP_LEVEL_KEYS.has(key))
if (unknownKeys.length > 0) {
throw new Error(`准 SKU 正式化修复计划存在不允许的顶层字段:${unknownKeys.join(', ')}。`)
}
}
function validatePreSkuFormalizationRepairPlanShape(plan) {
assertNoUnknownRepairPlanTopLevelKeys(plan)
const blockers = []
const candidates = Array.isArray(plan.candidates) ? plan.candidates : []
const requiredKeys = Array.isArray(plan.evidencePolicy?.requiredCurrentEvidenceKeys)
? plan.evidencePolicy.requiredCurrentEvidenceKeys
: []
if (plan.mode !== 'pre-sku-formalization-repair-plan') blockers.push('mode')
if (plan.readOnly !== true) blockers.push('readOnly')
if (plan.applySupported !== false) blockers.push('applySupported')
if (typeof plan.databaseIdentity !== 'string' || plan.databaseIdentity.length === 0) {
blockers.push('databaseIdentity')
}
if (!Array.isArray(plan.candidates)) blockers.push('candidates')
if (plan.candidateCount !== candidates.length) blockers.push('candidateCount')
const missingPolicyKeys = REPAIR_CURRENT_EVIDENCE_KEYS.filter((key) => !requiredKeys.includes(key))
if (missingPolicyKeys.length > 0) blockers.push('evidencePolicy.requiredCurrentEvidenceKeys')
const candidateBlockers = []
candidates.forEach((candidate, index) => {
const currentEvidence = candidate?.currentEvidence
const candidateIssues = []
if (typeof candidate?.preSkuId !== 'string' || candidate.preSkuId.length === 0) {
candidateIssues.push('preSkuId')
}
if (!currentEvidence || typeof currentEvidence !== 'object' || Array.isArray(currentEvidence)) {
candidateIssues.push('currentEvidence')
} else {
const missingEvidenceKeys = REPAIR_CURRENT_EVIDENCE_KEYS.filter(
(key) => !Object.prototype.hasOwnProperty.call(currentEvidence, key),
)
if (missingEvidenceKeys.length > 0) {
candidateIssues.push(`currentEvidence:${missingEvidenceKeys.join(',')}`)
}
if (candidate.evidenceFingerprint !== buildEvidenceFingerprint(currentEvidence)) {
candidateIssues.push('evidenceFingerprint')
}
}
if (!Array.isArray(candidate?.allowedDecisions) || candidate.allowedDecisions.length === 0) {
candidateIssues.push('allowedDecisions')
}
if (candidateIssues.length > 0) {
candidateBlockers.push({ index, issues: candidateIssues })
}
})
return {
mode: 'validate-pre-sku-formalization-repair-plan-shape',
valid: blockers.length === 0 && candidateBlockers.length === 0,
committed: false,
requiresDatabase: false,
blockerCount: blockers.length + candidateBlockers.length,
blockers,
candidateBlockers,
candidateCount: candidates.length,
requiredCurrentEvidenceKeyCount: requiredKeys.length,
}
}
function validatePreSkuFormalizationRepairPlanFreshness(plan, currentSamples = [], options = {}) {
const shape = validatePreSkuFormalizationRepairPlanShape(plan)
const blockers = shape.blockers.map((key) => ({ key, source: 'plan-shape' }))
shape.candidateBlockers.forEach((candidate) => {
blockers.push({
key: 'candidate-shape',
source: 'plan-shape',
candidateIndex: candidate.index,
issues: candidate.issues,
})
})
const actualDatabaseIdentity = options.databaseIdentity || ''
if (actualDatabaseIdentity && actualDatabaseIdentity !== plan.databaseIdentity) {
blockers.push({
key: 'databaseIdentityMismatch',
source: 'database',
message: '当前数据库身份与计划 databaseIdentity 不一致,禁止继续使用该计划。',
})
}
const currentByPreSkuId = new Map(
(Array.isArray(currentSamples) ? currentSamples : [])
.filter((row) => typeof row?.preSkuId === 'string' && row.preSkuId.length > 0)
.map((row) => [row.preSkuId, row]),
)
const candidateChecks = (Array.isArray(plan.candidates) ? plan.candidates : []).map(
(candidate, index) => {
const currentRow = currentByPreSkuId.get(candidate?.preSkuId)
if (!currentRow) {
return {
index,
ok: false,
reason: 'current-mismatch-not-found',
evidenceFingerprintMatched: false,
}
}
const currentEvidence = buildCurrentEvidence(currentRow)
const currentFingerprint = buildEvidenceFingerprint(currentEvidence)
const evidenceMatched =
JSON.stringify(currentEvidence) === JSON.stringify(candidate.currentEvidence)
const fingerprintMatched = currentFingerprint === candidate.evidenceFingerprint
return {
index,
ok: evidenceMatched && fingerprintMatched,
reason: evidenceMatched && fingerprintMatched ? 'current-evidence-matched' : 'current-evidence-changed',
evidenceFingerprintMatched: fingerprintMatched,
}
},
)
candidateChecks
.filter((check) => !check.ok)
.forEach((check) => {
blockers.push({
key: 'staleCurrentEvidence',
source: 'database',
candidateIndex: check.index,
reason: check.reason,
})
})
return {
mode: 'validate-pre-sku-formalization-repair-plan',
valid: blockers.length === 0,
committed: false,
requiresDatabase: true,
databaseIdentityMatched:
Boolean(actualDatabaseIdentity) && actualDatabaseIdentity === plan.databaseIdentity,
candidateCount: candidateChecks.length,
matchedCandidateCount: candidateChecks.filter((check) => check.ok).length,
staleCandidateCount: candidateChecks.filter((check) => !check.ok).length,
blockerCount: blockers.length,
blockers,
candidateChecks,
}
}
async function main() {
const options = parseArgs()
const databaseUrl = loadDatabaseUrl()
if (!databaseUrl) {
throw new Error('DATABASE_URL 未配置,无法生成或校验准 SKU 正式化修复计划。')
}
const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 })
const client = await pool.connect()
try {
const samples = await getPreSkuFormalizationLifecycleMismatchSamples(client)
if (options.validatePlan) {
const plan = readRepairPlan(options.planPath)
const validation = validatePreSkuFormalizationRepairPlanFreshness(plan, samples, {
databaseIdentity: databaseIdentityFromUrl(databaseUrl),
})
const outputFile = writeJsonReport(options.outputPath, validation)
console.log(JSON.stringify({ ...validation, outputFile }, null, 2))
if (!validation.valid) {
process.exitCode = 1
}
return
}
const plan = buildPreSkuFormalizationRepairPlan(samples, {
databaseIdentity: databaseIdentityFromUrl(databaseUrl),
})
const outputFile = writeJsonReport(options.outputPath, plan)
console.log(JSON.stringify({ ...plan, outputFile }, null, 2))
} finally {
client.release()
await pool.end()
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
parseArgs,
assertNoUnknownRepairPlanTopLevelKeys,
databaseIdentityFromUrl,
loadDatabaseUrl,
readRepairPlan,
writeJsonReport,
getAllowedDecisions,
buildCurrentEvidence,
buildEvidenceFingerprint,
buildPreSkuFormalizationRepairPlan,
validatePreSkuFormalizationRepairPlanFreshness,
validatePreSkuFormalizationRepairPlanShape,
REPAIR_CURRENT_EVIDENCE_KEYS,
}

View File

@ -0,0 +1,603 @@
const fs = require('node:fs')
const path = require('node:path')
const { buildPreflightReport } = require('./preflight-database-foundation-change')
const ROOT_DIR = path.resolve(__dirname, '..')
function parseArgs(argv = process.argv.slice(2)) {
const options = {
preflightPath: '',
rehearsalPath: '',
structureRehearsalPath: '',
outputPath: '',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--preflight') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --preflight <json-file>。')
}
options.preflightPath = value
index += 1
continue
}
if (arg.startsWith('--preflight=')) {
options.preflightPath = arg.slice('--preflight='.length)
if (!options.preflightPath) {
throw new Error('缺少 --preflight <json-file>。')
}
continue
}
if (arg === '--rehearsal') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --rehearsal <json-file>。')
}
options.rehearsalPath = value
index += 1
continue
}
if (arg.startsWith('--rehearsal=')) {
options.rehearsalPath = arg.slice('--rehearsal='.length)
if (!options.rehearsalPath) {
throw new Error('缺少 --rehearsal <json-file>。')
}
continue
}
if (arg === '--structure-rehearsal') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --structure-rehearsal <json-file>。')
}
options.structureRehearsalPath = value
index += 1
continue
}
if (arg.startsWith('--structure-rehearsal=')) {
options.structureRehearsalPath = arg.slice('--structure-rehearsal='.length)
if (!options.structureRehearsalPath) {
throw new Error('缺少 --structure-rehearsal <json-file>。')
}
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <json-file>。')
}
options.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputPath = arg.slice('--output='.length)
if (!options.outputPath) {
throw new Error('缺少 --output <json-file>。')
}
continue
}
throw new Error(`未知参数:${arg}`)
}
return options
}
function readJsonFile(filePath, rootDir = ROOT_DIR) {
if (!filePath) return null
const absolutePath = path.resolve(rootDir, filePath)
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'))
}
function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
if (!outputPath) return ''
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return absolutePath
}
function command(name, commandText, purpose, dangerous = false) {
return {
name,
command: commandText,
purpose,
dangerous,
}
}
function hasBlocker(blockers, key) {
return (blockers || []).some((blocker) => blocker.key === key)
}
function diffStatus(diff) {
if (!diff) return 'missing'
if (diff.skipped) return 'skipped'
if (diff.status === 0) return 'empty'
if (diff.status === 2) return 'not-empty'
if (diff.ok === false) return 'failed'
return 'unknown'
}
function summarizeStructureRehearsal(structureRehearsal = null) {
if (!structureRehearsal) {
return {
provided: false,
status: 'missing',
passed: false,
repairApplyOk: null,
destructiveSqlBlocked: null,
repairSqlArtifact: null,
currentSchemaDiffAfterStatus: null,
migrationDiffAfterStatus: null,
baselineResolveCandidate: null,
baselineResolveMigrationCount: null,
baselineResolveRehearsalOk: null,
baselineResolveAppliedCount: null,
prismaMigrateStatusAfterResolveStatus: null,
requiredMigrationApplyAfterResolveOk: null,
requiredMigrationApplyAfterResolveCount: null,
readinessAfterRequiredMigrationsHardReady: null,
readinessAfterRequiredMigrationsBlockerCount: null,
readinessAfterRequiredMigrationsBlockerKeys: [],
}
}
return {
provided: true,
status: structureRehearsal.status || 'unknown',
passed: structureRehearsal.status === 'structure-repair-rehearsal-passed',
repairApplyOk: structureRehearsal.repairApply?.ok ?? null,
destructiveSqlBlocked:
structureRehearsal.repairSqlSummary?.hasDestructiveStatements === true,
repairSqlArtifact: structureRehearsal.repairSqlArtifact
? {
sizeBytes: structureRehearsal.repairSqlArtifact.sizeBytes ?? 0,
sha256: structureRehearsal.repairSqlArtifact.sha256 || '',
}
: null,
currentSchemaDiffAfterStatus:
structureRehearsal.currentSchemaDiffAfter?.status ?? null,
migrationDiffAfterStatus: structureRehearsal.migrationDiffAfter?.status ?? null,
baselineResolveCandidate:
structureRehearsal.baselineResolvePlan?.candidate ?? null,
baselineResolveMigrationCount:
structureRehearsal.baselineResolvePlan?.migrationCount ?? null,
baselineResolveRehearsalOk:
structureRehearsal.baselineResolveRehearsal?.ok ?? null,
baselineResolveAppliedCount:
structureRehearsal.baselineResolveRehearsal?.appliedCount ?? null,
prismaMigrateStatusAfterResolveStatus:
structureRehearsal.prismaMigrateStatusAfterResolve?.status ?? null,
requiredMigrationApplyAfterResolveOk:
structureRehearsal.requiredMigrationApplyAfterResolve?.ok ?? null,
requiredMigrationApplyAfterResolveCount:
structureRehearsal.requiredMigrationApplyAfterResolve?.appliedMigrationCount ?? null,
readinessAfterRequiredMigrationsHardReady:
structureRehearsal.readinessAfterRequiredMigrations?.hardReady ?? null,
readinessAfterRequiredMigrationsBlockerCount:
structureRehearsal.readinessAfterRequiredMigrations?.blockerCount ?? null,
readinessAfterRequiredMigrationsBlockerKeys:
structureRehearsal.readinessAfterRequiredMigrations?.blockerKeys || [],
}
}
function structureRehearsalProvesMigrationSchemaEquivalence(summary) {
return Boolean(
summary?.passed &&
summary.currentSchemaDiffAfterStatus === 0 &&
summary.migrationDiffAfterStatus === 0,
)
}
function buildEvidenceSummary(preflight, rehearsal = null, structureRehearsal = null) {
const migrationSchemaDiffStatus = diffStatus(preflight.migrationSchemaDiff)
const migrationDiffStatus = diffStatus(preflight.migrationDiff)
const rehearsalP3005 = rehearsal?.migrateDeploy?.p3005Detected === true
const structureRepairRehearsal = summarizeStructureRehearsal(structureRehearsal)
const postRepairEquivalenceProvenByStructureRehearsal =
structureRehearsalProvesMigrationSchemaEquivalence(structureRepairRehearsal)
const migrationSchemaDiffProvenByStructureRehearsal =
migrationSchemaDiffStatus === 'skipped' &&
postRepairEquivalenceProvenByStructureRehearsal
const migrationDiffProvenByStructureRehearsal =
migrationDiffStatus === 'skipped' &&
postRepairEquivalenceProvenByStructureRehearsal
return {
backupUsable: preflight.backupEvidence?.usable === true,
backupVerified: preflight.backupVerification
? preflight.backupVerification.ok === true
: null,
prismaValidateOk: preflight.diagnosisBlockers
? !hasBlocker(preflight.diagnosisBlockers, 'prismaValidate')
: true,
migrationSchemaDiffStatus,
migrationSchemaDiffProvenByStructureRehearsal,
migrationDiffStatus,
migrationDiffProvenByStructureRehearsal,
postRepairEquivalenceProvenByStructureRehearsal,
currentSchemaDiffResolvedByStructureRehearsal:
structureRepairRehearsal.passed &&
preflight.currentSchemaDiffHasSql === true &&
structureRepairRehearsal.currentSchemaDiffAfterStatus === 0,
migrationDiffResolvedByStructureRehearsal:
structureRepairRehearsal.passed &&
migrationDiffStatus === 'not-empty' &&
structureRepairRehearsal.migrationDiffAfterStatus === 0,
currentSchemaDiffHasSql: preflight.currentSchemaDiffHasSql === true,
pendingMigrationCount: preflight.pendingMigrationCount ?? 0,
directMigrateDeployBlockedByP3005: rehearsalP3005,
structureRepairRehearsal,
migrationHistoryMissing: preflight.diagnosisBlockers
? hasBlocker(preflight.diagnosisBlockers, 'migrationHistory.tableExists')
: preflight.recommendedAction?.startsWith('baseline') === true,
}
}
function addEvidenceBlockers(blockers, preflight, evidence) {
if (!evidence.backupUsable) {
blockers.push({
key: 'backupEvidence',
message: '缺少可用数据库备份文件,禁止 baseline、结构修复或 migrate deploy。',
})
} else if (preflight.backupVerification && preflight.backupVerification.ok !== true) {
blockers.push({
key: 'backupVerification',
message: preflight.backupVerification.message || '备份文件未通过可读性核验。',
})
}
if (!evidence.prismaValidateOk) {
blockers.push({
key: 'prismaValidate',
message: 'Prisma schema 校验未通过,不能制定结构修复或 baseline 方案。',
})
}
if (evidence.migrationSchemaDiffStatus === 'skipped' && !evidence.migrationSchemaDiffProvenByStructureRehearsal) {
blockers.push({
key: 'migrationSchemaDiff.skipped',
message: '缺少 migrations 到 schema.prisma 的 shadow diff 证据。',
})
} else if (evidence.migrationSchemaDiffStatus === 'not-empty') {
blockers.push({
key: 'migrationSchemaDiff.notEmpty',
message: 'migrations 重放结果和 schema.prisma 不一致,必须先修迁移链。',
})
} else if (evidence.migrationSchemaDiffStatus === 'failed') {
blockers.push({
key: 'migrationSchemaDiff.failed',
message: 'migrations 到 schema.prisma 的 diff 执行失败,不能证明迁移链可重放。',
})
}
const rehearsedBaselineCount =
evidence.structureRepairRehearsal.baselineResolveMigrationCount
if (
evidence.structureRepairRehearsal.passed &&
rehearsedBaselineCount != null &&
evidence.pendingMigrationCount !== rehearsedBaselineCount
) {
blockers.push({
key: 'structureRepairRehearsal.baselineCountMismatch',
message:
`preflight 待处理 migration 数量为 ${evidence.pendingMigrationCount},` +
`结构修复演练 baseline resolve 数量为 ${rehearsedBaselineCount},` +
'证据不是同一迁移状态,禁止生成授权施工图。',
})
}
if (
evidence.structureRepairRehearsal.passed &&
evidence.structureRepairRehearsal.repairSqlArtifact == null
) {
blockers.push({
key: 'structureRepairRehearsal.repairSqlArtifact',
message: '结构修复演练通过但缺少 SQL artifact 摘要,真实施工前不能定位已审计 SQL。',
})
}
}
function buildPhasePlan(preflight, evidence, status) {
return [
{
id: 'backup',
label: '备份证据',
status: evidence.backupUsable && evidence.backupVerified !== false ? 'passed' : 'blocked',
message: evidence.backupUsable
? '已提供可用备份证据。'
: '真实动库前必须先提供可核验备份。',
},
{
id: 'migration-chain-replay',
label: '迁移链可重放',
status: evidence.migrationSchemaDiffStatus === 'empty' || evidence.migrationSchemaDiffProvenByStructureRehearsal
? 'passed'
: 'blocked',
message: evidence.migrationSchemaDiffStatus === 'empty'
? 'migrations 可从空库重放到 schema.prisma。'
: evidence.migrationSchemaDiffProvenByStructureRehearsal
? '本机 shadow diff 缺失,但克隆库结构修复演练已证明 migrations/schema/修复后结构等价。'
: '迁移链不能证明可重放到当前 schema。',
},
{
id: 'current-db-equivalence',
label: '当前库结构等价性',
status: evidence.migrationDiffStatus === 'empty' && !evidence.currentSchemaDiffHasSql
? 'passed'
: 'blocked',
message: evidence.migrationDiffStatus === 'empty' && !evidence.currentSchemaDiffHasSql
? '当前库结构和 migrations/schema 已等价。'
: '当前库结构仍不等价,不能直接 baseline resolve。',
},
{
id: 'structure-repair-rehearsal',
label: '克隆库结构修复演练',
status: evidence.structureRepairRehearsal.passed
? 'passed'
: evidence.currentSchemaDiffHasSql || evidence.migrationDiffStatus === 'not-empty'
? 'blocked'
: 'not-required',
message: evidence.structureRepairRehearsal.passed
? '克隆库已证明非破坏性结构修复、baseline resolve 和治理护栏路径可重复。'
: evidence.currentSchemaDiffHasSql || evidence.migrationDiffStatus === 'not-empty'
? '结构不等价时必须先取得克隆库结构修复演练证据。'
: '当前库结构已等价,不需要结构修复演练。',
},
{
id: 'operator-runbook',
label: '人工施工口径',
status: status.startsWith('ready-for-') ? 'ready' : 'blocked',
message: status.startsWith('ready-for-')
? '已形成可执行下一步。'
: '仍需先处理阻断项,再允许真实写库。',
},
]
}
function buildRunbook(preflight, evidence, status) {
if (status === 'ready-for-migrate-deploy') {
return {
summary: '当前可进入标准 Prisma migrate deploy 流程,但仍必须在真实执行前重新跑 preflight。',
commands: [
command(
'preflight',
'npm run db:preflight-database-foundation-change',
'真实迁移前重新验证备份、迁移状态和结构差异。',
),
command(
'migrate-deploy',
'npm run db:foundation:migrate -- --apply --confirm=migrate-deploy --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
'按已审计迁移链正式补齐结构。',
true,
),
command(
'readiness',
'npm run db:verify-master-data:readiness',
'迁移后重新验证数据地基总就绪度。',
),
],
}
}
if (status === 'ready-for-baseline-resolve') {
return {
summary: '当前是 baseline resolve 候选:结构等价但迁移历史缺失。必须逐项确认后只补迁移历史。',
commands: [
command(
'resolve-one-migration',
'npm run db:foundation:baseline-resolve -- --apply --confirm=apply-prisma-baseline-resolve --preflight <fresh-preflight.json> --migration-id <migration_id> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
'通过授权包装脚本逐项标记已应用;脚本会校验 preflight、目标库身份、备份 SHA-256 和单个 migration id。',
true,
),
command(
'readiness',
'npm run db:verify-master-data:readiness',
'baseline 后重新验证迁移历史、结构和质量债。',
),
],
pendingMigrationSample: preflight.pendingMigrationSample || [],
}
}
if (status === 'ready-for-authorized-structure-repair') {
return {
summary: '当前只说明结构修复路径已在克隆库演练通过;真实库仍必须重新备份、重新预检并取得明确授权后,按阶段施工。',
commands: [
command(
'fresh-backup',
'node scripts/create-postgres-backup.js --output .database-backups/<fresh-backup>.dump',
'真实施工前重新生成当前数据库备份,并校验 pg_restore 可读。',
),
command(
'fresh-preflight',
'npm run db:preflight-database-foundation-change -- --output .master-data-audits/preflight-before-apply.json',
'用最新备份和当前库重新确认仍是 baseline + structure repair 路径。',
),
command(
'apply-reviewed-structure-repair-sql',
'node scripts/apply-prisma-structure-repair.js --phase=apply-structure --apply --confirm=apply-prisma-structure-repair --expected-sha256 <repair-sql-sha256> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256> --preflight <fresh-preflight.json> --structure-rehearsal <structure-repair-rehearsal.json> --repair-sql <reviewed-structure-repair.sql>',
'用授权施工脚本核对目标库身份、备份 SHA-256、SQL artifact、SQL sha256、无 DROP 类语句和影响面后,写入真实库结构。',
true,
),
command(
'verify-structure-equivalence',
'prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script --exit-code && prisma migrate diff --from-migrations prisma/migrations --to-config-datasource --script --exit-code',
'结构修复后必须证明当前库到 schema、migrations 到当前库都为空 diff。',
),
command(
'baseline-resolve-one-by-one',
'node scripts/apply-prisma-structure-repair.js --phase=resolve-baseline --apply --confirm=apply-prisma-structure-repair --expected-sha256 <repair-sql-sha256> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256> --preflight <post-repair-preflight.json> --structure-rehearsal <structure-repair-rehearsal.json> --repair-sql <reviewed-structure-repair.sql>',
'结构等价后由授权施工脚本按演练清单逐项补迁移历史;目标库、备份和数量必须和演练报告一致。',
true,
),
command(
'apply-foundation-guards',
'npm run db:foundation:apply-required -- --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
'baseline 后应用治理迁移、视图和数据库护栏。',
true,
),
command(
'readiness-and-cleanup',
'npm run db:verify-master-data:readiness && node scripts/apply-master-data-cleanup-plan.js --template',
'确认剩余只剩业务质量债,并输出清洗模板给业务确认。',
),
],
repairSqlArtifact: evidence.structureRepairRehearsal.repairSqlArtifact,
baselineResolveMigrationCount:
evidence.structureRepairRehearsal.baselineResolveMigrationCount,
readinessAfterRequiredMigrationsBlockerKeys:
evidence.structureRepairRehearsal.readinessAfterRequiredMigrationsBlockerKeys,
}
}
if (status === 'manual-structure-repair-required') {
return {
summary: '当前不能 baseline,也不能直接 deploy;必须先在克隆库上形成结构修复 SQL 并演练。',
commands: [
command(
'generate-diff-evidence',
'npm run db:foundation:audit -- --use-docker-shadow --include-migration-rehearsal',
'重新生成 shadow diff 和克隆库演练证据。',
),
command(
'repair-clone-first',
'在克隆库执行结构修复 SQL,再重跑 migrate diff / migrate deploy rehearsal',
'先证明修复方案可重复,不直接写真实库。',
true,
),
],
}
}
return {
summary: '当前证据不足或迁移链未闭合,先处理 blockers。',
commands: [
command(
'full-audit',
'npm run db:foundation:audit',
'重新生成完整只读审计。',
),
],
}
}
function decideStatus(preflight, evidence, blockers) {
if (blockers.length > 0) return 'evidence-blocked'
if (preflight.recommendedAction === 'migrate-deploy-required') return 'ready-for-migrate-deploy'
if (
preflight.recommendedAction === 'baseline-resolve-candidate' &&
evidence.migrationDiffStatus === 'empty' &&
!evidence.currentSchemaDiffHasSql
) {
return 'ready-for-baseline-resolve'
}
if (
preflight.recommendedAction === 'baseline-and-structure-repair-required' ||
evidence.migrationDiffStatus === 'not-empty' ||
evidence.currentSchemaDiffHasSql
) {
if (evidence.structureRepairRehearsal.passed) {
return 'ready-for-authorized-structure-repair'
}
return 'manual-structure-repair-required'
}
return 'evidence-blocked'
}
function buildBaselineRepairPlan({
preflight,
rehearsal = null,
structureRehearsal = null,
checkedAt = new Date().toISOString(),
}) {
const evidence = buildEvidenceSummary(preflight, rehearsal, structureRehearsal)
const blockers = []
addEvidenceBlockers(blockers, preflight, evidence)
if (
blockers.length === 0 &&
preflight.recommendedAction === 'baseline-and-structure-repair-required' &&
evidence.migrationDiffStatus === 'skipped' &&
!evidence.migrationDiffProvenByStructureRehearsal
) {
blockers.push({
key: 'migrationDiff.skipped',
message: '缺少 migrations 与当前库的 shadow diff,不能生成结构修复边界。',
})
}
const status = decideStatus(preflight, evidence, blockers)
if (status === 'manual-structure-repair-required') {
blockers.push({
key: 'currentDatabase.notEquivalent',
message: '当前库和 migrations/schema 结果不等价,必须先做克隆库结构修复演练。',
})
}
const runbook = buildRunbook(preflight, evidence, status)
return {
mode: 'prisma-baseline-and-structure-repair-plan',
status,
checkedAt,
safeForProductionWrite: false,
database: preflight.database,
recommendedAction: preflight.recommendedAction,
evidence,
phasePlan: buildPhasePlan(preflight, evidence, status),
runbook,
forbiddenActions: [
{
name: 'blind-resolve-all',
reason: '不能在结构不等价时批量 prisma migrate resolve --applied。',
},
{
name: 'direct-migrate-deploy-on-baseline-required-db',
reason: evidence.directMigrateDeployBlockedByP3005
? '克隆库已经复现 P3005。'
: '缺少迁移历史的非空库存在 P3005 风险。',
},
{
name: 'runtime-ddl-hotfix',
reason: '底层结构只能通过 migration / 已审计修复 SQL 进入,不允许运行时代码补 DDL。',
},
],
blockers,
}
}
async function main() {
const options = parseArgs()
const preflight = readJsonFile(options.preflightPath) || await buildPreflightReport()
const rehearsal = readJsonFile(options.rehearsalPath)
const structureRehearsal = readJsonFile(options.structureRehearsalPath)
const plan = buildBaselineRepairPlan({ preflight, rehearsal, structureRehearsal })
writeJsonReport(options.outputPath, plan)
console.log(JSON.stringify(plan, null, 2))
if (!plan.status.startsWith('ready-for-')) {
process.exit(1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
buildBaselineRepairPlan,
buildEvidenceSummary,
buildPhasePlan,
buildRunbook,
decideStatus,
diffStatus,
parseArgs,
readJsonFile,
structureRehearsalProvesMigrationSchemaEquivalence,
summarizeStructureRehearsal,
writeJsonReport,
}

View File

@ -0,0 +1,596 @@
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const {
buildBaselineDiagnosis,
describeDatabaseUrl,
listMigrationDirectories,
loadDatabaseUrl,
loadShadowDatabaseUrl,
parsePendingPrismaMigrations,
getMigrationHistoryStatus,
runCurrentSchemaDiff,
runMigrationDiffWithShadow,
runMigrationSchemaDiffWithShadow,
runPrismaCommand,
} = require('./diagnose-prisma-migration-baseline')
const ROOT_DIR = path.resolve(__dirname, '..')
function parseArgs(argv = process.argv.slice(2)) {
const options = {
outputPath: '',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <json-file>。')
}
options.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputPath = arg.slice('--output='.length)
if (!options.outputPath) {
throw new Error('缺少 --output <json-file>。')
}
continue
}
throw new Error(`未知参数:${arg}`)
}
return options
}
function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
if (!outputPath) return ''
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return absolutePath
}
function summarizePrismaDiffResult(result) {
if (!result) return null
return {
skipped: Boolean(result.skipped),
ok: Boolean(result.ok),
status: result.status ?? null,
reason: result.reason || '',
stdoutPreview: String(result.stdout ?? '').slice(0, 1000),
stderrPreview: String(result.stderr ?? '').slice(0, 1000),
error: result.error || '',
}
}
function getBackupEvidence(env = process.env, rootDir = ROOT_DIR) {
const backupPath = env.DATABASE_BACKUP_FILE || env.DB_BACKUP_FILE || ''
if (!backupPath) {
return {
provided: false,
path: '',
exists: false,
sizeBytes: 0,
usable: false,
message: '未提供 DATABASE_BACKUP_FILE / DB_BACKUP_FILE。',
}
}
const absolutePath = path.resolve(rootDir, backupPath)
const exists = fs.existsSync(absolutePath)
const sizeBytes = exists ? fs.statSync(absolutePath).size : 0
const sha256 = exists && sizeBytes > 0
? crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex')
: ''
return {
provided: true,
path: absolutePath,
exists,
sizeBytes,
sha256,
usable: exists && sizeBytes > 0,
message: exists && sizeBytes > 0
? '已找到非空数据库备份文件。'
: exists
? '提供了备份文件路径,但文件为空。'
: '提供了备份文件路径,但文件不存在。',
}
}
function envNameForPostgresTool(command) {
return `${String(command).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_PATH`
}
function resolvePostgresTool(command, env = process.env) {
const specificPath = env[envNameForPostgresTool(command)] || ''
if (specificPath) {
return {
command,
executable: specificPath,
configuredBy: envNameForPostgresTool(command),
}
}
const binDir = env.POSTGRES_BIN_DIR || env.PG_BIN_DIR || ''
if (binDir) {
const executableName = process.platform === 'win32' ? `${command}.exe` : command
return {
command,
executable: path.resolve(binDir, executableName),
configuredBy: env.POSTGRES_BIN_DIR ? 'POSTGRES_BIN_DIR' : 'PG_BIN_DIR',
}
}
return {
command,
executable: command,
configuredBy: 'PATH',
}
}
function postgresToolExecutableName(command) {
return process.platform === 'win32' ? `${command}.exe` : command
}
function splitSearchDirs(value) {
return String(value || '')
.split(path.delimiter)
.map((item) => item.trim())
.filter(Boolean)
}
function defaultPostgresClientSearchRoots(env = process.env) {
const roots = [
...splitSearchDirs(env.POSTGRES_CLIENT_SEARCH_DIRS),
env.POSTGRES_BIN_DIR,
env.PG_BIN_DIR,
]
if (process.platform === 'win32') {
roots.push(
env.ProgramFiles ? path.join(env.ProgramFiles, 'PostgreSQL') : '',
env['ProgramFiles(x86)'] ? path.join(env['ProgramFiles(x86)'], 'PostgreSQL') : '',
env.ProgramW6432 ? path.join(env.ProgramW6432, 'PostgreSQL') : '',
'C:\\PostgreSQL',
)
} else {
roots.push('/usr/bin', '/usr/local/bin', '/opt/homebrew/bin', '/opt/PostgreSQL')
}
return Array.from(new Set(roots.filter(Boolean).map((root) => path.resolve(root))))
}
function listChildDirectories(rootDir) {
try {
if (!fs.existsSync(rootDir)) return []
return fs.readdirSync(rootDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(rootDir, entry.name))
} catch {
return []
}
}
function candidateBinDirsFromRoot(rootDir) {
const candidates = [rootDir]
if (path.basename(rootDir).toLowerCase() !== 'bin') {
candidates.push(path.join(rootDir, 'bin'))
}
for (const child of listChildDirectories(rootDir)) {
candidates.push(child)
candidates.push(path.join(child, 'bin'))
}
return candidates
}
function findPostgresClientCandidateDirs(env = process.env) {
const commands = ['pg_dump', 'pg_restore', 'psql']
const candidateDirs = []
const seen = new Set()
for (const rootDir of defaultPostgresClientSearchRoots(env)) {
for (const dir of candidateBinDirsFromRoot(rootDir)) {
const resolvedDir = path.resolve(dir)
if (seen.has(resolvedDir)) continue
seen.add(resolvedDir)
const foundCommands = commands.filter((command) =>
fs.existsSync(path.join(resolvedDir, postgresToolExecutableName(command))),
)
if (foundCommands.length === 0) continue
candidateDirs.push({
dir: resolvedDir,
foundCommands,
complete: foundCommands.length === commands.length,
})
}
}
return candidateDirs
}
function checkDockerAvailability(env = process.env) {
const dockerVersion = spawnSync('docker', ['--version'], {
encoding: 'utf8',
env: { ...process.env, ...env },
windowsHide: true,
})
const available = dockerVersion.status === 0
const postgresImage = env.POSTGRES_DOCKER_IMAGE || 'postgres:16'
let postgresImageAvailable = false
let postgresImageInspectStatus = null
let postgresImageInspectError = ''
if (available) {
const inspect = spawnSync('docker', ['image', 'inspect', postgresImage], {
encoding: 'utf8',
env: { ...process.env, ...env },
windowsHide: true,
})
postgresImageAvailable = inspect.status === 0
postgresImageInspectStatus = inspect.status
postgresImageInspectError = inspect.error ? String(inspect.error.message ?? inspect.error) : ''
}
return {
command: 'docker',
available,
status: dockerVersion.status,
stdout: String(dockerVersion.stdout ?? '').trim(),
stderr: String(dockerVersion.stderr ?? '').trim(),
error: dockerVersion.error ? String(dockerVersion.error.message ?? dockerVersion.error) : '',
postgresImage,
postgresImageAvailable,
postgresImageInspectStatus,
postgresImageInspectError,
}
}
function buildDockerPgRestoreListArgs(backupPath, dockerImage = 'postgres:16') {
const absolutePath = path.resolve(backupPath)
return {
args: [
'run',
'--rm',
'-v',
`${path.dirname(absolutePath)}:/backup`,
dockerImage,
'pg_restore',
'--list',
`/backup/${path.basename(absolutePath)}`,
],
containerBackupPath: `/backup/${path.basename(absolutePath)}`,
volumeArg: `${path.dirname(absolutePath)}:/backup`,
}
}
function buildBackupToolingHint(
backupTooling,
env = process.env,
dockerAvailability = checkDockerAvailability(env),
) {
const missingCommands = Object.values(backupTooling)
.filter((tool) => !tool.available)
.map((tool) => tool.command)
const candidateDirs = findPostgresClientCandidateDirs(env)
const completeCandidate = candidateDirs.find((candidate) => candidate.complete)
return {
missingCommands,
candidateDirs,
recommendedEnv: completeCandidate
? {
POSTGRES_BIN_DIR: completeCandidate.dir,
reason: '该目录同时包含 pg_dump / pg_restore / psql,可直接作为 PostgreSQL client bin 目录。',
}
: null,
docker: dockerAvailability,
dockerFallback: missingCommands.length > 0 && dockerAvailability.available
? {
postgresImage: dockerAvailability.postgresImage,
imageAvailable: dockerAvailability.postgresImageAvailable,
reason: dockerAvailability.postgresImageAvailable
? '本机 Docker 可用且已有 PostgreSQL client 镜像,可作为备份工具链兜底。'
: '本机 Docker 可用但未发现指定 PostgreSQL client 镜像;如需 Docker 兜底,必须先明确拉取/准备镜像。',
note:
'Docker 兜底仍必须生成可被 pg_restore --list 验证的备份文件,且不能替代 DATABASE_BACKUP_FILE 证据要求。',
}
: null,
configuredSearchDirs: defaultPostgresClientSearchRoots(env),
}
}
function checkCommandAvailable(command, args = ['--version'], env = process.env) {
const resolved = resolvePostgresTool(command, env)
const result = spawnSync(resolved.executable, args, {
encoding: 'utf8',
windowsHide: true,
})
return {
command,
executable: resolved.executable,
configuredBy: resolved.configuredBy,
available: result.status === 0,
status: result.status,
stdout: String(result.stdout ?? '').trim(),
stderr: String(result.stderr ?? '').trim(),
error: result.error ? String(result.error.message ?? result.error) : '',
}
}
function checkBackupTooling(env = process.env) {
return {
pgDump: checkCommandAvailable('pg_dump', ['--version'], env),
pgRestore: checkCommandAvailable('pg_restore', ['--version'], env),
psql: checkCommandAvailable('psql', ['--version'], env),
}
}
function verifyBackupFile(backupEvidence, backupTooling, env = process.env) {
if (!backupEvidence.usable) {
return {
attempted: false,
ok: false,
message: '没有可用备份文件,跳过备份格式校验。',
}
}
if (!backupTooling.pgRestore.available) {
const docker = checkDockerAvailability(env)
if (backupEvidence.path && docker.available && docker.postgresImageAvailable) {
const dockerPgRestore = buildDockerPgRestoreListArgs(
backupEvidence.path,
docker.postgresImage,
)
const result = spawnSync('docker', dockerPgRestore.args, {
encoding: 'utf8',
env: { ...process.env, ...env },
windowsHide: true,
})
return {
attempted: true,
ok: result.status === 0,
status: result.status,
stdoutPreview: String(result.stdout ?? '').slice(0, 500),
stderr: String(result.stderr ?? '').trim(),
error: result.error ? String(result.error.message ?? result.error) : '',
tooling: {
executable: 'docker',
configuredBy: 'POSTGRES_DOCKER_IMAGE',
dockerImage: docker.postgresImage,
},
message: result.status === 0
? '备份文件可被 Docker pg_restore --list 读取。'
: '备份文件无法被 Docker pg_restore --list 读取,不能作为迁移兜底。请确认它是 PostgreSQL custom 格式有效备份。',
}
}
return {
attempted: false,
ok: false,
message: 'pg_restore 不可用,无法验证 PostgreSQL custom 备份是否可读取。',
tooling: backupTooling.pgRestore,
}
}
const pgRestore = resolvePostgresTool('pg_restore', env)
const result = spawnSync(pgRestore.executable, ['--list', backupEvidence.path], {
encoding: 'utf8',
windowsHide: true,
})
return {
attempted: true,
ok: result.status === 0,
status: result.status,
stdoutPreview: String(result.stdout ?? '').slice(0, 500),
stderr: String(result.stderr ?? '').trim(),
error: result.error ? String(result.error.message ?? result.error) : '',
tooling: {
executable: pgRestore.executable,
configuredBy: pgRestore.configuredBy,
},
message: result.status === 0
? '备份文件可被 pg_restore --list 读取。'
: '备份文件无法被 pg_restore --list 读取,不能作为迁移兜底。请确认它是 PostgreSQL custom 格式有效备份。',
}
}
function decideFoundationChangeReadiness({
diagnosis,
backupEvidence,
backupTooling = null,
backupToolingHint = null,
backupVerification = null,
}) {
const blockers = []
if (!backupEvidence.usable) {
blockers.push({
key: 'backupEvidence',
message: '数据库结构变更或 baseline 前必须先提供可核验的备份文件路径。',
})
const directPgDumpAvailable = Boolean(backupTooling?.pgDump?.available)
const dockerBackupAvailable = Boolean(
backupToolingHint?.docker?.available
&& backupToolingHint?.dockerFallback?.imageAvailable,
)
if (backupTooling && !directPgDumpAvailable && !dockerBackupAvailable) {
blockers.push({
key: 'backupTooling.pgDump',
message: '当前环境没有可用 pg_dump,也没有已准备好的 Docker PostgreSQL client 镜像,无法直接生成标准 PostgreSQL 备份。',
})
}
} else if (backupVerification && !backupVerification.ok) {
blockers.push({
key: backupVerification.tooling?.command === 'pg_restore'
? 'backupTooling.pgRestore'
: 'backupVerification',
message: backupVerification.message,
})
}
if (!diagnosis.prismaValidate.ok) {
blockers.push({
key: 'prismaValidate',
message: 'Prisma schema 校验未通过,不能进入数据库地基变更。',
})
}
const baselineRequired = Boolean(diagnosis.baselineRequired)
const currentSchemaDiffNotEmpty = Boolean(diagnosis.currentSchemaDiff?.hasSqlDiff)
const migrationDiffSkipped = Boolean(diagnosis.migrationDiff?.skipped)
const migrationDiffNotEmpty = diagnosis.migrationDiff?.status === 2
const migrationSchemaDiffNotEmpty = diagnosis.migrationSchemaDiff?.status === 2
const migrationSchemaDiffFailed = Boolean(
diagnosis.migrationSchemaDiff
&& !diagnosis.migrationSchemaDiff.skipped
&& !diagnosis.migrationSchemaDiff.ok
&& diagnosis.migrationSchemaDiff.status !== 2,
)
const pendingMigrationCount = diagnosis.pendingMigrationCount ?? 0
let recommendedAction = 'none'
if (migrationSchemaDiffNotEmpty || migrationSchemaDiffFailed) {
blockers.push({
key: migrationSchemaDiffNotEmpty ? 'migrationSchemaDiff.notEmpty' : 'migrationSchemaDiff.error',
message: migrationSchemaDiffNotEmpty
? 'migrations 重放结果和 schema.prisma 不一致,必须先修迁移链,不能进入 baseline 或 deploy。'
: 'migrations 到 schema.prisma 的 diff 执行失败,不能证明迁移链可重放到当前结构。',
})
}
if (baselineRequired) {
recommendedAction = currentSchemaDiffNotEmpty
? 'baseline-and-structure-repair-required'
: 'baseline-resolve-candidate'
blockers.push({
key: 'migrationHistory.baselineRequired',
message: '目标库已有业务结构但缺少 Prisma 迁移历史,直接 migrate deploy 会被 Prisma P3005 拦截;必须先形成 baseline/结构修复方案。',
})
if (migrationDiffSkipped) {
blockers.push({
key: 'migrationDiff.skipped',
message: '缺少 shadow diff 证据,不能判断哪些 migration 可标记为已应用、哪些需要真实执行。',
})
} else if (migrationDiffNotEmpty) {
blockers.push({
key: 'migrationDiff.notEmpty',
message: 'migrations 重放结果和当前库结构不一致,不能直接 baseline resolve。',
})
}
} else if (currentSchemaDiffNotEmpty) {
recommendedAction = 'migrate-deploy-required'
} else if (pendingMigrationCount > 0 && migrationDiffSkipped) {
recommendedAction = 'baseline-evidence-required'
blockers.push({
key: 'migrationDiff.skipped',
message: '缺少 shadow diff 证据,不能判断是否可 baseline resolve。',
})
} else if (pendingMigrationCount > 0 && migrationDiffNotEmpty) {
recommendedAction = 'migrate-deploy-required'
blockers.push({
key: 'migrationDiff.notEmpty',
message: 'migrations 重放结果和当前库结构不一致,不能 baseline resolve。',
})
} else if (pendingMigrationCount > 0) {
recommendedAction = 'baseline-resolve-candidate'
}
return {
ready: blockers.length === 0,
recommendedAction,
blockers,
}
}
async function buildPreflightReport(env = process.env) {
const databaseUrl = loadDatabaseUrl(ROOT_DIR, env)
const shadowDatabaseUrl = loadShadowDatabaseUrl(ROOT_DIR, env)
const backupEvidence = getBackupEvidence(env)
const backupTooling = checkBackupTooling(env)
const backupToolingHint = buildBackupToolingHint(backupTooling, env)
const backupVerification = verifyBackupFile(backupEvidence, backupTooling, env)
const database = describeDatabaseUrl(databaseUrl)
const migrationDirectories = listMigrationDirectories()
const prismaValidate = runPrismaCommand(['validate'])
const prismaMigrateStatus = runPrismaCommand(['migrate', 'status'])
const currentSchemaDiff = runCurrentSchemaDiff()
const migrationDiff = runMigrationDiffWithShadow(shadowDatabaseUrl, databaseUrl)
const migrationSchemaDiff = runMigrationSchemaDiffWithShadow(shadowDatabaseUrl, databaseUrl)
const migrationHistory = await getMigrationHistoryStatus(databaseUrl)
const diagnosis = buildBaselineDiagnosis({
database,
migrationDirectories,
prismaValidate,
prismaMigrateStatus,
currentSchemaDiff,
migrationDiff,
migrationSchemaDiff,
migrationHistory,
})
const readiness = decideFoundationChangeReadiness({
diagnosis,
backupEvidence,
backupTooling,
backupToolingHint,
backupVerification,
})
return {
mode: 'database-foundation-change-preflight',
checkedAt: new Date().toISOString(),
ready: readiness.ready,
recommendedAction: readiness.recommendedAction,
database,
backupEvidence,
backupTooling,
backupToolingHint,
backupVerification,
pendingMigrationCount: diagnosis.pendingMigrationCount,
currentSchemaDiffHasSql: Boolean(diagnosis.currentSchemaDiff?.hasSqlDiff),
currentSchemaDiff: summarizePrismaDiffResult(currentSchemaDiff),
migrationDiffSkipped: Boolean(diagnosis.migrationDiff?.skipped),
migrationDiff: summarizePrismaDiffResult(migrationDiff),
migrationSchemaDiff: summarizePrismaDiffResult(migrationSchemaDiff),
blockers: readiness.blockers,
diagnosisBlockers: diagnosis.blockers,
pendingMigrationSample: parsePendingPrismaMigrations(prismaMigrateStatus.stdout).slice(0, 10),
}
}
async function main() {
const args = parseArgs()
const report = await buildPreflightReport()
writeJsonReport(args.outputPath, report)
console.log(JSON.stringify(report, null, 2))
if (!report.ready) {
process.exit(1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
buildPreflightReport,
buildBackupToolingHint,
buildDockerPgRestoreListArgs,
summarizePrismaDiffResult,
checkBackupTooling,
checkCommandAvailable,
checkDockerAvailability,
decideFoundationChangeReadiness,
envNameForPostgresTool,
findPostgresClientCandidateDirs,
getBackupEvidence,
parseArgs,
postgresToolExecutableName,
resolvePostgresTool,
verifyBackupFile,
writeJsonReport,
}

View File

@ -0,0 +1,237 @@
const fs = require('node:fs')
const path = require('node:path')
const DEFAULT_MANIFEST = path.join(
'.master-data-audits',
'foundation-audit-current-goal',
'manifest.json',
)
const DEFAULT_MAX_MANIFEST_AGE_MINUTES = 60
function parseArgs(argv = process.argv.slice(2)) {
const options = {
manifestFile: DEFAULT_MANIFEST,
maxAgeMinutes: Number(
process.env.MASTER_DATA_CHECKLIST_MAX_AGE_MINUTES ||
DEFAULT_MAX_MANIFEST_AGE_MINUTES,
),
outputFile: '',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--manifest') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --manifest <file>。')
}
options.manifestFile = value
index += 1
continue
}
if (arg.startsWith('--manifest=')) {
options.manifestFile = arg.slice('--manifest='.length)
if (!options.manifestFile) {
throw new Error('缺少 --manifest <file>。')
}
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <file>。')
}
options.outputFile = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputFile = arg.slice('--output='.length)
if (!options.outputFile) {
throw new Error('缺少 --output <file>。')
}
continue
}
if (arg === '--max-age-minutes') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --max-age-minutes <minutes>。')
}
options.maxAgeMinutes = Number(value)
index += 1
continue
}
if (arg.startsWith('--max-age-minutes=')) {
options.maxAgeMinutes = Number(arg.slice('--max-age-minutes='.length))
if (!Number.isFinite(options.maxAgeMinutes)) {
throw new Error('缺少 --max-age-minutes <minutes>。')
}
continue
}
throw new Error(`未知参数:${arg}`)
}
if (!Number.isFinite(options.maxAgeMinutes) || options.maxAgeMinutes <= 0) {
throw new Error('--max-age-minutes 必须是大于 0 的数字。')
}
return {
manifestFile: path.resolve(options.manifestFile),
maxAgeMinutes: options.maxAgeMinutes,
outputFile: options.outputFile ? path.resolve(options.outputFile) : '',
}
}
function readJsonFile(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`找不到地基审计 manifest:${filePath}`)
}
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function formatDisplayPath(filePath, cwd = process.cwd()) {
if (!filePath) return ''
const relativePath = path.relative(cwd, filePath)
if (
relativePath &&
!relativePath.startsWith('..') &&
!path.isAbsolute(relativePath)
) {
return relativePath || '.'
}
return path.basename(filePath)
}
function buildManifestFreshness(manifest, options = {}) {
const checkedAt = typeof manifest?.checkedAt === 'string' ? manifest.checkedAt : ''
const now = options.now instanceof Date ? options.now : new Date()
const maxAgeMinutes = Number(options.maxAgeMinutes ?? DEFAULT_MAX_MANIFEST_AGE_MINUTES)
const checkedAtMs = checkedAt ? Date.parse(checkedAt) : NaN
const nowMs = now.getTime()
const ageMinutes = Number.isFinite(checkedAtMs)
? Math.max(0, (nowMs - checkedAtMs) / 60_000)
: null
const fresh =
Number.isFinite(maxAgeMinutes) &&
maxAgeMinutes > 0 &&
ageMinutes !== null &&
ageMinutes <= maxAgeMinutes
return {
checkedAt,
evaluatedAt: now.toISOString(),
maxAgeMinutes,
ageMinutes,
fresh,
stale: !fresh,
reason: fresh
? ''
: checkedAt
? '地基审计 manifest 已过期,必须重新运行完整地基审计后才能作为当前完成证据。'
: '地基审计 manifest 缺少 checkedAt,不能作为当前完成证据。',
}
}
function buildCompletionChecklistReport(manifest, manifestFile = '', options = {}) {
const checklist = manifest?.foundationCompletionChecklist
if (!checklist || typeof checklist !== 'object') {
throw new Error('manifest 中缺少 foundationCompletionChecklist,请先运行完整地基审计。')
}
const freshness = buildManifestFreshness(manifest, options)
const checklistItems = Array.isArray(checklist.items) ? checklist.items : []
const freshnessItem = {
id: 'audit-manifest-freshness',
label: '地基审计证据新鲜度',
status: freshness.fresh ? 'passed' : 'blocked',
passed: freshness.fresh,
blockedBy: freshness.fresh ? [] : ['audit-manifest-stale'],
evidence: {
checkedAt: freshness.checkedAt,
evaluatedAt: freshness.evaluatedAt,
ageMinutes: freshness.ageMinutes,
maxAgeMinutes: freshness.maxAgeMinutes,
reason: freshness.reason,
},
}
const items = [...checklistItems, freshnessItem]
return {
mode: 'master-data-foundation-completion-checklist',
sourceManifest: manifestFile,
checkedAt: manifest.checkedAt || '',
freshness,
hardReady: manifest.hardReady === true,
complete: checklist.complete === true && freshness.fresh,
itemCount: Number(checklist.itemCount ?? checklistItems.length) + 1,
passedCount: Number(checklist.passedCount ?? 0) + (freshness.fresh ? 1 : 0),
blockedCount: Number(checklist.blockedCount ?? 0) + (freshness.fresh ? 0 : 1),
missingCount: Number(checklist.missingCount ?? 0),
blockedItems: items
.filter((item) => item?.status !== 'passed')
.map((item) => ({
id: item.id,
label: item.label,
status: item.status,
blockedBy: Array.isArray(item.blockedBy) ? item.blockedBy : [],
evidence: item.evidence || {},
})),
items: items.map((item) => ({
id: item.id,
label: item.label,
status: item.status,
passed: item.passed === true,
blockedBy: Array.isArray(item.blockedBy) ? item.blockedBy : [],
})),
}
}
function writeJsonReport(filePath, payload) {
if (!filePath) return ''
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return filePath
}
function run(options = parseArgs()) {
const manifest = readJsonFile(options.manifestFile)
const report = buildCompletionChecklistReport(
manifest,
formatDisplayPath(options.manifestFile),
{
maxAgeMinutes: options.maxAgeMinutes,
now: options.now,
},
)
writeJsonReport(options.outputFile, report)
return report
}
function main() {
const report = run()
console.log(JSON.stringify(report, null, 2))
if (!report.complete) {
process.exit(1)
}
}
if (require.main === module) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
module.exports = {
DEFAULT_MANIFEST,
DEFAULT_MAX_MANIFEST_AGE_MINUTES,
buildCompletionChecklistReport,
buildManifestFreshness,
formatDisplayPath,
parseArgs,
readJsonFile,
run,
writeJsonReport,
}

View File

@ -0,0 +1,591 @@
const fs = require('node:fs')
const path = require('node:path')
const DEFAULT_MANIFEST = path.join(
'.master-data-audits',
'foundation-audit-current-goal',
'manifest.json',
)
const DEFAULT_OUTPUT = path.join(
'.master-data-audits',
'foundation-audit-current-goal',
'decision-package.json',
)
function parseArgs(argv = process.argv.slice(2)) {
const options = {
manifestFile: DEFAULT_MANIFEST,
outputFile: '',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--manifest') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --manifest <file>。')
}
options.manifestFile = value
index += 1
continue
}
if (arg.startsWith('--manifest=')) {
options.manifestFile = arg.slice('--manifest='.length)
if (!options.manifestFile) {
throw new Error('缺少 --manifest <file>。')
}
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <file>。')
}
options.outputFile = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputFile = arg.slice('--output='.length)
if (!options.outputFile) {
throw new Error('缺少 --output <file>。')
}
continue
}
if (arg === '--write-default') {
options.outputFile = DEFAULT_OUTPUT
continue
}
throw new Error(`未知参数:${arg}`)
}
return {
manifestFile: path.resolve(options.manifestFile),
outputFile: options.outputFile ? path.resolve(options.outputFile) : '',
}
}
function readJsonFile(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`找不到地基审计 manifest:${filePath}`)
}
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function readOptionalJsonFile(filePath) {
if (!filePath || !fs.existsSync(filePath)) return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function formatDisplayPath(filePath, cwd = process.cwd()) {
if (!filePath) return ''
const relativePath = path.relative(cwd, filePath)
if (
relativePath &&
!relativePath.startsWith('..') &&
!path.isAbsolute(relativePath)
) {
return relativePath || '.'
}
return path.basename(filePath)
}
function asArray(value) {
return Array.isArray(value) ? value : []
}
function findChecklistItem(manifest, id) {
return asArray(manifest?.foundationCompletionChecklist?.items).find(
(item) => item?.id === id,
)
}
function findNextActionStage(manifest, id) {
return asArray(manifest?.readinessSummary?.nextActionStages).find(
(stage) => stage?.id === id,
)
}
function getBlockerValues(item) {
return item?.evidence?.blockerValues && typeof item.evidence.blockerValues === 'object'
? item.evidence.blockerValues
: {}
}
function summarizeAuthorizedStructurePlan(plan) {
if (!plan || typeof plan !== 'object') {
return {
available: false,
}
}
return {
available: true,
phase: plan.phase || '',
dryRun: plan.dryRun === true,
readyToApply: plan.readyToApply === true,
checkedAt: plan.checkedAt || '',
expectedDatabaseIdentity: plan.expectedDatabaseIdentity || '',
expectedBackupSha256: plan.expectedBackupSha256 || '',
expectedRepairSqlSha256: plan.expectedSha256 || '',
repairSqlSha256: plan.repairSqlArtifact?.sha256 || '',
repairSqlSizeBytes: plan.repairSqlArtifact?.sizeBytes ?? null,
repairSqlHasDestructiveStatements:
plan.repairSqlSafety?.hasDestructiveStatements === true,
blockerCount: asArray(plan.blockers).length,
exactPhaseCommandsAvailable: plan.exactPhaseCommands?.available === true,
applyStructureCommandReady: Boolean(plan.exactPhaseCommands?.applyStructure),
resolveBaselineTemplateReady: Boolean(plan.exactPhaseCommands?.resolveBaselineTemplate),
}
}
function buildStructureStage(manifest, sidecars = {}) {
const checklistItem = findChecklistItem(manifest, 'schema-and-migration-history')
const nextAction = findNextActionStage(manifest, 'repair-prisma-structure-and-history')
const baseline = manifest?.baselineRepairPlanSummary || {}
const structure = manifest?.structureRepairRehearsalSummary || {}
const authorized = manifest?.authorizedStructureRepairApplyPlanSummary || {}
const authorizedPlan = summarizeAuthorizedStructurePlan(
sidecars.authorizedStructureRepairApplyPlan,
)
const blockerValues = getBlockerValues(checklistItem)
return {
id: 'schema-and-migration-history',
label: '数据库结构与 Prisma 迁移历史',
status: checklistItem?.status || 'missing',
requiresExplicitAuthorization: true,
authorizationState: authorized.executionState || 'unknown',
recommendedAction:
baseline.recommendedAction || nextAction?.id || 'repair-prisma-structure-and-history',
blockerValues: {
pendingMigrations: blockerValues['prisma-migrate-status:pendingMigrations'] ?? null,
hasSchemaDiff: blockerValues['current-schema-diff:hasSqlDiff'] ?? null,
migrationHistoryTableExists:
blockerValues['migration-status:migrationHistory.tableExists'] ?? null,
pendingStructureMigrationCount:
blockerValues['migration-status:pendingStructureMigrationCount'] ?? null,
pendingIdempotentMigrationCount:
blockerValues['migration-status:pendingIdempotentMigrationCount'] ?? null,
fullyReady: blockerValues['migration-status:fullyReady'] ?? null,
},
evidence: {
baselinePlanStatus: baseline.status || '',
structureRepairRehearsalStatus: structure.status || '',
structureRepairSqlSha256: structure.repairSqlArtifact?.sha256 || '',
structureRepairSqlSizeBytes: structure.repairSqlArtifact?.sizeBytes ?? null,
destructiveSqlBlocked: structure.destructiveSqlBlocked === true,
repairSqlHasDestructiveStatements:
authorized.repairSqlSafety?.hasDestructiveStatements === true,
baselineResolveMigrationCount: structure.baselineResolveMigrationCount ?? null,
baselineResolveRehearsalOk: structure.baselineResolveRehearsalOk === true,
requiredMigrationApplyAfterResolveOk:
structure.requiredMigrationApplyAfterResolveOk === true,
expectedDatabaseIdentityInManifestSummary: Boolean(
authorized.expectedDatabaseIdentity,
),
expectedBackupSha256InManifestSummary: Boolean(authorized.expectedBackupSha256),
exactPhaseCommandsAvailable:
authorized.exactPhaseCommandAvailability?.available === true,
authorizedPlan,
},
requiredEvidence: [
'新鲜 PostgreSQL custom 格式备份及 SHA-256',
'新鲜 preflight 报告',
'结构修复克隆库演练报告',
'结构修复 SQL 文件及 SHA-256',
'目标数据库身份',
'用户明确授权真实写库',
],
commands: asArray(nextAction?.commands),
forbiddenActions: [
'没有新鲜备份和 preflight 时执行真实写库',
'在当前库直接重试 prisma migrate deploy',
'结构 diff 未归零时执行 baseline resolve',
'手工复制 psql 或 migrate resolve 命令绕过授权脚本',
],
}
}
function buildGovernanceMigrationStage(manifest) {
const nextAction = findNextActionStage(manifest, 'apply-governance-migrations')
const required = manifest?.foundationRequiredMigrationApplyPlanSummary || {}
return {
id: 'apply-governance-migrations',
label: '治理迁移、外部只读视图与数据库护栏落库',
status: required.readyToApply === true ? 'ready-to-apply' : 'blocked',
mustRunAfter: 'schema-and-migration-history',
blockerKeys: asArray(required.blockerKeys),
evidence: {
readyToApply: required.readyToApply === true,
pendingMigrationCount: required.pendingMigrationCount ?? null,
pendingStructureMigrationCount: required.pendingStructureMigrationCount ?? null,
pendingIdempotentMigrationCount: required.pendingIdempotentMigrationCount ?? null,
migrationHistoryReady: required.migrationHistoryReady === true,
},
commands: asArray(nextAction?.commands),
forbiddenActions: [
'把治理护栏迁移当成 Prisma 结构修复替代品',
'迁移历史未 ready 时执行 apply-required --apply',
'用 dry-run 结果冒充真实落库',
],
}
}
function buildHistoricalCleanupStage(manifest) {
const checklistItem = findChecklistItem(manifest, 'historical-master-data-quality')
const nextAction = findNextActionStage(manifest, 'clean-historical-master-data-fields')
const cleanup = manifest?.cleanupTemplateSummary || {}
const blockerValues = getBlockerValues(checklistItem)
return {
id: 'historical-master-data-quality',
label: '历史主数据字段清洗',
status: checklistItem?.status || 'missing',
mustRunAfter: 'apply-governance-migrations',
requiresBusinessConfirmation: cleanup.requiresBusinessConfirmation !== false,
blockerValues: {
incompleteSkuItems:
blockerValues['foundation-dry-run:structuredSkuItems.incomplete'] ?? null,
productProfilesMissingProductCategory:
blockerValues['foundation-dry-run:nonIntegratedProductProfiles.missing_product_category'] ??
null,
productProfilesMissingDevelopmentKind:
blockerValues[
'foundation-dry-run:nonIntegratedProductProfiles.missing_development_kind'
] ?? null,
},
evidence: {
skuItemCount: cleanup.skuItemCount ?? null,
productProfileCount: cleanup.productProfileCount ?? null,
blankMissingFillFieldCount: cleanup.blankMissingFillFieldCount ?? null,
skuBlankFillFieldCounts: cleanup.skuBlankFillFieldCounts || {},
productProfileBlankFillFieldCounts:
cleanup.productProfileBlankFillFieldCounts || {},
},
requiredBusinessDecisions: [
'补齐 skuCode/specName/model 的真实业务值',
'补齐 productCategory/productDevelopmentKind 的真实业务值',
'确认不能使用 UNKNOWN/待补齐/默认值 等伪资料',
'确认是否存在允许暂不完整的业务原因;该原因只可审计,不可放行 hardReady',
],
commands: asArray(nextAction?.commands),
forbiddenActions: [
'自动编造历史 SKU 编码、规格名称或型号',
'用占位值让巡检变绿',
'治理迁移未 fullyReady 时执行清洗 apply',
],
}
}
function buildPreSkuLifecycleStage(manifest) {
const checklistItem = findChecklistItem(manifest, 'pre-sku-formalization-lifecycle')
const nextAction = findNextActionStage(manifest, 'resolve-pre-sku-formalization-lifecycle')
const preSku = manifest?.preSkuFormalizationRepairPlanSummary || {}
return {
id: 'pre-sku-formalization-lifecycle',
label: 'PreSku 正式化生命周期一致性',
status: checklistItem?.status || 'missing',
requiresBusinessDecision: true,
candidateCount: preSku.candidateCount ?? null,
reasonCounts: preSku.reasonCounts || {},
allowedDecisions: [
'revert-pre-sku-status',
'link-existing-product-sku',
'archive-invalid-pre-sku',
],
evidence: {
validationValid: checklistItem?.evidence?.validationValid === true,
staleCandidateCount: checklistItem?.evidence?.staleCandidateCount ?? null,
candidateEvidenceFingerprintCount:
preSku.candidateEvidenceFingerprintCount ?? null,
requiredCurrentEvidenceKeyCount: preSku.requiredCurrentEvidenceKeyCount ?? null,
globalGuardCount: preSku.globalGuardCount ?? null,
},
commands: asArray(nextAction?.commands),
forbiddenActions: [
'用通用主数据清洗脚本处理 PreSku 生命周期错位',
'跳过 currentEvidence/evidenceFingerprint 校验',
'没有业务确认就决定回退、补链或归档',
],
}
}
function buildFinalVerificationStage() {
return {
id: 'final-verification',
label: '最终 hardReady 验证',
status: 'required-after-all-stages',
commands: [
'npm run db:verify-master-data:audit',
'npm run db:foundation:audit:full',
'npm run db:foundation:completion-checklist',
'npm run type-check',
'npm test -- --runInBand tests/scripts --silent',
],
successCriteria: [
'completion-checklist 8/8 passed',
'hardReady = true',
'complete = true',
'blockedCount = 0',
'missingCount = 0',
],
}
}
function buildExecutionChecklist() {
return [
{
id: 'refresh-evidence-before-apply',
label: '授权前刷新证据',
writeDatabase: false,
prerequisites: [
'确认 DATABASE_URL 指向目标库',
'生成新鲜 PostgreSQL custom dump 并通过 pg_restore --list 校验',
'重新运行 preflight',
'重新运行结构修复克隆库演练',
'重新生成授权结构修复计划',
'重新运行 apply-structure dry-run final check',
],
commands: [
'npm run db:backup:postgres -- --use-docker',
'npm run db:preflight-database-foundation-change:audit',
'npm run db:foundation:rehearse-structure-repair -- --backup <fresh-dump> --output <fresh-structure-rehearsal.json> --repair-sql-output <fresh-structure-repair.sql>',
'npm run db:foundation:apply-structure-repair:audit',
'npm run db:foundation:apply-structure-repair -- --phase=apply-structure --confirm=apply-prisma-structure-repair --expected-sha256 <repair-sql-sha256> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256> --preflight <fresh-preflight.json> --structure-rehearsal <fresh-structure-rehearsal.json> --repair-sql <fresh-structure-repair.sql>',
],
successCriteria: [
'备份 SHA、SQL SHA、数据库身份三者和授权计划一致',
'结构修复 SQL 无 DROP/TRUNCATE 类语句',
'dry-run final check blockers 为空',
],
},
{
id: 'apply-structure',
label: '真实执行结构修复 SQL',
writeDatabase: true,
requiresExplicitUserAuthorization: true,
prerequisites: [
'refresh-evidence-before-apply 已通过',
'用户明确授权真实写库',
],
commands: [
'npm run db:foundation:apply-structure-repair -- --phase=apply-structure --apply --confirm=apply-prisma-structure-repair --expected-sha256 <repair-sql-sha256> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256> --preflight <fresh-preflight.json> --structure-rehearsal <fresh-structure-rehearsal.json> --repair-sql <fresh-structure-repair.sql>',
],
successCriteria: [
'applied.ok = true',
'verificationAfterApply.structureAligned = true',
'真实库到 schema.prisma diff 为空',
'migrations 重放结果到真实库 diff 为空',
],
forbiddenNextStepsUntilPassed: [
'resolve-baseline',
'apply-required',
'历史清洗',
],
},
{
id: 'post-repair-preflight',
label: '结构修复后重新预检',
writeDatabase: false,
prerequisites: [
'apply-structure 已成功',
],
commands: [
'npm run db:preflight-database-foundation-change:audit',
],
successCriteria: [
'当前库结构 diff 为空',
'migrationDiff 为空',
'post-repair preflight 可作为 resolve-baseline 输入',
],
},
{
id: 'resolve-baseline',
label: '逐项补齐 Prisma 迁移历史',
writeDatabase: true,
requiresExplicitUserAuthorization: true,
prerequisites: [
'post-repair-preflight 已证明结构 diff 归零',
'结构修复演练中的 migration manifest 和当前迁移目录一致',
],
commands: [
'npm run db:foundation:apply-structure-repair -- --phase=resolve-baseline --apply --confirm=apply-prisma-structure-repair --expected-sha256 <repair-sql-sha256> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256> --preflight <post-repair-preflight.json> --structure-rehearsal <fresh-structure-rehearsal.json> --repair-sql <fresh-structure-repair.sql>',
],
successCriteria: [
'baselineResolve.ok = true',
'Prisma migrate status 为 up to date',
'_prisma_migrations 历史存在且无失败/回滚记录',
],
forbiddenNextStepsUntilPassed: [
'apply-required',
'历史清洗',
],
},
{
id: 'apply-required-governance-migrations',
label: '应用治理迁移、外部视图和数据库护栏',
writeDatabase: true,
requiresExplicitUserAuthorization: true,
prerequisites: [
'resolve-baseline 已成功',
'只读迁移状态不再有 Prisma 历史阻断',
],
commands: [
'npm run db:foundation:apply-required:audit',
'npm run db:foundation:apply-required -- --phase=bootstrap-repair --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
'npm run db:foundation:apply-required -- --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
],
successCriteria: [
'bootstrap-repair 目标迁移 ready = true',
'migrationApplicationStatus.fullyReady = true',
'pendingStructureMigrationCount = 0',
'pendingIdempotentMigrationCount = 0',
],
},
{
id: 'clean-historical-quality-debt',
label: '业务确认后清洗历史主数据质量债',
writeDatabase: true,
requiresBusinessConfirmation: true,
requiresExplicitUserAuthorization: true,
prerequisites: [
'apply-required-governance-migrations 已达到 fullyReady',
'业务已确认真实 skuCode/specName/model/productCategory/productDevelopmentKind',
'PreSku 生命周期修复决策已确认',
],
commands: [
'npm run db:cleanup-master-data:validate -- --plan <confirmed-cleanup-plan.json>',
'npm run db:cleanup-master-data -- --plan <confirmed-cleanup-plan.json>',
'npm run db:cleanup-master-data -- --apply --plan <confirmed-cleanup-plan.json> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
'npm run db:foundation:apply-pre-sku-formalization-repair -- --apply --confirm=I_UNDERSTAND_THIS_UPDATES_PRE_SKU_LIFECYCLE --plan <confirmed-pre-sku-lifecycle-plan.json> --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
],
successCriteria: [
'structuredSkuItems.incomplete = 0',
'nonIntegratedProductProfiles.missing_product_category = 0',
'nonIntegratedProductProfiles.missing_development_kind = 0',
'preSkuFormalizationLifecycleMismatchCount = 0',
],
},
{
id: 'final-hard-ready-audit',
label: '最终 hardReady 审计',
writeDatabase: false,
prerequisites: [
'所有写库阶段已完成',
'历史质量债和 PreSku 生命周期阻断已清零',
],
commands: [
'npm run db:verify-master-data:audit',
'npm run db:foundation:audit:full',
'npm run db:foundation:completion-checklist',
],
successCriteria: [
'hardReady = true',
'completion-checklist 8/8 passed',
'blockedCount = 0',
'missingCount = 0',
],
},
]
}
function buildDecisionPackageReport(manifest, manifestFile = '', sidecars = {}) {
const checklist = manifest?.foundationCompletionChecklist
if (!checklist || typeof checklist !== 'object') {
throw new Error('manifest 中缺少 foundationCompletionChecklist,请先运行完整地基审计。')
}
const stages = [
buildStructureStage(manifest, sidecars),
buildGovernanceMigrationStage(manifest),
buildHistoricalCleanupStage(manifest),
buildPreSkuLifecycleStage(manifest),
buildFinalVerificationStage(),
]
const canClaimFoundationHardReady =
manifest.hardReady === true && checklist.complete === true
const blockedStages = canClaimFoundationHardReady
? []
: stages.filter((stage) => stage.status === 'blocked')
return {
mode: 'master-data-foundation-decision-package',
sourceManifest: manifestFile,
checkedAt: manifest.checkedAt || '',
hardReady: manifest.hardReady === true,
complete: checklist.complete === true,
currentState: {
itemCount: Number(checklist.itemCount ?? 0),
passedCount: Number(checklist.passedCount ?? 0),
blockedCount: Number(checklist.blockedCount ?? 0),
missingCount: Number(checklist.missingCount ?? 0),
},
decisionPackageReady: true,
canClaimFoundationHardReady,
nextGate: blockedStages[0]?.id || 'final-verification',
stages,
executionChecklist: buildExecutionChecklist(),
globalForbiddenActions: [
'把局部绿色命令当成完整地基完成',
'把 JSON/快照重新作为主数据真相源',
'绕过服务层、迁移清单或授权脚本直接写核心表',
'把含敏感样本的 .master-data-audits 文件提交 Git',
],
}
}
function writeJsonReport(filePath, payload) {
if (!filePath) return ''
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return filePath
}
function run(options = parseArgs()) {
const manifest = readJsonFile(options.manifestFile)
const manifestDir = path.dirname(options.manifestFile)
const sidecars = {
authorizedStructureRepairApplyPlan: readOptionalJsonFile(
path.join(manifestDir, 'authorized-structure-repair-apply-plan.json'),
),
}
const report = buildDecisionPackageReport(
manifest,
formatDisplayPath(options.manifestFile),
sidecars,
)
writeJsonReport(options.outputFile, report)
return report
}
function main() {
const report = run()
console.log(JSON.stringify(report, null, 2))
}
if (require.main === module) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
module.exports = {
DEFAULT_MANIFEST,
DEFAULT_OUTPUT,
buildDecisionPackageReport,
formatDisplayPath,
parseArgs,
readJsonFile,
readOptionalJsonFile,
run,
summarizeAuthorizedStructurePlan,
writeJsonReport,
}

View File

@ -0,0 +1,114 @@
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const LOCAL_ONLY_CONFIRMATION = 'LOCAL_ONLY'
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
const PRODUCTION_DATABASE_NAME_PATTERN = /(^|[^a-z0-9])(?:prod|production)(?:$|[^a-z0-9])/i
function readEnvFileValue(key) {
const envPath = path.join(process.cwd(), '.env')
if (!fs.existsSync(envPath)) return ''
const content = fs.readFileSync(envPath, 'utf8')
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex <= 0) continue
if (trimmed.slice(0, separatorIndex).trim() !== key) continue
let value = trimmed.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return value
}
return ''
}
function getDatabaseUrl() {
return process.env.DATABASE_URL || readEnvFileValue('DATABASE_URL')
}
function assertLocalOnlyDbPush(databaseUrl, env = process.env) {
if (env.NODE_ENV === 'production' || env.VERCEL_ENV === 'production') {
throw new Error('拒绝执行 prisma db push:当前环境标记为 production。生产/准生产必须使用 prisma migrate deploy。')
}
if (!databaseUrl) {
throw new Error('拒绝执行 prisma db push:DATABASE_URL 缺失,无法确认目标库是否为本地开发库。')
}
let parsed
try {
parsed = new URL(databaseUrl)
} catch {
throw new Error('拒绝执行 prisma db push:DATABASE_URL 格式无效,无法确认目标库是否为本地开发库。')
}
const databaseName = decodeURIComponent(parsed.pathname.replace(/^\//, ''))
const isLocalHost = LOCAL_HOSTS.has(parsed.hostname)
const looksProductionDatabase = PRODUCTION_DATABASE_NAME_PATTERN.test(databaseName)
const explicitLocalConfirmation = env.ALLOW_PRISMA_DB_PUSH === LOCAL_ONLY_CONFIRMATION
if (!isLocalHost && !explicitLocalConfirmation) {
throw new Error(
`拒绝执行 prisma db push:数据库主机是 ${parsed.hostname},不是本机地址。` +
`如确认这是本地开发容器,请临时设置 ALLOW_PRISMA_DB_PUSH=${LOCAL_ONLY_CONFIRMATION}。`,
)
}
if (looksProductionDatabase && !explicitLocalConfirmation) {
throw new Error(
`拒绝执行 prisma db push:数据库名 ${databaseName} 看起来像生产库。` +
`生产/准生产必须使用 prisma migrate deploy。`,
)
}
return {
databaseName,
host: parsed.hostname,
localHost: isLocalHost,
}
}
function main() {
const args = process.argv.slice(2)
const checkOnly = args.includes('--check-only')
const prismaArgs = args.filter((arg) => arg !== '--check-only')
const target = assertLocalOnlyDbPush(getDatabaseUrl())
console.log(
`prisma db push 本地保护通过:${target.host}/${target.databaseName || '(default)'}`,
)
if (checkOnly) return
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'
const result = spawnSync(npx, ['prisma', 'db', 'push', ...prismaArgs], {
stdio: 'inherit',
shell: false,
})
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
}
if (require.main === module) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
module.exports = {
assertLocalOnlyDbPush,
}

View File

@ -0,0 +1,360 @@
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const { describeDatabaseUrl } = require('./diagnose-prisma-migration-baseline')
const ROOT_DIR = path.resolve(__dirname, '..')
const PRISMA_CLI = path.join(ROOT_DIR, 'node_modules/prisma/build/index.js')
function parseArgs(argv = process.argv.slice(2), env = process.env) {
const options = {
backupPath: env.DATABASE_BACKUP_FILE || env.DB_BACKUP_FILE || '',
outputPath: '',
dockerImage: env.POSTGRES_DOCKER_IMAGE || 'postgres:16',
dockerPort: env.DB_FOUNDATION_REHEARSAL_PORT || '65447',
keepContainer: env.DB_FOUNDATION_REHEARSAL_KEEP_CONTAINER === '1',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--backup') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --backup <dump-file>。')
}
options.backupPath = value
index += 1
continue
}
if (arg.startsWith('--backup=')) {
options.backupPath = arg.slice('--backup='.length)
if (!options.backupPath) {
throw new Error('缺少 --backup <dump-file>。')
}
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <json-file>。')
}
options.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputPath = arg.slice('--output='.length)
if (!options.outputPath) {
throw new Error('缺少 --output <json-file>。')
}
continue
}
if (arg === '--docker-port') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --docker-port <port>。')
}
options.dockerPort = value
index += 1
continue
}
if (arg.startsWith('--docker-port=')) {
options.dockerPort = arg.slice('--docker-port='.length)
if (!options.dockerPort) {
throw new Error('缺少 --docker-port <port>。')
}
continue
}
if (arg === '--keep-container') {
options.keepContainer = true
continue
}
throw new Error(`未知参数:${arg}`)
}
if (!options.backupPath) {
throw new Error('必须通过 --backup 或 DATABASE_BACKUP_FILE / DB_BACKUP_FILE 提供 PostgreSQL custom dump。')
}
if (!/^\d+$/.test(String(options.dockerPort))) {
throw new Error(`Docker rehearsal 端口必须是数字:${options.dockerPort}`)
}
return {
backupPath: path.resolve(ROOT_DIR, options.backupPath),
outputPath: options.outputPath,
dockerImage: options.dockerImage,
dockerPort: String(options.dockerPort),
keepContainer: options.keepContainer,
}
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || ROOT_DIR,
encoding: 'utf8',
env: { ...process.env, ...(options.env || {}) },
windowsHide: true,
})
return {
command: [command, ...args].join(' '),
status: result.status,
ok: result.status === 0,
stdout: String(result.stdout ?? '').trim(),
stderr: String(result.stderr ?? '').trim(),
error: result.error ? String(result.error.message ?? result.error) : '',
}
}
function assertOk(result, label) {
if (result.status !== 0) {
const detail = result.stderr || result.stdout || result.error || '无输出'
throw new Error(`${label} 失败:${detail}`)
}
}
function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
if (!outputPath) return ''
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return absolutePath
}
function buildRehearsalReport({
options,
containerName,
databaseUrl,
restore,
migrateDeploy,
cleanupStatus,
checkedAt = new Date().toISOString(),
}) {
const combinedOutput = `${migrateDeploy.stdout}\n${migrateDeploy.stderr}`
const p3005Detected = /P3005|database schema is not empty/i.test(combinedOutput)
const blockers = []
if (!restore.ok) {
blockers.push({
key: 'restore',
message: '备份恢复到克隆库失败,不能进行迁移演练。',
})
}
if (!migrateDeploy.ok) {
blockers.push({
key: p3005Detected ? 'migrateDeploy.P3005' : 'migrateDeploy.failed',
message: p3005Detected
? '克隆库复现 Prisma P3005:目标库非空且缺少迁移历史,不能直接 migrate deploy。'
: '克隆库 migrate deploy 失败,需要查看 stdout/stderr。',
})
}
return {
mode: 'database-foundation-migration-rehearsal',
status: blockers.length === 0 ? 'deploy-rehearsal-passed' : 'deploy-rehearsal-blocked',
checkedAt,
safeForProduction: false,
database: describeDatabaseUrl(databaseUrl),
docker: {
image: options.dockerImage,
port: options.dockerPort,
containerName,
cleanupStatus,
keptContainer: options.keepContainer,
},
backupEvidence: {
path: options.backupPath,
exists: fs.existsSync(options.backupPath),
sizeBytes: fs.existsSync(options.backupPath) ? fs.statSync(options.backupPath).size : 0,
},
restore: {
ok: restore.ok,
status: restore.status,
stdoutPreview: restore.stdout.slice(0, 1000),
stderrPreview: restore.stderr.slice(0, 1000),
error: restore.error,
},
migrateDeploy: {
ok: migrateDeploy.ok,
status: migrateDeploy.status,
p3005Detected,
stdoutPreview: migrateDeploy.stdout.slice(0, 2000),
stderrPreview: migrateDeploy.stderr.slice(0, 2000),
error: migrateDeploy.error,
},
blockers,
recommendedNextSteps: p3005Detected
? [
'不要对当前目标库直接执行 prisma migrate deploy。',
'先完成备份核验和 shadow diff 证据。',
'形成 baseline/结构修复方案后,再决定逐项 resolve --applied 或在维护窗口补结构。',
'任何真实库写入前都要重新跑 db:preflight-database-foundation-change 和 readiness。',
]
: [
'如果克隆库演练通过,仍需在真实目标库迁移前重新跑 preflight、备份核验和 strict readiness。',
],
}
}
function waitForPostgres(containerName, env = process.env) {
for (let index = 0; index < 40; index += 1) {
const ready = runCommand('docker', ['exec', containerName, 'pg_isready', '-U', 'postgres'], { env })
if (ready.status === 0) return true
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500)
}
return false
}
function runRehearsal(options = parseArgs(), env = process.env) {
if (!fs.existsSync(options.backupPath) || fs.statSync(options.backupPath).size === 0) {
throw new Error(`备份文件不存在或为空:${options.backupPath}`)
}
assertOk(runCommand('docker', ['--version'], { env }), '检查 Docker')
assertOk(runCommand('docker', ['image', 'inspect', options.dockerImage], { env }), `检查 Docker 镜像 ${options.dockerImage}`)
const password = `foundation_rehearsal_${process.pid}`
const databaseName = 'furniture_3d_rehearsal'
const containerName = `f3d-foundation-rehearsal-${Date.now()}-${process.pid}`
const databaseUrl = `postgresql://postgres:${password}@localhost:${options.dockerPort}/${databaseName}?schema=public`
let cleanupStatus = 'not-started'
let restore = {
ok: false,
status: null,
stdout: '',
stderr: '',
error: '',
}
let migrateDeploy = {
ok: false,
status: null,
stdout: '',
stderr: '',
error: '',
}
let fatalError = null
try {
assertOk(
runCommand('docker', [
'run',
'-d',
'--name',
containerName,
'-e',
`POSTGRES_PASSWORD=${password}`,
'-p',
`${options.dockerPort}:5432`,
options.dockerImage,
], { env }),
'启动 Docker 克隆 PostgreSQL',
)
if (!waitForPostgres(containerName, env)) {
throw new Error('Docker 克隆 PostgreSQL 启动超时。')
}
assertOk(
runCommand('docker', [
'exec',
'-e',
`PGPASSWORD=${password}`,
containerName,
'psql',
'-U',
'postgres',
'-d',
'postgres',
'-c',
`CREATE DATABASE ${databaseName};`,
], { env }),
'创建克隆数据库',
)
assertOk(
runCommand('docker', [
'cp',
options.backupPath,
`${containerName}:/tmp/foundation-backup.dump`,
], { env }),
'复制备份到 Docker 克隆库',
)
restore = runCommand('docker', [
'exec',
'-e',
`PGPASSWORD=${password}`,
containerName,
'pg_restore',
'--no-owner',
'--no-privileges',
'-U',
'postgres',
'-d',
databaseName,
'/tmp/foundation-backup.dump',
], { env })
if (restore.ok) {
migrateDeploy = runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'deploy',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
})
}
} catch (error) {
fatalError = error
} finally {
if (options.keepContainer) {
cleanupStatus = 'kept'
} else {
const cleanup = runCommand('docker', ['rm', '-f', containerName], { env })
cleanupStatus = cleanup.status === 0 ? 'removed' : 'remove-failed'
}
}
if (fatalError) {
throw fatalError
}
return buildRehearsalReport({
options,
containerName,
databaseUrl,
restore,
migrateDeploy,
cleanupStatus,
})
}
function main() {
const options = parseArgs()
const report = runRehearsal(options)
const outputFile = writeJsonReport(options.outputPath, report)
console.log(JSON.stringify({ ...report, outputFile }, null, 2))
if (report.status !== 'deploy-rehearsal-passed') {
process.exit(1)
}
}
if (require.main === module) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
module.exports = {
buildRehearsalReport,
parseArgs,
runCommand,
runRehearsal,
waitForPostgres,
writeJsonReport,
}

View File

@ -0,0 +1,802 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const crypto = require('node:crypto')
const { Pool } = require('pg')
const {
describeDatabaseUrl,
listMigrationDirectories,
} = require('./diagnose-prisma-migration-baseline')
const {
applyMissingMigrationFiles,
classifyAppliedMigrationPaths,
} = require('./master-data-required-migrations')
const { runCommand, waitForPostgres, writeJsonReport } = require('./rehearse-database-foundation-migration')
const { extractJson } = require('./verify-master-data-readiness')
const ROOT_DIR = path.resolve(__dirname, '..')
const PRISMA_CLI = path.join(ROOT_DIR, 'node_modules/prisma/build/index.js')
function parseArgs(argv = process.argv.slice(2), env = process.env) {
const options = {
backupPath: env.DATABASE_BACKUP_FILE || env.DB_BACKUP_FILE || '',
outputPath: '',
repairSqlOutputPath: env.DB_FOUNDATION_STRUCTURE_REPAIR_SQL_OUTPUT || '',
dockerImage: env.POSTGRES_DOCKER_IMAGE || 'postgres:16',
dockerPort: env.DB_FOUNDATION_STRUCTURE_REPAIR_PORT || '65448',
keepContainer: env.DB_FOUNDATION_STRUCTURE_REPAIR_KEEP_CONTAINER === '1',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--backup') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --backup <dump-file>。')
}
options.backupPath = value
index += 1
continue
}
if (arg.startsWith('--backup=')) {
options.backupPath = arg.slice('--backup='.length)
if (!options.backupPath) {
throw new Error('缺少 --backup <dump-file>。')
}
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <json-file>。')
}
options.outputPath = value
index += 1
continue
}
if (arg === '--repair-sql-output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --repair-sql-output <sql-file>。')
}
options.repairSqlOutputPath = value
index += 1
continue
}
if (arg.startsWith('--repair-sql-output=')) {
options.repairSqlOutputPath = arg.slice('--repair-sql-output='.length)
if (!options.repairSqlOutputPath) {
throw new Error('缺少 --repair-sql-output <sql-file>。')
}
continue
}
if (arg.startsWith('--output=')) {
options.outputPath = arg.slice('--output='.length)
if (!options.outputPath) {
throw new Error('缺少 --output <json-file>。')
}
continue
}
if (arg === '--docker-port') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --docker-port <port>。')
}
options.dockerPort = value
index += 1
continue
}
if (arg.startsWith('--docker-port=')) {
options.dockerPort = arg.slice('--docker-port='.length)
if (!options.dockerPort) {
throw new Error('缺少 --docker-port <port>。')
}
continue
}
if (arg === '--keep-container') {
options.keepContainer = true
continue
}
throw new Error(`未知参数:${arg}`)
}
if (!options.backupPath) {
throw new Error('必须通过 --backup 或 DATABASE_BACKUP_FILE / DB_BACKUP_FILE 提供 PostgreSQL custom dump。')
}
if (!/^\d+$/.test(String(options.dockerPort))) {
throw new Error(`Docker structure repair 端口必须是数字:${options.dockerPort}`)
}
const repairSqlOutputPath = options.repairSqlOutputPath ||
(options.outputPath ? path.join(path.dirname(options.outputPath), 'structure-repair.sql') : '')
return {
backupPath: path.resolve(ROOT_DIR, options.backupPath),
outputPath: options.outputPath,
repairSqlOutputPath,
dockerImage: options.dockerImage,
dockerPort: String(options.dockerPort),
keepContainer: options.keepContainer,
}
}
function assertOk(result, label) {
if (result.status !== 0) {
const detail = result.stderr || result.stdout || result.error || '无输出'
throw new Error(`${label} 失败:${detail}`)
}
}
function hasSqlDiff(stdout) {
return /\b(?:CREATE|ALTER|DROP)\s+(?:TYPE|TABLE|INDEX|CONSTRAINT|COLUMN|SCHEMA|VIEW|TRIGGER)\b/i.test(
String(stdout ?? ''),
)
}
function summarizeRepairSql(sql) {
const text = String(sql ?? '')
const patterns = {
createSchema: /\bCREATE\s+SCHEMA\b/gi,
createType: /\bCREATE\s+TYPE\b/gi,
createTable: /\bCREATE\s+TABLE\b/gi,
createIndex: /\bCREATE\s+(?:UNIQUE\s+)?INDEX\b/gi,
alterTable: /\bALTER\s+TABLE\b/gi,
addColumn: /\bADD\s+COLUMN\b/gi,
addConstraint: /\bADD\s+CONSTRAINT\b/gi,
dropTable: /\bDROP\s+TABLE\b/gi,
dropColumn: /\bDROP\s+COLUMN\b/gi,
dropType: /\bDROP\s+TYPE\b/gi,
dropSchema: /\bDROP\s+SCHEMA\b/gi,
}
const counts = Object.fromEntries(
Object.entries(patterns).map(([key, pattern]) => [key, (text.match(pattern) || []).length]),
)
const changedRelations = Array.from(new Set(
text
.split(/\r?\n/)
.map((line) => {
const match = line.match(/\b(?:CREATE|ALTER|DROP)\s+TABLE\s+(?:"public"\.)?"?([^"\s(]+)"?/i)
return match?.[1] || ''
})
.filter(Boolean),
)).slice(0, 80)
return {
hasSqlDiff: hasSqlDiff(text),
statementCounts: counts,
hasDestructiveStatements:
counts.dropTable > 0 || counts.dropColumn > 0 || counts.dropType > 0 || counts.dropSchema > 0,
changedRelations,
}
}
function writeRepairSqlArtifact(outputPath, sql, rootDir = ROOT_DIR) {
if (!outputPath || !String(sql || '').trim()) return null
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, sql, 'utf8')
const buffer = fs.readFileSync(absolutePath)
return {
path: absolutePath,
sizeBytes: buffer.length,
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
}
}
function buildMigrationManifest(rootDir = ROOT_DIR) {
return listMigrationDirectories(rootDir).map((migrationId) => {
const relativePath = path.join('prisma', 'migrations', migrationId, 'migration.sql')
const absolutePath = path.join(rootDir, relativePath)
if (!fs.existsSync(absolutePath)) {
return {
migrationId,
path: relativePath.replace(/\\/g, '/'),
exists: false,
sizeBytes: 0,
sha256: '',
}
}
const buffer = fs.readFileSync(absolutePath)
return {
migrationId,
path: relativePath.replace(/\\/g, '/'),
exists: true,
sizeBytes: buffer.length,
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
}
})
}
function buildBaselineResolvePlan(migrationIds, structureAligned) {
if (!structureAligned) {
return {
candidate: false,
migrationCount: 0,
migrationIds: [],
commandsPreview: [],
warning: '结构修复后仍未证明 schema 与 migrations 等价,不能生成 baseline resolve 清单。',
}
}
return {
candidate: true,
migrationCount: migrationIds.length,
migrationIds,
commandsPreview: migrationIds.slice(0, 12).map((migrationId) =>
`node node_modules/prisma/build/index.js migrate resolve --applied ${migrationId}`),
warning:
'该清单只适用于结构修复已真实执行且重新验证 migrationDiff 为空之后;真实 resolve 必须逐项执行并留痕,不能跳过授权。',
}
}
function summarizeResolveResults(results) {
const firstFailed = results.find((result) => !result.ok) || null
return {
attempted: results.length > 0,
attemptedCount: results.length,
appliedCount: results.filter((result) => result.ok).length,
failedCount: results.filter((result) => !result.ok).length,
ok: results.length > 0 && results.every((result) => result.ok),
firstFailed: firstFailed
? {
migrationId: firstFailed.migrationId,
status: firstFailed.status,
stdoutPreview: firstFailed.stdout.slice(0, 500),
stderrPreview: firstFailed.stderr.slice(0, 500),
error: firstFailed.error,
}
: null,
}
}
function summarizeReadinessAfterResolve(result) {
if (!result) return null
let data = null
let jsonError = ''
try {
data = extractJson(result.stdout)
} catch (error) {
jsonError = error instanceof Error ? error.message : String(error)
}
const blockerKeys = Array.isArray(data?.blockers)
? data.blockers.map((blocker) => blocker.key).filter(Boolean)
: []
return {
attempted: true,
ok: result.ok,
status: result.status,
jsonParsed: Boolean(data),
jsonError,
readinessStatus: data?.status || '',
hardReady: data?.hardReady === true,
blockerCount: data?.blockerCount ?? null,
blockerKeys,
stdoutPreview: result.stdout.slice(0, 1200),
stderrPreview: result.stderr.slice(0, 1000),
error: result.error,
}
}
async function applyRequiredMigrationsToClone(databaseUrl) {
const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 })
const client = await pool.connect()
try {
const appliedMigrationPaths = await applyMissingMigrationFiles(client)
const migrationReplay = classifyAppliedMigrationPaths(appliedMigrationPaths)
return {
attempted: true,
ok: true,
appliedMigrationCount: appliedMigrationPaths.length,
pendingStructureMigrationCount: migrationReplay.structureMigrations.length,
replayableIdempotentMigrationCount: migrationReplay.idempotentMigrations.length,
appliedMigrationPaths,
}
} catch (error) {
return {
attempted: true,
ok: false,
appliedMigrationCount: 0,
pendingStructureMigrationCount: null,
replayableIdempotentMigrationCount: null,
appliedMigrationPaths: [],
error: error instanceof Error ? error.message : String(error),
}
} finally {
client.release()
await pool.end()
}
}
function buildReport({
options,
containerName,
databaseUrl,
restore,
currentSchemaDiffBefore,
repairSqlSummary,
repairApply,
currentSchemaDiffAfter,
migrationDiffAfter,
repairSqlArtifact = null,
migrationManifest = [],
migrationIds = [],
baselineResolveResults = [],
prismaMigrateStatusAfterResolve = null,
readinessAfterResolve = null,
requiredMigrationApplyAfterResolve = null,
readinessAfterRequiredMigrations = null,
cleanupStatus,
checkedAt = new Date().toISOString(),
}) {
const blockers = []
if (!restore.ok) {
blockers.push({ key: 'restore', message: '备份恢复到克隆库失败。' })
}
if (currentSchemaDiffBefore.status !== 0 && currentSchemaDiffBefore.status !== 2) {
blockers.push({ key: 'currentSchemaDiffBefore.failed', message: '无法生成克隆库到 schema 的结构修复 diff。' })
}
if (repairSqlSummary.hasDestructiveStatements) {
blockers.push({
key: 'repairSql.destructive',
message: '结构修复 SQL 含 DROP 类语句,脚本拒绝自动应用到克隆库。',
})
}
if (repairApply && !repairApply.ok) {
blockers.push({ key: 'repairApply.failed', message: '结构修复 SQL 在克隆库执行失败。' })
}
if (currentSchemaDiffAfter && currentSchemaDiffAfter.status !== 0) {
blockers.push({ key: 'currentSchemaDiffAfter.notEmpty', message: '结构修复后克隆库仍未对齐 schema.prisma。' })
}
if (migrationDiffAfter && migrationDiffAfter.status !== 0) {
blockers.push({ key: 'migrationDiffAfter.notEmpty', message: '结构修复后克隆库仍未对齐 migrations 重放结果。' })
}
const structureAligned = currentSchemaDiffAfter?.status === 0 && migrationDiffAfter?.status === 0
const baselineResolveRehearsal = summarizeResolveResults(baselineResolveResults)
if (structureAligned && !baselineResolveRehearsal.ok) {
blockers.push({
key: 'baselineResolveRehearsal.failed',
message: '结构修复后逐项 baseline resolve 演练失败,不能把该路径用于真实库。',
})
}
if (
structureAligned &&
baselineResolveRehearsal.ok &&
prismaMigrateStatusAfterResolve &&
prismaMigrateStatusAfterResolve.status !== 0
) {
blockers.push({
key: 'prismaMigrateStatusAfterResolve.failed',
message: 'baseline resolve 演练后 Prisma migrate status 仍未通过。',
})
}
const readinessAfterResolveSummary = summarizeReadinessAfterResolve(readinessAfterResolve)
if (
structureAligned &&
baselineResolveRehearsal.ok &&
prismaMigrateStatusAfterResolve?.status === 0 &&
!readinessAfterResolveSummary?.jsonParsed
) {
blockers.push({
key: 'readinessAfterResolve.unreadable',
message: 'baseline resolve 后 readiness 演练没有输出可解析 JSON,不能证明剩余阻断项性质。',
})
}
if (
structureAligned &&
baselineResolveRehearsal.ok &&
prismaMigrateStatusAfterResolve?.status === 0 &&
requiredMigrationApplyAfterResolve &&
!requiredMigrationApplyAfterResolve.ok
) {
blockers.push({
key: 'requiredMigrationApplyAfterResolve.failed',
message: 'baseline resolve 后治理迁移清单在克隆库真实应用失败,不能把该路径用于真实库。',
})
}
const readinessAfterRequiredMigrationsSummary =
summarizeReadinessAfterResolve(readinessAfterRequiredMigrations)
if (
structureAligned &&
baselineResolveRehearsal.ok &&
prismaMigrateStatusAfterResolve?.status === 0 &&
requiredMigrationApplyAfterResolve?.ok &&
!readinessAfterRequiredMigrationsSummary?.jsonParsed
) {
blockers.push({
key: 'readinessAfterRequiredMigrations.unreadable',
message: '治理迁移真实应用后 readiness 演练没有输出可解析 JSON,不能证明剩余阻断项性质。',
})
}
return {
mode: 'prisma-structure-repair-rehearsal',
status: blockers.length === 0 ? 'structure-repair-rehearsal-passed' : 'structure-repair-rehearsal-blocked',
checkedAt,
safeForProductionWrite: false,
database: describeDatabaseUrl(databaseUrl),
docker: {
image: options.dockerImage,
port: options.dockerPort,
containerName,
cleanupStatus,
keptContainer: options.keepContainer,
},
backupEvidence: {
path: options.backupPath,
exists: fs.existsSync(options.backupPath),
sizeBytes: fs.existsSync(options.backupPath) ? fs.statSync(options.backupPath).size : 0,
},
restore: {
ok: restore.ok,
status: restore.status,
stderrPreview: restore.stderr.slice(0, 1000),
error: restore.error,
},
currentSchemaDiffBefore: {
ok: currentSchemaDiffBefore.ok,
status: currentSchemaDiffBefore.status,
hasSqlDiff: hasSqlDiff(currentSchemaDiffBefore.stdout),
stdoutPreview: currentSchemaDiffBefore.stdout.slice(0, 2000),
stderrPreview: currentSchemaDiffBefore.stderr.slice(0, 1000),
error: currentSchemaDiffBefore.error,
},
repairSqlSummary,
repairSqlArtifact,
migrationManifest,
repairApply: repairApply
? {
ok: repairApply.ok,
status: repairApply.status,
stdoutPreview: repairApply.stdout.slice(0, 1000),
stderrPreview: repairApply.stderr.slice(0, 1000),
error: repairApply.error,
}
: null,
currentSchemaDiffAfter: currentSchemaDiffAfter
? {
ok: currentSchemaDiffAfter.ok,
status: currentSchemaDiffAfter.status,
hasSqlDiff: hasSqlDiff(currentSchemaDiffAfter.stdout),
stdoutPreview: currentSchemaDiffAfter.stdout.slice(0, 1000),
stderrPreview: currentSchemaDiffAfter.stderr.slice(0, 1000),
error: currentSchemaDiffAfter.error,
}
: null,
migrationDiffAfter: migrationDiffAfter
? {
ok: migrationDiffAfter.ok,
status: migrationDiffAfter.status,
hasSqlDiff: hasSqlDiff(migrationDiffAfter.stdout),
stdoutPreview: migrationDiffAfter.stdout.slice(0, 1000),
stderrPreview: migrationDiffAfter.stderr.slice(0, 1000),
error: migrationDiffAfter.error,
}
: null,
baselineResolvePlan: buildBaselineResolvePlan(migrationIds, structureAligned),
baselineResolveRehearsal,
prismaMigrateStatusAfterResolve: prismaMigrateStatusAfterResolve
? {
ok: prismaMigrateStatusAfterResolve.ok,
status: prismaMigrateStatusAfterResolve.status,
stdoutPreview: prismaMigrateStatusAfterResolve.stdout.slice(0, 1200),
stderrPreview: prismaMigrateStatusAfterResolve.stderr.slice(0, 1000),
error: prismaMigrateStatusAfterResolve.error,
}
: null,
readinessAfterResolve: readinessAfterResolveSummary,
requiredMigrationApplyAfterResolve,
readinessAfterRequiredMigrations: readinessAfterRequiredMigrationsSummary,
blockers,
recommendedNextSteps: blockers.length === 0
? [
'把本报告作为结构修复 SQL 可演练证据,但不要直接写真实库。',
'真实库执行前重新生成备份、preflight、baseline-repair-plan,并取得明确授权。',
'真实结构修复完成且 migrationDiff 为空后,先补齐治理 raw SQL 护栏,再逐项 baseline resolve 已存在迁移历史。',
readinessAfterRequiredMigrationsSummary?.hardReady === false
? '治理护栏应用后仍存在质量债,按 readinessAfterRequiredMigrations.blockerKeys 清洗后再进入 hard-ready。'
: '治理护栏应用后 readiness 已进入 hard-ready 演练状态,可作为真实执行前复核目标。',
]
: [
'先处理 blockers,不要把当前结构修复 SQL 用到真实库。',
'如包含 DROP 类语句,必须人工拆分和复核,不能自动套用。',
],
}
}
async function runStructureRepairRehearsal(options = parseArgs(), env = process.env) {
if (!fs.existsSync(options.backupPath) || fs.statSync(options.backupPath).size === 0) {
throw new Error(`备份文件不存在或为空:${options.backupPath}`)
}
assertOk(runCommand('docker', ['--version'], { env }), '检查 Docker')
assertOk(runCommand('docker', ['image', 'inspect', options.dockerImage], { env }), `检查 Docker 镜像 ${options.dockerImage}`)
const password = `foundation_repair_${process.pid}`
const databaseName = 'furniture_3d_structure_repair'
const shadowDatabaseName = 'furniture_3d_structure_repair_shadow'
const containerName = `f3d-foundation-structure-repair-${Date.now()}-${process.pid}`
const databaseUrl = `postgresql://postgres:${password}@localhost:${options.dockerPort}/${databaseName}?schema=public`
const shadowDatabaseUrl = `postgresql://postgres:${password}@localhost:${options.dockerPort}/${shadowDatabaseName}?schema=public`
let cleanupStatus = 'not-started'
let fatalError = null
let restore = { ok: false, status: null, stdout: '', stderr: '', error: '' }
let currentSchemaDiffBefore = { ok: false, status: null, stdout: '', stderr: '', error: '' }
let repairSqlSummary = summarizeRepairSql('')
let repairApply = null
let currentSchemaDiffAfter = null
let migrationDiffAfter = null
let repairSqlArtifact = null
let baselineResolveResults = []
let prismaMigrateStatusAfterResolve = null
let readinessAfterResolve = null
let requiredMigrationApplyAfterResolve = null
let readinessAfterRequiredMigrations = null
const migrationIds = listMigrationDirectories()
const migrationManifest = buildMigrationManifest()
try {
assertOk(
runCommand('docker', [
'run',
'-d',
'--name',
containerName,
'-e',
`POSTGRES_PASSWORD=${password}`,
'-p',
`${options.dockerPort}:5432`,
options.dockerImage,
], { env }),
'启动 Docker 结构修复 PostgreSQL',
)
if (!waitForPostgres(containerName, env)) {
throw new Error('Docker 结构修复 PostgreSQL 启动超时。')
}
for (const name of [databaseName, shadowDatabaseName]) {
assertOk(
runCommand('docker', [
'exec',
'-e',
`PGPASSWORD=${password}`,
containerName,
'psql',
'-U',
'postgres',
'-d',
'postgres',
'-c',
`CREATE DATABASE ${name};`,
], { env }),
`创建克隆数据库 ${name}`,
)
}
assertOk(
runCommand('docker', ['cp', options.backupPath, `${containerName}:/tmp/foundation-backup.dump`], { env }),
'复制备份到 Docker 克隆库',
)
restore = runCommand('docker', [
'exec',
'-e',
`PGPASSWORD=${password}`,
containerName,
'pg_restore',
'--no-owner',
'--no-privileges',
'-U',
'postgres',
'-d',
databaseName,
'/tmp/foundation-backup.dump',
], { env })
if (restore.ok) {
currentSchemaDiffBefore = runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'diff',
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'--script',
'--exit-code',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
})
repairSqlSummary = summarizeRepairSql(currentSchemaDiffBefore.stdout)
if (repairSqlSummary.hasSqlDiff) {
repairSqlArtifact = writeRepairSqlArtifact(
options.repairSqlOutputPath,
currentSchemaDiffBefore.stdout,
)
}
if (currentSchemaDiffBefore.status === 2 && !repairSqlSummary.hasDestructiveStatements) {
const tempSql = path.join(os.tmpdir(), `f3d-structure-repair-${Date.now()}-${process.pid}.sql`)
fs.writeFileSync(tempSql, currentSchemaDiffBefore.stdout, 'utf8')
try {
assertOk(
runCommand('docker', ['cp', tempSql, `${containerName}:/tmp/structure-repair.sql`], { env }),
'复制结构修复 SQL 到 Docker 克隆库',
)
repairApply = runCommand('docker', [
'exec',
'-e',
`PGPASSWORD=${password}`,
containerName,
'psql',
'-v',
'ON_ERROR_STOP=1',
'-U',
'postgres',
'-d',
databaseName,
'-f',
'/tmp/structure-repair.sql',
], { env })
} finally {
fs.rmSync(tempSql, { force: true })
}
}
if (currentSchemaDiffBefore.status === 0 || repairApply?.ok) {
currentSchemaDiffAfter = runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'diff',
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'--script',
'--exit-code',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
})
migrationDiffAfter = runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'diff',
'--from-migrations',
'prisma/migrations',
'--to-config-datasource',
'--script',
'--exit-code',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
SHADOW_DATABASE_URL: shadowDatabaseUrl,
},
})
if (currentSchemaDiffAfter.status === 0 && migrationDiffAfter.status === 0) {
baselineResolveResults = migrationIds.map((migrationId) => ({
migrationId,
...runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'resolve',
'--applied',
migrationId,
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
}),
}))
if (baselineResolveResults.every((result) => result.ok)) {
prismaMigrateStatusAfterResolve = runCommand(process.execPath, [
PRISMA_CLI,
'migrate',
'status',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
},
})
if (prismaMigrateStatusAfterResolve.status === 0) {
readinessAfterResolve = runCommand(process.execPath, [
'scripts/verify-master-data-readiness.js',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
SHADOW_DATABASE_URL: shadowDatabaseUrl,
},
})
requiredMigrationApplyAfterResolve = await applyRequiredMigrationsToClone(databaseUrl)
if (requiredMigrationApplyAfterResolve.ok) {
readinessAfterRequiredMigrations = runCommand(process.execPath, [
'scripts/verify-master-data-readiness.js',
], {
env: {
...env,
DATABASE_URL: databaseUrl,
SHADOW_DATABASE_URL: shadowDatabaseUrl,
},
})
}
}
}
}
}
}
} catch (error) {
fatalError = error
} finally {
if (options.keepContainer) {
cleanupStatus = 'kept'
} else {
const cleanup = runCommand('docker', ['rm', '-f', containerName], { env })
cleanupStatus = cleanup.status === 0 ? 'removed' : 'remove-failed'
}
}
if (fatalError) {
throw fatalError
}
return buildReport({
options,
containerName,
databaseUrl,
restore,
currentSchemaDiffBefore,
repairSqlSummary,
repairApply,
currentSchemaDiffAfter,
migrationDiffAfter,
repairSqlArtifact,
migrationManifest,
migrationIds,
baselineResolveResults,
prismaMigrateStatusAfterResolve,
readinessAfterResolve,
requiredMigrationApplyAfterResolve,
readinessAfterRequiredMigrations,
cleanupStatus,
})
}
async function main() {
const options = parseArgs()
const report = await runStructureRepairRehearsal(options)
const outputFile = writeJsonReport(options.outputPath, report)
console.log(JSON.stringify({ ...report, outputFile }, null, 2))
if (report.status !== 'structure-repair-rehearsal-passed') {
process.exit(1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
buildReport,
buildBaselineResolvePlan,
buildMigrationManifest,
hasSqlDiff,
parseArgs,
runStructureRepairRehearsal,
summarizeResolveResults,
summarizeReadinessAfterResolve,
summarizeRepairSql,
writeRepairSqlArtifact,
applyRequiredMigrationsToClone,
}

View File

@ -0,0 +1,240 @@
const path = require('node:path')
const fs = require('node:fs')
const crypto = require('node:crypto')
const { spawnSync } = require('node:child_process')
const { buildPreflightReport } = require('./preflight-database-foundation-change')
const { loadDatabaseUrl, getDatabaseIdentity } = require('./diagnose-prisma-migration-baseline')
const ROOT_DIR = path.resolve(__dirname, '..')
const PRISMA_CLI = path.join(ROOT_DIR, 'node_modules/prisma/build/index.js')
function parseArgs(argv = process.argv.slice(2)) {
const options = {
apply: false,
confirm: '',
expectedDatabaseIdentity: '',
expectedBackupSha256: '',
outputPath: '',
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--apply') {
options.apply = true
continue
}
if (arg.startsWith('--confirm=')) {
options.confirm = arg.slice('--confirm='.length)
continue
}
if (arg === '--expected-database-identity') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --expected-database-identity <identity>。')
}
options.expectedDatabaseIdentity = value
index += 1
continue
}
if (arg.startsWith('--expected-database-identity=')) {
options.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length)
continue
}
if (arg === '--expected-backup-sha256') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --expected-backup-sha256 <sha256>。')
}
options.expectedBackupSha256 = value
index += 1
continue
}
if (arg.startsWith('--expected-backup-sha256=')) {
options.expectedBackupSha256 = arg.slice('--expected-backup-sha256='.length)
continue
}
if (arg === '--output') {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('缺少 --output <json-file>。')
}
options.outputPath = value
index += 1
continue
}
if (arg.startsWith('--output=')) {
options.outputPath = arg.slice('--output='.length)
if (!options.outputPath) {
throw new Error('缺少 --output <json-file>。')
}
continue
}
throw new Error(`未知参数:${arg}`)
}
return options
}
function getBackupFingerprint(env = process.env, rootDir = ROOT_DIR) {
const rawPath = env.DATABASE_BACKUP_FILE || env.DB_BACKUP_FILE || ''
if (!rawPath) {
return { provided: false, path: '', exists: false, sha256: '' }
}
const absolutePath = path.resolve(rootDir, rawPath)
if (!fs.existsSync(absolutePath)) {
return { provided: true, path: absolutePath, exists: false, sha256: '' }
}
const bytes = fs.readFileSync(absolutePath)
return {
provided: true,
path: absolutePath,
exists: true,
sizeBytes: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
}
}
function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
if (!outputPath) return ''
const absolutePath = path.resolve(rootDir, outputPath)
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return absolutePath
}
function buildFoundationMigrationPlan(preflight, options = {}) {
const blockers = [...(preflight.blockers ?? [])]
const canDeploy = preflight.ready && preflight.recommendedAction === 'migrate-deploy-required'
if (preflight.ready && preflight.recommendedAction !== 'migrate-deploy-required') {
blockers.push({
key: 'recommendedAction',
message: `当前建议动作是 ${preflight.recommendedAction},本脚本只执行 migrate deploy。`,
})
}
if (options.apply && options.confirm !== 'migrate-deploy') {
blockers.push({
key: 'confirm',
message: '真实执行必须显式传入 --confirm=migrate-deploy。',
})
}
if (options.apply && !options.expectedDatabaseIdentity) {
blockers.push({
key: 'expectedDatabaseIdentity.missing',
message: '真实迁移必须显式传入 --expected-database-identity,避免误连错库。',
})
} else if (
options.apply &&
options.actualDatabaseIdentity &&
options.expectedDatabaseIdentity !== options.actualDatabaseIdentity
) {
blockers.push({
key: 'expectedDatabaseIdentity.mismatch',
message: '命令行指定的目标库身份和当前 DATABASE_URL 不一致,禁止执行 migrate deploy。',
})
}
if (options.apply && !options.expectedBackupSha256) {
blockers.push({
key: 'expectedBackupSha256.missing',
message: '真实迁移必须显式传入 --expected-backup-sha256,避免在没有确认备份的情况下写库。',
})
} else if (options.apply && options.backupFingerprint?.sha256) {
if (options.expectedBackupSha256 !== options.backupFingerprint.sha256) {
blockers.push({
key: 'expectedBackupSha256.mismatch',
message: '命令行指定的备份 sha256 和当前 DATABASE_BACKUP_FILE 不一致,禁止执行 migrate deploy。',
})
}
}
if (options.apply && options.backupFingerprint?.provided !== true) {
blockers.push({
key: 'backupFingerprint.missing',
message: '真实迁移前必须通过 DATABASE_BACKUP_FILE 或 DB_BACKUP_FILE 指定当前数据库备份。',
})
} else if (options.apply && options.backupFingerprint?.exists !== true) {
blockers.push({
key: 'backupFingerprint.notFound',
message: '命令指定的数据库备份文件不存在,禁止执行 migrate deploy。',
})
}
return {
mode: 'database-foundation-migration-plan',
dryRun: !options.apply,
readyToRun: canDeploy && blockers.length === 0,
recommendedAction: preflight.recommendedAction,
database: preflight.database,
backupEvidence: preflight.backupEvidence,
pendingMigrationCount: preflight.pendingMigrationCount,
currentSchemaDiffHasSql: preflight.currentSchemaDiffHasSql,
migrationDiffSkipped: preflight.migrationDiffSkipped,
actualDatabaseIdentity: options.actualDatabaseIdentity || '',
expectedDatabaseIdentity: options.expectedDatabaseIdentity || '',
backupFingerprint: options.backupFingerprint || null,
commands: [
{
name: 'prisma-migrate-deploy',
command: 'npm run db:foundation:migrate -- --apply --confirm=migrate-deploy --expected-database-identity <database-identity> --expected-backup-sha256 <backup-sha256>',
purpose: '把已审计的 Prisma migration 正式应用到目标库。',
dangerous: true,
},
{
name: 'master-data-readiness',
command: 'npm run db:verify-master-data:readiness',
purpose: '迁移后重新跑总就绪度门禁,确认结构、迁移历史和数据质量状态。',
},
],
blockers,
}
}
function runPrismaMigrateDeploy() {
return spawnSync(process.execPath, [PRISMA_CLI, 'migrate', 'deploy'], {
cwd: ROOT_DIR,
encoding: 'utf8',
stdio: 'inherit',
windowsHide: true,
})
}
async function main() {
const options = parseArgs()
const databaseUrl = loadDatabaseUrl(ROOT_DIR)
const actualDatabaseIdentity = getDatabaseIdentity(databaseUrl)
const backupFingerprint = getBackupFingerprint()
const preflight = await buildPreflightReport()
const plan = buildFoundationMigrationPlan(preflight, {
...options,
actualDatabaseIdentity,
backupFingerprint,
})
writeJsonReport(options.outputPath, plan)
console.log(JSON.stringify(plan, null, 2))
if (!plan.readyToRun) {
process.exit(1)
}
if (!options.apply) {
return
}
const deploy = runPrismaMigrateDeploy()
if (deploy.status !== 0) {
process.exit(deploy.status || 1)
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}
module.exports = {
buildFoundationMigrationPlan,
getBackupFingerprint,
parseArgs,
writeJsonReport,
}

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,38 @@
import 'dotenv/config'
import { prisma } from '../src/lib/prisma'
import { openPlatformDemoSeedService } from '../src/server/services/open-platform-demo-seed.service'
const CONFIRM_TOKEN = 'seed-local-open-platform-demo'
function assertLocalDemoSeedAllowed() {
if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) {
throw new Error(`open-platform demo seed 只允许显式本地造数:请追加 --confirm=${CONFIRM_TOKEN}。`)
}
if (process.env.NODE_ENV === 'production') {
throw new Error('open-platform demo seed 禁止在 NODE_ENV=production 下执行。')
}
const databaseUrl = process.env.DATABASE_URL || ''
if (!databaseUrl) {
throw new Error('open-platform demo seed 必须先配置 DATABASE_URL。')
}
const host = new URL(databaseUrl).hostname
if (!['localhost', '127.0.0.1', '::1'].includes(host)) {
throw new Error('open-platform demo seed 只允许连接本机数据库。')
}
}
async function main() {
const result = await openPlatformDemoSeedService.seed()
assertLocalDemoSeedAllowed()
const [{ prisma }, { openPlatformDemoSeedService }] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/open-platform-demo-seed.service'),
])
try {
const result = await openPlatformDemoSeedService.seed()
console.log('开放平台演示数据已完成。')
console.log(JSON.stringify(result, null, 2))
console.log('开放平台演示数据已完成。')
console.log(JSON.stringify(result, null, 2))
} finally {
await prisma.$disconnect()
}
}
main()
@ -16,6 +41,3 @@ main()
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -1,25 +1,53 @@
/**
* 创建测试产品数据
*/
const CONFIRM_TOKEN = 'seed-local-test-products'
import { PrismaClient } from '@prisma/client'
function assertLocalScriptAllowed() {
if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) {
throw new Error(`seed-test-products 只允许显式本地补数:请追加 --confirm=${CONFIRM_TOKEN}。`)
}
if (process.env.NODE_ENV === 'production') {
throw new Error('seed-test-products 禁止在 NODE_ENV=production 下执行。')
}
const databaseUrl = process.env.DATABASE_URL || ''
if (!databaseUrl) {
throw new Error('seed-test-products 必须先配置 DATABASE_URL。')
}
const host = new URL(databaseUrl).hostname
if (!['localhost', '127.0.0.1', '::1'].includes(host)) {
throw new Error('seed-test-products 只允许连接本机数据库。')
}
}
const prisma = new PrismaClient()
function buildDemoAssetProfile(profile: { color: string; size: string; material: string }) {
return {
size: { label: profile.size },
color: profile.color,
material: profile.material,
syncedAt: new Date(),
lockedReasons: {},
sourceVariants: [],
}
}
async function main() {
assertLocalScriptAllowed()
const { prisma } = await import('../src/lib/prisma')
console.log('开始创建测试产品...')
// 获取第一个用户作为创建者和 SKU 负责人
const user = await prisma.user.findFirst({
where: { status: 'ACTIVE' },
})
try {
// 获取第一个用户作为创建者和 SKU 负责人
const user = await prisma.user.findFirst({
where: { status: 'ACTIVE' },
})
if (!user) {
console.error('❌ 没有找到活跃用户,请先创建用户')
process.exit(1)
}
if (!user) {
console.error('❌ 没有找到活跃用户,请先创建用户')
process.exit(1)
}
console.log(`使用用户: ${user.name} (${user.email})`)
console.log(`使用用户: ${user.name} (${user.email})`)
// 测试产品数据
const testProducts = [
@ -32,7 +60,7 @@ async function main() {
skus: [
{
skuCode: 'SF-001-GRAY-3S',
attributes: { color: '灰色', size: '三人座', material: '布艺' },
assetProfile: { color: '灰色', size: '三人座', material: '布艺' },
price: 3999.00,
costPrice: 2000.00,
stock: 50,
@ -40,7 +68,7 @@ async function main() {
},
{
skuCode: 'SF-001-BLUE-3S',
attributes: { color: '蓝色', size: '三人座', material: '布艺' },
assetProfile: { color: '蓝色', size: '三人座', material: '布艺' },
price: 3999.00,
costPrice: 2000.00,
stock: 30,
@ -86,7 +114,7 @@ async function main() {
skus: [
{
skuCode: 'TB-002-OAK-L',
attributes: { color: '橡木色', size: '大号(1.6m)', material: '实木' },
assetProfile: { color: '橡木色', size: '大号(1.6m)', material: '实木' },
price: 2599.00,
costPrice: 1300.00,
stock: 40,
@ -132,7 +160,7 @@ async function main() {
skus: [
{
skuCode: 'BK-003-WHITE-5L',
attributes: { color: '白色', size: '五层', material: '环保板材' },
assetProfile: { color: '白色', size: '五层', material: '环保板材' },
price: 1299.00,
costPrice: 650.00,
stock: 60,
@ -140,7 +168,7 @@ async function main() {
},
{
skuCode: 'BK-003-WALNUT-5L',
attributes: { color: '胡桃木色', size: '五层', material: '环保板材' },
assetProfile: { color: '胡桃木色', size: '五层', material: '环保板材' },
price: 1399.00,
costPrice: 700.00,
stock: 45,
@ -189,7 +217,7 @@ async function main() {
]
// 创建产品
for (const productData of testProducts) {
for (const productData of testProducts) {
console.log(`\n创建产品: ${productData.modelNumber} - ${productData.name}`)
const { skus, assemblySteps, ...productInfo } = productData
@ -199,11 +227,15 @@ async function main() {
...productInfo,
createdBy: user.id,
skus: {
create: skus.map(sku => ({
create: skus.map(({ assetProfile, ...sku }) => ({
...sku,
attributes: {},
price: sku.price,
costPrice: sku.costPrice,
ownerId: user.id,
assetProfile: {
create: buildDemoAssetProfile(assetProfile),
},
})),
},
assemblySteps: {
@ -227,9 +259,12 @@ async function main() {
console.log(` - SKU数量: ${product.skus.length}`)
console.log(` - 装配步骤: ${product.assemblySteps.length}`)
console.log(` - 分享链接: ${product.shareLinks.length}`)
}
}
console.log('\n✅ 所有测试产品创建完成!')
console.log('\n✅ 所有测试产品创建完成!')
} finally {
await prisma.$disconnect()
}
}
main()
@ -237,6 +272,3 @@ main()
console.error('❌ 创建失败:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,560 @@
-- External read-only contract health check.
-- Run after applying the external schema migration.
-- This script is read-only and only reports contract or data risks.
WITH expected_views(view_name) AS (
VALUES
('materials_v1'),
('asset_variants_v1'),
('product_skus_v1'),
('product_assets_v1'),
('product_bom_v1'),
('intake_products_v1'),
('intake_products_ready_v1'),
('intake_sku_items_v1'),
('intake_sku_items_ready_v1')
)
SELECT
'missing_external_view' AS check_name,
expected_views.view_name
FROM expected_views
LEFT JOIN information_schema.views existing_views
ON existing_views.table_schema = 'external'
AND existing_views.table_name = expected_views.view_name
WHERE existing_views.table_name IS NULL
ORDER BY expected_views.view_name;
WITH expected_columns(view_name, column_name) AS (
VALUES
('materials_v1', 'material_id'),
('materials_v1', 'material_code'),
('materials_v1', 'material_name'),
('materials_v1', 'category_name'),
('materials_v1', 'thumbnail_url'),
('materials_v1', 'texture_url'),
('materials_v1', 'normal_map_url'),
('materials_v1', 'base_color'),
('materials_v1', 'metalness'),
('materials_v1', 'roughness'),
('materials_v1', 'opacity'),
('materials_v1', 'record_status'),
('asset_variants_v1', 'asset_variant_id'),
('asset_variants_v1', 'spec_code'),
('asset_variants_v1', 'spec_name'),
('asset_variants_v1', 'asset_type'),
('asset_variants_v1', 'asset_status'),
('asset_variants_v1', 'asset_family_id'),
('asset_variants_v1', 'asset_family_code'),
('asset_variants_v1', 'asset_family_name'),
('asset_variants_v1', 'asset_series_id'),
('asset_variants_v1', 'asset_series_code'),
('asset_variants_v1', 'asset_series_name'),
('asset_variants_v1', 'product_category_id'),
('asset_variants_v1', 'product_category_name'),
('asset_variants_v1', 'size_label'),
('asset_variants_v1', 'dimensions'),
('asset_variants_v1', 'model_url'),
('asset_variants_v1', 'thumbnail_url'),
('asset_variants_v1', 'barcode'),
('asset_variants_v1', 'supplier_name'),
('asset_variants_v1', 'supplier_code'),
('asset_variants_v1', 'version_no'),
('asset_variants_v1', 'substrate_id'),
('asset_variants_v1', 'substrate_name'),
('asset_variants_v1', 'finish_id'),
('asset_variants_v1', 'finish_code'),
('asset_variants_v1', 'finish_name'),
('asset_variants_v1', 'material_label'),
('asset_variants_v1', 'weight_kg'),
('asset_variants_v1', 'cost_price'),
('asset_variants_v1', 'estimated_cost_price'),
('asset_variants_v1', 'created_at'),
('asset_variants_v1', 'updated_at'),
('product_skus_v1', 'product_id'),
('product_skus_v1', 'product_model_number'),
('product_skus_v1', 'product_business_key'),
('product_skus_v1', 'product_name'),
('product_skus_v1', 'product_description'),
('product_skus_v1', 'product_category_id'),
('product_skus_v1', 'product_category_name'),
('product_skus_v1', 'product_thumbnail_url'),
('product_skus_v1', 'product_model_url'),
('product_skus_v1', 'sku_id'),
('product_skus_v1', 'sku_code'),
('product_skus_v1', 'sku_business_key'),
('product_skus_v1', 'sku_name'),
('product_skus_v1', 'price'),
('product_skus_v1', 'cost_price'),
('product_skus_v1', 'stock'),
('product_skus_v1', 'is_active'),
('product_skus_v1', 'record_status'),
('product_skus_v1', 'model_url'),
('product_skus_v1', 'image_url'),
('product_skus_v1', 'image_urls'),
('product_skus_v1', 'source_pre_sku_id'),
('product_skus_v1', 'source_asset_variant_id'),
('product_skus_v1', 'source_asset_variant_ids'),
('product_skus_v1', 'asset_family_code'),
('product_skus_v1', 'asset_family_name'),
('product_skus_v1', 'spec_code'),
('product_skus_v1', 'spec_name'),
('product_skus_v1', 'barcode'),
('product_skus_v1', 'size_json'),
('product_skus_v1', 'color'),
('product_skus_v1', 'material'),
('product_skus_v1', 'source_status'),
('product_skus_v1', 'synced_at'),
('product_skus_v1', 'warehouse_location_id'),
('product_skus_v1', 'warehouse_location_code'),
('product_skus_v1', 'warehouse_location_name'),
('product_skus_v1', 'created_at'),
('product_skus_v1', 'updated_at'),
('product_assets_v1', 'asset_type'),
('product_assets_v1', 'product_id'),
('product_assets_v1', 'sku_id'),
('product_assets_v1', 'asset_url'),
('product_assets_v1', 'file_name'),
('product_assets_v1', 'mime_type'),
('product_assets_v1', 'sort_order'),
('product_assets_v1', 'product_business_key'),
('product_assets_v1', 'sku_business_key'),
('product_bom_v1', 'item_type'),
('product_bom_v1', 'product_id'),
('product_bom_v1', 'sku_id'),
('product_bom_v1', 'item_id'),
('product_bom_v1', 'item_code'),
('product_bom_v1', 'item_name'),
('product_bom_v1', 'quantity'),
('product_bom_v1', 'unit'),
('product_bom_v1', 'material_id'),
('product_bom_v1', 'material_code'),
('product_bom_v1', 'material_name'),
('product_bom_v1', 'dimensions'),
('product_bom_v1', 'unit_cost'),
('product_bom_v1', 'total_cost'),
('product_bom_v1', 'created_at'),
('product_bom_v1', 'updated_at'),
('product_bom_v1', 'product_business_key'),
('product_bom_v1', 'sku_business_key'),
('intake_products_v1', 'intake_product_id'),
('intake_products_v1', 'spu'),
('intake_products_v1', 'spu_base'),
('intake_products_v1', 'brand'),
('intake_products_v1', 'product_business_key'),
('intake_products_v1', 'product_name'),
('intake_products_v1', 'product_image_url'),
('intake_products_v1', 'product_category'),
('intake_products_v1', 'sales_category'),
('intake_products_v1', 'product_year'),
('intake_products_v1', 'product_status'),
('intake_products_v1', 'designer'),
('intake_products_v1', 'operator'),
('intake_products_v1', 'purchaser'),
('intake_products_v1', 'product_planning'),
('intake_products_v1', 'visual_planning'),
('intake_products_v1', 'structure_designer'),
('intake_products_v1', 'purchase_owner'),
('intake_products_v1', 'product_development_kind'),
('intake_products_v1', 'goods_type'),
('intake_products_v1', 'material_type'),
('intake_products_v1', 'production_type'),
('intake_products_v1', 'created_at'),
('intake_products_v1', 'updated_at'),
('intake_products_ready_v1', 'intake_product_id'),
('intake_products_ready_v1', 'spu'),
('intake_products_ready_v1', 'spu_base'),
('intake_products_ready_v1', 'brand'),
('intake_products_ready_v1', 'product_business_key'),
('intake_products_ready_v1', 'product_name'),
('intake_products_ready_v1', 'product_image_url'),
('intake_products_ready_v1', 'product_category'),
('intake_products_ready_v1', 'sales_category'),
('intake_products_ready_v1', 'product_year'),
('intake_products_ready_v1', 'product_status'),
('intake_products_ready_v1', 'designer'),
('intake_products_ready_v1', 'operator'),
('intake_products_ready_v1', 'purchaser'),
('intake_products_ready_v1', 'product_planning'),
('intake_products_ready_v1', 'visual_planning'),
('intake_products_ready_v1', 'structure_designer'),
('intake_products_ready_v1', 'purchase_owner'),
('intake_products_ready_v1', 'product_development_kind'),
('intake_products_ready_v1', 'goods_type'),
('intake_products_ready_v1', 'material_type'),
('intake_products_ready_v1', 'production_type'),
('intake_products_ready_v1', 'created_at'),
('intake_products_ready_v1', 'updated_at'),
('intake_sku_items_v1', 'intake_product_id'),
('intake_sku_items_v1', 'spu'),
('intake_sku_items_v1', 'brand'),
('intake_sku_items_v1', 'product_business_key'),
('intake_sku_items_v1', 'intake_sku_item_id'),
('intake_sku_items_v1', 'sku_business_key'),
('intake_sku_items_v1', 'sku_sequence'),
('intake_sku_items_v1', 'batch_no'),
('intake_sku_items_v1', 'image_url'),
('intake_sku_items_v1', 'image_name'),
('intake_sku_items_v1', 'model'),
('intake_sku_items_v1', 'sku_code'),
('intake_sku_items_v1', 'spec_name'),
('intake_sku_items_v1', 'barcode'),
('intake_sku_items_v1', 'size'),
('intake_sku_items_v1', 'color'),
('intake_sku_items_v1', 'material'),
('intake_sku_items_v1', 'version_no'),
('intake_sku_items_v1', 'attribute_code'),
('intake_sku_items_v1', 'drawing_id'),
('intake_sku_items_v1', 'install_method'),
('intake_sku_items_v1', 'execute_standard'),
('intake_sku_items_v1', 'manufacturer'),
('intake_sku_items_v1', 'manufacturer_code'),
('intake_sku_items_v1', 'sales_status'),
('intake_sku_items_v1', 'model_3d_url'),
('intake_sku_items_v1', 'source'),
('intake_sku_items_v1', 'source_table'),
('intake_sku_items_v1', 'source_sku_id'),
('intake_sku_items_v1', 'missing_required_fields'),
('intake_sku_items_v1', 'data_quality_status'),
('intake_sku_items_v1', 'content_locked'),
('intake_sku_items_v1', 'sort_order'),
('intake_sku_items_ready_v1', 'intake_product_id'),
('intake_sku_items_ready_v1', 'spu'),
('intake_sku_items_ready_v1', 'brand'),
('intake_sku_items_ready_v1', 'product_business_key'),
('intake_sku_items_ready_v1', 'intake_sku_item_id'),
('intake_sku_items_ready_v1', 'sku_business_key'),
('intake_sku_items_ready_v1', 'sku_sequence'),
('intake_sku_items_ready_v1', 'batch_no'),
('intake_sku_items_ready_v1', 'image_url'),
('intake_sku_items_ready_v1', 'image_name'),
('intake_sku_items_ready_v1', 'model'),
('intake_sku_items_ready_v1', 'sku_code'),
('intake_sku_items_ready_v1', 'spec_name'),
('intake_sku_items_ready_v1', 'barcode'),
('intake_sku_items_ready_v1', 'size'),
('intake_sku_items_ready_v1', 'color'),
('intake_sku_items_ready_v1', 'material'),
('intake_sku_items_ready_v1', 'version_no'),
('intake_sku_items_ready_v1', 'attribute_code'),
('intake_sku_items_ready_v1', 'drawing_id'),
('intake_sku_items_ready_v1', 'install_method'),
('intake_sku_items_ready_v1', 'execute_standard'),
('intake_sku_items_ready_v1', 'manufacturer'),
('intake_sku_items_ready_v1', 'manufacturer_code'),
('intake_sku_items_ready_v1', 'sales_status'),
('intake_sku_items_ready_v1', 'model_3d_url'),
('intake_sku_items_ready_v1', 'source'),
('intake_sku_items_ready_v1', 'source_table'),
('intake_sku_items_ready_v1', 'source_sku_id'),
('intake_sku_items_ready_v1', 'missing_required_fields'),
('intake_sku_items_ready_v1', 'data_quality_status'),
('intake_sku_items_ready_v1', 'content_locked'),
('intake_sku_items_ready_v1', 'sort_order')
)
SELECT
'missing_external_column' AS check_name,
expected_columns.view_name,
expected_columns.column_name
FROM expected_columns
LEFT JOIN information_schema.columns existing_columns
ON existing_columns.table_schema = 'external'
AND existing_columns.table_name = expected_columns.view_name
AND existing_columns.column_name = expected_columns.column_name
WHERE existing_columns.column_name IS NULL
ORDER BY expected_columns.view_name, expected_columns.column_name;
SELECT
'external_contract_private_column_risk' AS check_name,
table_name AS view_name,
column_name
FROM information_schema.columns
WHERE table_schema = 'external'
AND column_name ~* '(payload|attributes|business_data|visibility|department|team|storage_key|file_path|password|token|secret)'
ORDER BY table_name, column_name;
SELECT
'external_view_json_source_dependency_risk' AS check_name,
viewname AS view_name,
CASE
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '\"payload\"' THEN 'payload'
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '\"attributes\"' THEN 'attributes'
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '\"businessData\"' THEN 'businessData'
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '\"displaySummary\"' THEN 'displaySummary'
WHEN pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '\"familySelection\"' THEN 'familySelection'
ELSE 'json_operator'
END AS dependency_hint
FROM pg_views
WHERE schemaname = 'external'
AND (
pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '\"(payload|attributes|businessData|displaySummary|familySelection)\"'
OR pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) ~ '(->>|->|jsonb_array_elements)'
)
ORDER BY viewname, dependency_hint;
SELECT
'external_reader_public_schema_grant_risk' AS check_name,
grantee,
table_schema,
table_name,
privilege_type
FROM information_schema.table_privileges
WHERE grantee = 'external_reader'
AND table_schema = 'public'
ORDER BY table_name, privilege_type;
SELECT
'external_reader_non_select_grant_risk' AS check_name,
grantee,
table_schema,
table_name,
privilege_type
FROM information_schema.table_privileges
WHERE grantee = 'external_reader'
AND table_schema = 'external'
AND privilege_type <> 'SELECT'
ORDER BY table_name, privilege_type;
SELECT
'external_view_row_count' AS check_name,
'materials_v1' AS view_name,
COUNT(*)::integer AS row_count
FROM external.materials_v1
UNION ALL
SELECT
'external_view_row_count',
'asset_variants_v1',
COUNT(*)::integer
FROM external.asset_variants_v1
UNION ALL
SELECT
'external_view_row_count',
'product_skus_v1',
COUNT(*)::integer
FROM external.product_skus_v1
UNION ALL
SELECT
'external_view_row_count',
'product_assets_v1',
COUNT(*)::integer
FROM external.product_assets_v1
UNION ALL
SELECT
'external_view_row_count',
'product_bom_v1',
COUNT(*)::integer
FROM external.product_bom_v1
UNION ALL
SELECT
'external_view_row_count',
'intake_products_v1',
COUNT(*)::integer
FROM external.intake_products_v1
UNION ALL
SELECT
'external_view_row_count',
'intake_products_ready_v1',
COUNT(*)::integer
FROM external.intake_products_ready_v1
UNION ALL
SELECT
'external_view_row_count',
'intake_sku_items_v1',
COUNT(*)::integer
FROM external.intake_sku_items_v1
UNION ALL
SELECT
'external_view_row_count',
'intake_sku_items_ready_v1',
COUNT(*)::integer
FROM external.intake_sku_items_ready_v1;
SELECT
'product_skus_blank_key_fields' AS check_name,
COUNT(*) FILTER (WHERE NULLIF(sku_code, '') IS NULL) AS blank_sku_code_count,
COUNT(*) FILTER (WHERE NULLIF(product_model_number, '') IS NULL) AS blank_product_model_number_count,
COUNT(*) FILTER (WHERE NULLIF(sku_name, '') IS NULL) AS blank_sku_name_count,
COUNT(*) FILTER (WHERE NULLIF(product_business_key, '') IS NULL) AS blank_product_business_key_count,
COUNT(*) FILTER (WHERE NULLIF(sku_business_key, '') IS NULL) AS blank_sku_business_key_count
FROM external.product_skus_v1;
SELECT
'asset_variants_blank_key_fields' AS check_name,
COUNT(*) FILTER (WHERE NULLIF(spec_code, '') IS NULL) AS blank_spec_code_count,
COUNT(*) FILTER (WHERE NULLIF(spec_name, '') IS NULL) AS blank_spec_name_count,
COUNT(*) FILTER (WHERE NULLIF(asset_family_code, '') IS NULL) AS blank_asset_family_code_count
FROM external.asset_variants_v1;
SELECT
'materials_blank_key_fields' AS check_name,
COUNT(*) FILTER (WHERE NULLIF(material_code, '') IS NULL) AS blank_material_code_count,
COUNT(*) FILTER (WHERE NULLIF(material_name, '') IS NULL) AS blank_material_name_count
FROM external.materials_v1;
SELECT
'product_bom_blank_key_fields' AS check_name,
COUNT(*) FILTER (WHERE NULLIF(product_business_key, '') IS NULL) AS blank_product_business_key_count,
COUNT(*) FILTER (WHERE NULLIF(item_code, '') IS NULL) AS blank_item_code_count,
COUNT(*) FILTER (WHERE NULLIF(item_name, '') IS NULL) AS blank_item_name_count,
COUNT(*) FILTER (
WHERE item_type = 'PANEL'
AND NULLIF(material_code, '') IS NULL
) AS blank_panel_material_code_count
FROM external.product_bom_v1;
SELECT
'intake_sku_items_blank_key_fields' AS check_name,
COUNT(*) FILTER (WHERE NULLIF(sku_code, '') IS NULL) AS blank_sku_code_count,
COUNT(*) FILTER (WHERE NULLIF(spec_name, '') IS NULL) AS blank_spec_name_count,
COUNT(*) FILTER (WHERE NULLIF(model, '') IS NULL) AS blank_model_count
FROM external.intake_sku_items_v1;
SELECT
'intake_sku_items_quality_status_count' AS check_name,
data_quality_status,
COUNT(*)::integer AS row_count
FROM external.intake_sku_items_v1
GROUP BY data_quality_status
ORDER BY data_quality_status;
SELECT
'intake_sku_items_ready_quality_violation' AS check_name,
COUNT(*)::integer AS row_count
FROM external.intake_sku_items_ready_v1
WHERE data_quality_status <> 'READY';
SELECT
'intake_ready_business_key_missing_risk' AS check_name,
(
SELECT COUNT(*)::integer
FROM external.intake_products_ready_v1
WHERE NULLIF(product_business_key, '') IS NULL
) AS missing_product_business_key_count,
(
SELECT COUNT(*)::integer
FROM external.intake_sku_items_ready_v1
WHERE NULLIF(product_business_key, '') IS NULL
OR NULLIF(sku_business_key, '') IS NULL
) AS missing_sku_business_key_count;
SELECT
'product_sku_business_key_duplicate_risk' AS check_name,
sku_business_key,
COUNT(*)::integer AS duplicate_count
FROM external.product_skus_v1
WHERE NULLIF(sku_business_key, '') IS NOT NULL
GROUP BY sku_business_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, sku_business_key
LIMIT 20;
SELECT
'intake_product_business_key_duplicate_risk' AS check_name,
product_business_key,
COUNT(*)::integer AS duplicate_count
FROM external.intake_products_ready_v1
WHERE NULLIF(product_business_key, '') IS NOT NULL
GROUP BY product_business_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, product_business_key
LIMIT 20;
SELECT
'intake_sku_business_key_duplicate_risk' AS check_name,
sku_business_key,
COUNT(*)::integer AS duplicate_count
FROM external.intake_sku_items_ready_v1
WHERE NULLIF(sku_business_key, '') IS NOT NULL
GROUP BY sku_business_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, sku_business_key
LIMIT 20;
SELECT
'product_assets_data_url_risk' AS check_name,
asset_type,
COUNT(*) AS data_url_count
FROM external.product_assets_v1
WHERE asset_url ~* '^data:'
GROUP BY asset_type
ORDER BY asset_type;
SELECT
'product_assets_business_key_missing_risk' AS check_name,
COUNT(*) FILTER (WHERE NULLIF(product_business_key, '') IS NULL) AS missing_product_business_key_count,
COUNT(*) FILTER (WHERE NULLIF(sku_business_key, '') IS NULL) AS missing_sku_business_key_count
FROM external.product_assets_v1;
SELECT
'product_bom_business_key_missing_risk' AS check_name,
COUNT(*) FILTER (WHERE NULLIF(product_business_key, '') IS NULL) AS missing_product_business_key_count,
COUNT(*) FILTER (
WHERE sku_id IS NOT NULL
AND NULLIF(sku_business_key, '') IS NULL
) AS missing_sku_business_key_count
FROM external.product_bom_v1;
SELECT
'duplicate_product_sku_code_risk' AS check_name,
sku_code,
COUNT(*)::integer AS duplicate_count
FROM external.product_skus_v1
WHERE NULLIF(sku_code, '') IS NOT NULL
GROUP BY sku_code
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, sku_code
LIMIT 20;
SELECT
'duplicate_asset_variant_code_risk' AS check_name,
spec_code,
COUNT(*)::integer AS duplicate_count
FROM external.asset_variants_v1
WHERE NULLIF(spec_code, '') IS NOT NULL
GROUP BY spec_code
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, spec_code
LIMIT 20;
SELECT
'duplicate_material_code_risk' AS check_name,
material_code,
COUNT(*)::integer AS duplicate_count
FROM external.materials_v1
WHERE NULLIF(material_code, '') IS NOT NULL
GROUP BY material_code
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, material_code
LIMIT 20;
SELECT
'duplicate_product_barcode_risk' AS check_name,
barcode,
COUNT(*)::integer AS duplicate_count
FROM external.product_skus_v1
WHERE NULLIF(barcode, '') IS NOT NULL
GROUP BY barcode
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, barcode
LIMIT 20;
SELECT
'duplicate_intake_sku_code_risk' AS check_name,
sku_code,
COUNT(*)::integer AS duplicate_count
FROM external.intake_sku_items_v1
WHERE NULLIF(sku_code, '') IS NOT NULL
GROUP BY sku_code
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, sku_code
LIMIT 20;
SELECT
'duplicate_intake_barcode_risk' AS check_name,
barcode,
COUNT(*)::integer AS duplicate_count
FROM external.intake_sku_items_v1
WHERE NULLIF(barcode, '') IS NOT NULL
GROUP BY barcode
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, barcode
LIMIT 20;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
-- Grant read-only access to the external contract schema.
--
-- Usage:
-- 1. Create the database role/user outside this script through the approved admin channel.
-- 2. Replace external_reader with the actual read-only role name if needed.
-- 3. Run this as a database administrator.
--
-- This script intentionally does not create users or include connection details.
GRANT USAGE ON SCHEMA external TO external_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA external TO external_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA external
GRANT SELECT ON TABLES TO external_reader;

View File

@ -0,0 +1,77 @@
-- Preflight for structuring IntakeNonIntegratedProduct.payload.skuItems.
-- Run before migration 20260530093000_add_intake_non_integrated_sku_items.
-- Read-only. Any returned duplicate rows must be cleaned before applying the migration.
WITH payload_sku_items AS (
SELECT
p."id" AS product_id,
p."spu",
p."brand",
item.ordinality::integer AS sort_order,
NULLIF(btrim(item.sku_item ->> 'skuCode'), '') AS sku_code,
NULLIF(btrim(item.sku_item ->> 'barcode'), '') AS barcode
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS item(sku_item, ordinality)
WHERE p."deletedAt" IS NULL
)
SELECT
'preflight_duplicate_sku_code' AS check_name,
sku_code AS duplicate_value,
COUNT(*)::integer AS duplicate_count,
jsonb_agg(
jsonb_build_object(
'product_id', product_id,
'spu', spu,
'brand', brand,
'sort_order', sort_order
)
ORDER BY product_id, sort_order
) AS affected_rows
FROM payload_sku_items
WHERE sku_code IS NOT NULL
GROUP BY sku_code
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, sku_code;
WITH payload_sku_items AS (
SELECT
p."id" AS product_id,
p."spu",
p."brand",
item.ordinality::integer AS sort_order,
NULLIF(btrim(item.sku_item ->> 'skuCode'), '') AS sku_code,
NULLIF(btrim(item.sku_item ->> 'barcode'), '') AS barcode
FROM "IntakeNonIntegratedProduct" p
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(p."payload"::jsonb -> 'skuItems') = 'array'
THEN p."payload"::jsonb -> 'skuItems'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS item(sku_item, ordinality)
WHERE p."deletedAt" IS NULL
)
SELECT
'preflight_duplicate_barcode' AS check_name,
barcode AS duplicate_value,
COUNT(*)::integer AS duplicate_count,
jsonb_agg(
jsonb_build_object(
'product_id', product_id,
'spu', spu,
'brand', brand,
'sort_order', sort_order
)
ORDER BY product_id, sort_order
) AS affected_rows
FROM payload_sku_items
WHERE barcode IS NOT NULL
GROUP BY barcode
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, barcode;

Some files were not shown because too many files have changed in this diff Show More