diff --git a/furniture-3d-system/.gitignore b/furniture-3d-system/.gitignore index fd957313..4801205e 100644 --- a/furniture-3d-system/.gitignore +++ b/furniture-3d-system/.gitignore @@ -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/ diff --git a/furniture-3d-system/docs/DEPLOYMENT.md b/furniture-3d-system/docs/DEPLOYMENT.md index 8c301f0c..f7178d61 100644 --- a/furniture-3d-system/docs/DEPLOYMENT.md +++ b/furniture-3d-system/docs/DEPLOYMENT.md @@ -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` 和疑似生产库名。 diff --git a/furniture-3d-system/docs/workflow/生产部署入口说明.md b/furniture-3d-system/docs/workflow/生产部署入口说明.md index 797a71fd..543a454e 100644 --- a/furniture-3d-system/docs/workflow/生产部署入口说明.md +++ b/furniture-3d-system/docs/workflow/生产部署入口说明.md @@ -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`。 diff --git a/furniture-3d-system/docs/开发指南/包装方案增强/包装方案增强-非程序员操作手册.md b/furniture-3d-system/docs/开发指南/包装方案增强/包装方案增强-非程序员操作手册.md index 50b7def9..46081722 100644 --- a/furniture-3d-system/docs/开发指南/包装方案增强/包装方案增强-非程序员操作手册.md +++ b/furniture-3d-system/docs/开发指南/包装方案增强/包装方案增强-非程序员操作手册.md @@ -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:页面没变化 diff --git a/furniture-3d-system/docs/架构设计/内部主数据地基治理方案.md b/furniture-3d-system/docs/架构设计/内部主数据地基治理方案.md new file mode 100644 index 00000000..41e2a439 --- /dev/null +++ b/furniture-3d-system/docs/架构设计/内部主数据地基治理方案.md @@ -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-/` 下依次写出 `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 ` 指定端口。该步骤会调用 `scripts/rehearse-database-foundation-migration.js`,输出 `migration-rehearsal.json`;它预期在当前历史库状态下失败并复现 P3005,因此失败本身是阻断证据,不是脚本异常。 + +如果要证明“结构修复 SQL 是否真的能把当前备份恢复出的库修到 schema 和 migrations 等价”,显式追加 `--include-structure-repair-rehearsal`,并可用 `--docker-structure-repair-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 `,因此“资产中心、草稿组、正式产品、非集成开品、人员组织、旧 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 `,因此这些边界报告不能只存在于终端滚动输出里。 + +如果要把历史质量债候选行也放进同一批证据,使用 `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 --expected-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 --expected-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 落盘并写入 `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 `、`--rehearsal ` 和 `--structure-rehearsal ` 复用同一批审计证据。该脚本不会执行 `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 --expected-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 ` 先 dry-run 回滚;只有计划文件包含确认字段、业务确认人 `confirmedBy`,并且真实落库命令带 `--apply --expected-database-identity --expected-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 ` 不读取数据库,只确认计划文件具备 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 的质量债归零。 diff --git a/furniture-3d-system/docs/架构设计/内部主数据巡检基线-20260529.md b/furniture-3d-system/docs/架构设计/内部主数据巡检基线-20260529.md new file mode 100644 index 00000000..975d094f --- /dev/null +++ b/furniture-3d-system/docs/架构设计/内部主数据巡检基线-20260529.md @@ -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 ` 测试入口,已用隔离 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 ` dry-run;`npm run db:cleanup-master-data:validate -- --plan ` 做不连库、不执行迁移 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 --expected-backup-sha256 `,不是 `migrate deploy`。 + +授权结构修复入口:`npm run db:foundation:apply-structure-repair` 默认只读生成计划。真实执行结构 SQL 使用 `--phase=apply-structure --apply --confirm=apply-prisma-structure-repair --expected-sha256 --expected-database-identity --expected-backup-sha256 `,真实执行 baseline resolve 使用 `--phase=resolve-baseline --apply --confirm=apply-prisma-structure-repair --expected-sha256 --expected-database-identity --expected-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/-.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 质量债归零。 diff --git a/furniture-3d-system/docs/架构设计/外部只读契约巡检说明.md b/furniture-3d-system/docs/架构设计/外部只读契约巡检说明.md new file mode 100644 index 00000000..186e9aaf --- /dev/null +++ b/furniture-3d-system/docs/架构设计/外部只读契约巡检说明.md @@ -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` 契约层,不反向依赖内部原表。 +- 视觉:本次不涉及前端视觉。 +- 根因:把“外部只读是否安全”变成可运行检查,不靠口头约定。 diff --git a/furniture-3d-system/docs/架构设计/外部只读视图字段字典-v1.md b/furniture-3d-system/docs/架构设计/外部只读视图字段字典-v1.md new file mode 100644 index 00000000..88055166 --- /dev/null +++ b/furniture-3d-system/docs/架构设计/外部只读视图字段字典-v1.md @@ -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` 只读视图,不鼓励外部系统读业务原表。 +- 视觉:本次不涉及前端视觉。 +- 根因:从数据契约层隔离内部混乱,不再靠口头约定避免误读。 diff --git a/furniture-3d-system/docs/架构设计/数据库主数据治理与外部只读契约.md b/furniture-3d-system/docs/架构设计/数据库主数据治理与外部只读契约.md new file mode 100644 index 00000000..46b1eec1 --- /dev/null +++ b/furniture-3d-system/docs/架构设计/数据库主数据治理与外部只读契约.md @@ -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` + +这是自审,不是独立复审。 + +- 架构:文档明确了主数据真相源、外部契约层和只读账号边界,没有破坏现有分层。 +- 视觉:本次不涉及前端视觉。 +- 根因:没有用“直接开放表权限”做表面处理,而是先治理内部数据库地基和对外契约。 diff --git a/furniture-3d-system/docs/架构设计/数据库混乱点治理台账.md b/furniture-3d-system/docs/架构设计/数据库混乱点治理台账.md new file mode 100644 index 00000000..d3e0918b --- /dev/null +++ b/furniture-3d-system/docs/架构设计/数据库混乱点治理台账.md @@ -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 ` 不连接数据库、不执行迁移 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` + +这是自审,不是独立复审。 + +- 架构:本台账没有改变数据库结构,只明确混乱点、真相源和治理顺序。 +- 视觉:本次不涉及前端视觉。 +- 根因:没有把“直接开放只读账号”当解决方案,而是先治理数据地基和外部契约边界。 diff --git a/furniture-3d-system/docs/架构设计/数据库质量巡检基线-20260529.md b/furniture-3d-system/docs/架构设计/数据库质量巡检基线-20260529.md new file mode 100644 index 00000000..a6ea14cf --- /dev/null +++ b/furniture-3d-system/docs/架构设计/数据库质量巡检基线-20260529.md @@ -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` + +这是自审,不是独立复审。 + +- 架构:巡检基线只记录数据质量,不改变业务表和写入链路。 +- 视觉:本次不涉及前端视觉。 +- 根因:把“数据库哪里乱”落成可量化问题,后续按数据来源治理。 diff --git a/furniture-3d-system/docs/飞牛OS部署指南.md b/furniture-3d-system/docs/飞牛OS部署指南.md index 57a52a06..d0b6befe 100644 --- a/furniture-3d-system/docs/飞牛OS部署指南.md +++ b/furniture-3d-system/docs/飞牛OS部署指南.md @@ -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 diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index be05cfcb..48bd6d47 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -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", diff --git a/furniture-3d-system/prisma.config.ts b/furniture-3d-system/prisma.config.ts index f79f7597..bbf4860d 100644 --- a/furniture-3d-system/prisma.config.ts +++ b/furniture-3d-system/prisma.config.ts @@ -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 } + : {}), }, }) diff --git a/furniture-3d-system/prisma/migrations/20260430110000_add_approval_workflow_and_account_template/migration.sql b/furniture-3d-system/prisma/migrations/20260430110000_add_approval_workflow_and_account_template/migration.sql index 01c2d279..8d4566ec 100644 --- a/furniture-3d-system/prisma/migrations/20260430110000_add_approval_workflow_and_account_template/migration.sql +++ b/furniture-3d-system/prisma/migrations/20260430110000_add_approval_workflow_and_account_template/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/migrations/20260529213000_add_external_readonly_contract/migration.sql b/furniture-3d-system/prisma/migrations/20260529213000_add_external_readonly_contract/migration.sql new file mode 100644 index 00000000..f30c1d4f --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260529213000_add_external_readonly_contract/migration.sql @@ -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 +$$; diff --git a/furniture-3d-system/prisma/migrations/20260530093000_add_intake_non_integrated_sku_items/migration.sql b/furniture-3d-system/prisma/migrations/20260530093000_add_intake_non_integrated_sku_items/migration.sql new file mode 100644 index 00000000..dfb406e8 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530093000_add_intake_non_integrated_sku_items/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260530103000_add_product_sku_asset_profile/migration.sql b/furniture-3d-system/prisma/migrations/20260530103000_add_product_sku_asset_profile/migration.sql new file mode 100644 index 00000000..331b738c --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530103000_add_product_sku_asset_profile/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260530130000_add_pre_sku_basic_info/migration.sql b/furniture-3d-system/prisma/migrations/20260530130000_add_pre_sku_basic_info/migration.sql new file mode 100644 index 00000000..ffe21eaa --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530130000_add_pre_sku_basic_info/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260530143000_tighten_external_readonly_view_sources/migration.sql b/furniture-3d-system/prisma/migrations/20260530143000_tighten_external_readonly_view_sources/migration.sql new file mode 100644 index 00000000..0d3c567c --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530143000_tighten_external_readonly_view_sources/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260530150000_add_intake_non_integrated_product_profile/migration.sql b/furniture-3d-system/prisma/migrations/20260530150000_add_intake_non_integrated_product_profile/migration.sql new file mode 100644 index 00000000..3f732156 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530150000_add_intake_non_integrated_product_profile/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260530162000_add_product_patent_records/migration.sql b/furniture-3d-system/prisma/migrations/20260530162000_add_product_patent_records/migration.sql new file mode 100644 index 00000000..a614289e --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530162000_add_product_patent_records/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/migrations/20260530172000_add_stock_source_fields_to_intake_product_profile/migration.sql b/furniture-3d-system/prisma/migrations/20260530172000_add_stock_source_fields_to_intake_product_profile/migration.sql new file mode 100644 index 00000000..b0d65cb5 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530172000_add_stock_source_fields_to_intake_product_profile/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/migrations/20260530190000_add_intake_reference_library/migration.sql b/furniture-3d-system/prisma/migrations/20260530190000_add_intake_reference_library/migration.sql new file mode 100644 index 00000000..aedfed6a --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260530190000_add_intake_reference_library/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/migrations/20260531103000_tighten_external_intake_views_to_structured_sources/migration.sql b/furniture-3d-system/prisma/migrations/20260531103000_tighten_external_intake_views_to_structured_sources/migration.sql new file mode 100644 index 00000000..dadd8f61 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531103000_tighten_external_intake_views_to_structured_sources/migration.sql @@ -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'; diff --git a/furniture-3d-system/prisma/migrations/20260531113000_add_spu_number_counter/migration.sql b/furniture-3d-system/prisma/migrations/20260531113000_add_spu_number_counter/migration.sql new file mode 100644 index 00000000..7814dc5b --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531113000_add_spu_number_counter/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260531143000_add_intake_master_data_check_constraints/migration.sql b/furniture-3d-system/prisma/migrations/20260531143000_add_intake_master_data_check_constraints/migration.sql new file mode 100644 index 00000000..4b4bac55 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531143000_add_intake_master_data_check_constraints/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260531153000_add_spu_number_counter_check_constraints/migration.sql b/furniture-3d-system/prisma/migrations/20260531153000_add_spu_number_counter_check_constraints/migration.sql new file mode 100644 index 00000000..8c427261 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531153000_add_spu_number_counter_check_constraints/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260531162000_add_spu_rule_product_category_unique_guard/migration.sql b/furniture-3d-system/prisma/migrations/20260531162000_add_spu_rule_product_category_unique_guard/migration.sql new file mode 100644 index 00000000..198b7a60 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531162000_add_spu_rule_product_category_unique_guard/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260531170000_tighten_external_intake_product_people_sources/migration.sql b/furniture-3d-system/prisma/migrations/20260531170000_tighten_external_intake_product_people_sources/migration.sql new file mode 100644 index 00000000..4a991764 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531170000_tighten_external_intake_product_people_sources/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260531173000_add_product_sku_asset_profile_barcode_unique_guard/migration.sql b/furniture-3d-system/prisma/migrations/20260531173000_add_product_sku_asset_profile_barcode_unique_guard/migration.sql new file mode 100644 index 00000000..7bcc1ce4 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531173000_add_product_sku_asset_profile_barcode_unique_guard/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260531181000_add_product_sku_active_code_marker_guard/migration.sql b/furniture-3d-system/prisma/migrations/20260531181000_add_product_sku_active_code_marker_guard/migration.sql new file mode 100644 index 00000000..019c6196 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531181000_add_product_sku_active_code_marker_guard/migration.sql @@ -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 + ); diff --git a/furniture-3d-system/prisma/migrations/20260531190000_add_product_sku_asset_profile_sku_deleted_guard/migration.sql b/furniture-3d-system/prisma/migrations/20260531190000_add_product_sku_asset_profile_sku_deleted_guard/migration.sql new file mode 100644 index 00000000..a1b51e9c --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531190000_add_product_sku_asset_profile_sku_deleted_guard/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/migrations/20260531203000_normalize_core_code_identity_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260531203000_normalize_core_code_identity_guards/migration.sql new file mode 100644 index 00000000..7bd8c442 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531203000_normalize_core_code_identity_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260531213000_add_product_sku_asset_profile_identity_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260531213000_add_product_sku_asset_profile_identity_guards/migration.sql new file mode 100644 index 00000000..adc739b0 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531213000_add_product_sku_asset_profile_identity_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260531223000_add_product_patent_record_identity_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260531223000_add_product_patent_record_identity_guards/migration.sql new file mode 100644 index 00000000..fec38165 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531223000_add_product_patent_record_identity_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260531233000_add_intake_reference_library_identity_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260531233000_add_intake_reference_library_identity_guards/migration.sql new file mode 100644 index 00000000..3f1721e0 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531233000_add_intake_reference_library_identity_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260531234000_add_spu_number_counter_identity_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260531234000_add_spu_number_counter_identity_guards/migration.sql new file mode 100644 index 00000000..5f368465 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531234000_add_spu_number_counter_identity_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260531235000_add_pre_sku_basic_info_extended_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260531235000_add_pre_sku_basic_info_extended_guards/migration.sql new file mode 100644 index 00000000..2e475a4e --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260531235000_add_pre_sku_basic_info_extended_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260601000000_add_asset_sku_sync_event_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601000000_add_asset_sku_sync_event_guards/migration.sql new file mode 100644 index 00000000..4ea21cff --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601000000_add_asset_sku_sync_event_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260601001000_add_product_sku_asset_profile_source_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601001000_add_product_sku_asset_profile_source_guards/migration.sql new file mode 100644 index 00000000..ada5bf25 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601001000_add_product_sku_asset_profile_source_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260601002000_add_packaging_warehouse_foundation_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601002000_add_packaging_warehouse_foundation_guards/migration.sql new file mode 100644 index 00000000..a58f2863 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601002000_add_packaging_warehouse_foundation_guards/migration.sql @@ -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")) + ); diff --git a/furniture-3d-system/prisma/migrations/20260601003000_add_approval_workflow_foundation_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601003000_add_approval_workflow_foundation_guards/migration.sql new file mode 100644 index 00000000..dc9babce --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601003000_add_approval_workflow_foundation_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260601004000_add_people_org_foundation_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601004000_add_people_org_foundation_guards/migration.sql new file mode 100644 index 00000000..8e5cd7ab --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601004000_add_people_org_foundation_guards/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260601005000_add_planning_foundation_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601005000_add_planning_foundation_guards/migration.sql new file mode 100644 index 00000000..ff21db16 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601005000_add_planning_foundation_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260601006000_add_share_upload_collaboration_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601006000_add_share_upload_collaboration_guards/migration.sql new file mode 100644 index 00000000..4f09229c --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601006000_add_share_upload_collaboration_guards/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260601007000_add_legacy_cad_structure_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601007000_add_legacy_cad_structure_guards/migration.sql new file mode 100644 index 00000000..61098624 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601007000_add_legacy_cad_structure_guards/migration.sql @@ -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))); diff --git a/furniture-3d-system/prisma/migrations/20260601008000_add_reference_option_and_mapping_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601008000_add_reference_option_and_mapping_guards/migration.sql new file mode 100644 index 00000000..b5f680e3 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601008000_add_reference_option_and_mapping_guards/migration.sql @@ -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))); diff --git a/furniture-3d-system/prisma/migrations/20260601009000_add_config_audit_permission_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601009000_add_config_audit_permission_guards/migration.sql new file mode 100644 index 00000000..ac3c4494 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601009000_add_config_audit_permission_guards/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260601010000_add_external_intake_stable_business_keys/migration.sql b/furniture-3d-system/prisma/migrations/20260601010000_add_external_intake_stable_business_keys/migration.sql new file mode 100644 index 00000000..1946d54a --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601010000_add_external_intake_stable_business_keys/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260601011000_add_external_product_sku_stable_business_keys/migration.sql b/furniture-3d-system/prisma/migrations/20260601011000_add_external_product_sku_stable_business_keys/migration.sql new file mode 100644 index 00000000..be91880a --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601011000_add_external_product_sku_stable_business_keys/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260601012000_add_external_product_asset_bom_business_keys/migration.sql b/furniture-3d-system/prisma/migrations/20260601012000_add_external_product_asset_bom_business_keys/migration.sql new file mode 100644 index 00000000..4fc1f6a9 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601012000_add_external_product_asset_bom_business_keys/migration.sql @@ -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); diff --git a/furniture-3d-system/prisma/migrations/20260601013000_add_spu_rule_normalized_category_guard/migration.sql b/furniture-3d-system/prisma/migrations/20260601013000_add_spu_rule_normalized_category_guard/migration.sql new file mode 100644 index 00000000..b040cb42 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601013000_add_spu_rule_normalized_category_guard/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260601014000_add_reference_library_code_required_guard/migration.sql b/furniture-3d-system/prisma/migrations/20260601014000_add_reference_library_code_required_guard/migration.sql new file mode 100644 index 00000000..3470aa79 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601014000_add_reference_library_code_required_guard/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/migrations/20260601015000_add_product_sku_soft_delete_identity_fields/migration.sql b/furniture-3d-system/prisma/migrations/20260601015000_add_product_sku_soft_delete_identity_fields/migration.sql new file mode 100644 index 00000000..8816577b --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601015000_add_product_sku_soft_delete_identity_fields/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/migrations/20260601016000_tighten_external_material_and_bom_contract/migration.sql b/furniture-3d-system/prisma/migrations/20260601016000_tighten_external_material_and_bom_contract/migration.sql new file mode 100644 index 00000000..129a1ee8 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601016000_tighten_external_material_and_bom_contract/migration.sql @@ -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); diff --git a/furniture-3d-system/prisma/migrations/20260601017000_align_migration_replay_to_current_schema/migration.sql b/furniture-3d-system/prisma/migrations/20260601017000_align_migration_replay_to_current_schema/migration.sql new file mode 100644 index 00000000..de067d8c --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601017000_align_migration_replay_to_current_schema/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/migrations/20260601018000_add_master_data_quality_decisions/migration.sql b/furniture-3d-system/prisma/migrations/20260601018000_add_master_data_quality_decisions/migration.sql new file mode 100644 index 00000000..12c3db34 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601018000_add_master_data_quality_decisions/migration.sql @@ -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'; diff --git a/furniture-3d-system/prisma/migrations/20260601019000_add_formal_cost_bom_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601019000_add_formal_cost_bom_guards/migration.sql new file mode 100644 index 00000000..a4263aaa --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601019000_add_formal_cost_bom_guards/migration.sql @@ -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 + ); diff --git a/furniture-3d-system/prisma/migrations/20260601020000_add_asset_center_source_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601020000_add_asset_center_source_guards/migration.sql new file mode 100644 index 00000000..6781fb62 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601020000_add_asset_center_source_guards/migration.sql @@ -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 + ); diff --git a/furniture-3d-system/prisma/migrations/20260601021000_add_draft_pre_sku_source_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601021000_add_draft_pre_sku_source_guards/migration.sql new file mode 100644 index 00000000..16c27269 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601021000_add_draft_pre_sku_source_guards/migration.sql @@ -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) + ); diff --git a/furniture-3d-system/prisma/migrations/20260601022000_add_product_sku_attributes_truth_guard/migration.sql b/furniture-3d-system/prisma/migrations/20260601022000_add_product_sku_attributes_truth_guard/migration.sql new file mode 100644 index 00000000..a666fef2 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601022000_add_product_sku_attributes_truth_guard/migration.sql @@ -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' + ) + ); diff --git a/furniture-3d-system/prisma/migrations/20260601023000_add_product_sku_asset_profile_deleted_at_trigger/migration.sql b/furniture-3d-system/prisma/migrations/20260601023000_add_product_sku_asset_profile_deleted_at_trigger/migration.sql new file mode 100644 index 00000000..8268c6a8 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601023000_add_product_sku_asset_profile_deleted_at_trigger/migration.sql @@ -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"(); diff --git a/furniture-3d-system/prisma/migrations/20260601024000_add_product_sku_soft_delete_tombstone_guard/migration.sql b/furniture-3d-system/prisma/migrations/20260601024000_add_product_sku_soft_delete_tombstone_guard/migration.sql new file mode 100644 index 00000000..9a1dbe5e --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601024000_add_product_sku_soft_delete_tombstone_guard/migration.sql @@ -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 + ) + ) + ) + ); diff --git a/furniture-3d-system/prisma/migrations/20260601025000_tighten_master_data_quality_decision_text_guards/migration.sql b/furniture-3d-system/prisma/migrations/20260601025000_tighten_master_data_quality_decision_text_guards/migration.sql new file mode 100644 index 00000000..da59831e --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260601025000_tighten_master_data_quality_decision_text_guards/migration.sql @@ -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') !~ '(待补|待确认|默认值)' + ) + ); diff --git a/furniture-3d-system/prisma/migrations/add_packaging_plan/migration.sql b/furniture-3d-system/prisma/migrations/add_packaging_plan/migration.sql index b9045457..f7327c44 100644 --- a/furniture-3d-system/prisma/migrations/add_packaging_plan/migration.sql +++ b/furniture-3d-system/prisma/migrations/add_packaging_plan/migration.sql @@ -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 $$; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index e8602721..14ec908a 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -23,7 +23,7 @@ model ProductCategory { isActive Boolean @default(true) // 是否启用 // 关联产品 - products Product[] + products Product[] planningTemplates PlanningTemplate[] @relation("PlanningTemplateCategory") planningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCategory") assetFamilies AssetFamily[] @relation("AssetFamilyCategory") @@ -113,6 +113,7 @@ model ProductSKU { // SKU标识 skuCode String @unique // SKU编码,如 "A001-RED-3S" + name String? // SKU名称 // 规格属性(灵活的JSON结构) // 示例: { "color": "红色", "size": "三人座", "material": "布艺" } @@ -138,22 +139,27 @@ model ProductSKU { // 状态 isActive Boolean @default(true) // 是否上架 + originalSkuCode String? + deletedCodeAt DateTime? + deletedByProductId String? + productDeletedAt DateTime? // SKU 级装配步骤(与产品级步骤隔离) - assemblySteps AssemblyStep[] + assemblySteps AssemblyStep[] // SKU 级板件 / 孔位 / 五金结构数据 - panels Panel[] - holes Hole[] - productHardwares ProductHardware[] - sourcePreSkuId String? @unique - sourcePreSku PreSku? @relation("PreSkuFormalizedSku", fields: [sourcePreSkuId], references: [id], onDelete: SetNull) - sourceAssetVariantId String? - sourceAssetVariant AssetVariant? @relation("ProductSKUSourceAssetVariant", fields: [sourceAssetVariantId], references: [id], onDelete: SetNull) - sourceAssetVariantIds String[] @default([]) - warehouseLocationId String? - warehouseLocation WarehouseLocation? @relation("ProductSKUWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) - packagedItems PackagedItem[] @relation("PackagedItemSku") - qualityInspections ProductSkuQualityInspection[] @relation("ProductSKUQualityInspections") + panels Panel[] + holes Hole[] + productHardwares ProductHardware[] + sourcePreSkuId String? @unique + sourcePreSku PreSku? @relation("PreSkuFormalizedSku", fields: [sourcePreSkuId], references: [id], onDelete: SetNull) + sourceAssetVariantId String? + sourceAssetVariant AssetVariant? @relation("ProductSKUSourceAssetVariant", fields: [sourceAssetVariantId], references: [id], onDelete: SetNull) + sourceAssetVariantIds String[] @default([]) + assetProfile ProductSkuAssetProfile? + warehouseLocationId String? + warehouseLocation WarehouseLocation? @relation("ProductSKUWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) + packagedItems PackagedItem[] @relation("PackagedItemSku") + qualityInspections ProductSkuQualityInspection[] @relation("ProductSKUQualityInspections") // 元数据 createdAt DateTime @default(now()) @@ -162,6 +168,7 @@ model ProductSKU { @@index([productId]) @@index([skuCode]) + @@index([name]) @@index([isActive]) @@index([ownerId]) @@index([sourceAssetVariantId]) @@ -169,6 +176,47 @@ model ProductSKU { @@index([warehouseLocationId]) } +// 正式 SKU 的资产资料结构化同步副本 +model ProductSkuAssetProfile { + id String @id @default(cuid()) + skuId String @unique + sku ProductSKU @relation(fields: [skuId], references: [id], onDelete: Cascade) + + primarySourceAssetVariantId String? + sourceAssetVariantIds String[] @default([]) + sourceAssetFamilyId String? + sourceAssetSeriesId String? + + model String? + modelName String? + specCode String? + specName String? + barcode String? + size Json + color String? + material String? + imageUrl String? + + sourceStatus ProductSkuAssetSourceStatus @default(ACTIVE) + skuDeletedAt DateTime? + syncedAt DateTime + lockedFields String[] @default([]) + lockedReasons Json + sourceVariants Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([primarySourceAssetVariantId]) + @@index([sourceAssetVariantIds], type: Gin) + @@index([sourceAssetFamilyId]) + @@index([sourceAssetSeriesId]) + @@index([specCode]) + @@index([barcode]) + @@index([sourceStatus]) + @@index([skuDeletedAt]) +} + // 产品协作者关系表 model ProductCollaborator { productId String @@ -177,9 +225,9 @@ model ProductCollaborator { addedAt DateTime @default(now()) addedBy String? - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - user User @relation("ProductCollaboratorUser", fields: [userId], references: [id], onDelete: Cascade) - addedByUser User? @relation("ProductCollaboratorAddedBy", fields: [addedBy], references: [id], onDelete: SetNull) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + user User @relation("ProductCollaboratorUser", fields: [userId], references: [id], onDelete: Cascade) + addedByUser User? @relation("ProductCollaboratorAddedBy", fields: [addedBy], references: [id], onDelete: SetNull) @@id([productId, userId]) @@index([productId]) @@ -188,23 +236,25 @@ model ProductCollaborator { } model IntakeNonIntegratedProduct { - id String @id - spu String - spuBase String - brand String - designer String - operator String - purchaser String - payload Json - visibility String @default("DEPARTMENT") // PRIVATE, TEAM, DEPARTMENT, PUBLIC - departmentId String? - teamId String? - createdBy String - updatedBy String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + id String @id + spu String + spuBase String + brand String + designer String + operator String + purchaser String + payload Json + visibility String @default("DEPARTMENT") // PRIVATE, TEAM, DEPARTMENT, PUBLIC + departmentId String? + teamId String? + createdBy String + updatedBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? approvalWorkflows ApprovalWorkflow[] + skuItems IntakeNonIntegratedSkuItem[] + profile IntakeNonIntegratedProductProfile? @@unique([spu, brand]) @@index([spu]) @@ -216,15 +266,231 @@ model IntakeNonIntegratedProduct { @@index([deletedAt, createdAt]) } +model IntakeNonIntegratedProductProfile { + id String @id @default(cuid()) + productId String @unique + product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) + productName String @default("") + productImageUrl String? @db.Text + productCategory String @default("") + salesCategory String @default("") + productYear String @default("") + productStatus String @default("") + productDevelopmentKind String @default("") + goodsType String @default("") + materialType String @default("") + productionType String @default("") + size String @default("") + color String @default("") + tagMaterial String @default("") + priceBand String @default("") + demandFlowNo String @default("") + processTemplate String @default("") + productPlanning String @default("") + brandPlanning String @default("") + demandDepartment String @default("") + spuOwner String @default("") + operationsOwner String @default("") + purchaseOwner String @default("") + visualDirector String @default("") + visualPlanning String @default("") + structureDesigner String @default("") + sourceSpu String @default("") + sourceSkuId String @default("") + sourceCategory String @default("") + sourceItemType String @default("") + sourceSalesStatus String @default("") + sourceEnabled String @default("") + sourceStockDisabled String @default("") + stockSkuCount Int @default(0) + stockHasOnSaleSku Boolean @default(false) + source String @default("PAYLOAD_SYNC") + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([productName]) + @@index([productCategory]) + @@index([salesCategory]) + @@index([productStatus]) + @@index([productDevelopmentKind]) + @@index([spuOwner]) + @@index([purchaseOwner]) + @@index([sourceSpu]) + @@index([sourceSkuId]) + @@index([sourceCategory]) + @@index([sourceItemType]) + @@index([sourceSalesStatus]) + @@index([sourceEnabled]) +} + +model IntakeNonIntegratedSkuItem { + id String @id + productId String + product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) + skuItemId String + sortOrder Int + skuSequence Int @default(0) + batchNo Int @default(1) + packageGroupId String @default("") + image String? @db.Text + imageName String @default("") + model String @default("") + skuCode String @default("") + specName String @default("") + barcode String @default("") + size String @default("") + color String @default("") + material String @default("") + boardMaterialType String @default("") + versionNo String @default("V01") + attributeCode String @default("") + drawingId String @default("") + installMethod String @default("") + executeStandard String @default("") + nationalStandardCompliant String @default("") + manufacturer String @default("") + manufacturerCode String @default("") + salesStatus String @default("") + tagMaterial String @default("") + model3dDataUrl String? @db.Text + source String @default("") + sourceTable String @default("") + sourceSkuId String @default("") + contentLocked Boolean @default(false) + dataQualityStatus IntakeNonIntegratedSkuDataQualityStatus @default(INCOMPLETE) + missingRequiredFields String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([productId, skuItemId]) + @@index([productId]) + @@index([skuCode]) + @@index([barcode]) + @@index([model]) + @@index([dataQualityStatus]) + @@index([sourceTable, sourceSkuId]) +} + +model MasterDataQualityDecision { + id String @id @default(cuid()) + targetType String + targetId String + targetBusinessKey String @default("") + status String @default("APPROVED_INCOMPLETE") + missingFields String[] @default([]) + reason String @db.Text + decidedBy String + decidedAt DateTime @default(now()) + resolvedBy String? + resolvedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([targetType, targetId]) + @@index([targetBusinessKey]) + @@index([status]) + @@index([decidedAt]) +} + +model ProductPatentRecord { + id String @id + patentGroupKey String @default("") + productId String @default("") + linkedProductIds String[] @default([]) + linkedProductNames String[] @default([]) + linkedSpus String[] @default([]) + linkedBrands String[] @default([]) + productDisplayName String @default("") + productImage String? @db.Text + productImageName String @default("") + patentStatus String @default("待确认") + patentType String @default("外观设计") + patentName String @default("") + spu String @default("") + saRequired String @default("否") + salesStatus String @default("在售") + designer String @default("") + applicationNo String @default("") + patentNo String @default("") + applicationDate String @default("") + authorizationDate String @default("") + firstFeeDate String @default("") + annualFees Json @default("{}") + annualFeeDates Json @default("{}") + nextFeeDueDate String @default("") + feeAmount String @default("") + patentCertificateFileName String @default("") + patentCertificateFileUrl String? @db.Text + patentCertificateFileMimeType String @default("") + patentCertificateStorageKey String @default("") + annualFeeNote String @default("") + continueDecision String @default("待判断") + decisionReason String @default("") + owner String @default("") + remark String @default("") + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([patentGroupKey]) + @@index([productId]) + @@index([spu]) + @@index([patentStatus]) + @@index([patentNo]) + @@index([applicationNo]) + @@index([designer]) + @@index([nextFeeDueDate]) + @@index([sortOrder]) +} + +model IntakeReferenceLibraryItem { + id String @id @default(cuid()) + libraryType String + itemKey String + code String @default("") + name String @default("") + category String @default("") + letter String @default("") + payload Json @default("{}") + sortOrder Int @default(0) + isActive Boolean @default(true) + createdBy String? + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([libraryType, itemKey]) + @@index([libraryType, isActive, sortOrder]) + @@index([libraryType, code]) + @@index([libraryType, name]) + @@index([category]) +} + +model SpuNumberCounter { + id String @id @default(cuid()) + productCategory String @unique + prefix String @unique + lastUsedIndex Int @default(0) + sourceLibraryItemId String? + createdBy String? + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([prefix]) + @@index([sourceLibraryItemId]) +} + // ============================================ // 装配相关 // ============================================ // 装配步骤(默认产品级,可选绑定到 SKU) model AssemblyStep { - id String @id @default(cuid()) + id String @id @default(cuid()) productId String - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) skuId String? sku ProductSKU? @relation(fields: [skuId], references: [id], onDelete: Cascade) @@ -287,9 +553,9 @@ model AssemblyVersion { // 板件(属于Product) model Panel { - id String @id @default(cuid()) + id String @id @default(cuid()) productId String - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) skuId String? sku ProductSKU? @relation(fields: [skuId], references: [id], onDelete: Cascade) @@ -307,7 +573,7 @@ model Panel { installOrder Int? @default(0) // 安装序号 explosionDirection String? // 爆炸方向:auto/up/down/left/right/front/back isAnchor Boolean @default(false) // 是否为基准件(不移动) - assemblySemantic Json? // 当前生效的装配语义(SKU scoped) + assemblySemantic Json? // 当前生效的装配语义(SKU scoped) // 材质关联 panelMaterial PanelMaterial? @@ -423,7 +689,7 @@ model PlanningWorkspace { category ProductCategory? @relation("PlanningWorkspaceCategory", fields: [categoryId], references: [id]) createdBy String - creator User @relation("PlanningWorkspaceCreator", fields: [createdBy], references: [id]) + creator User @relation("PlanningWorkspaceCreator", fields: [createdBy], references: [id]) departmentId String? department Department? @relation("PlanningWorkspaceDepartment", fields: [departmentId], references: [id]) @@ -445,7 +711,7 @@ model PlanningWorkspace { } model PlanningDraft { - id String @id @default(cuid()) + id String @id @default(cuid()) workspaceId String workspace PlanningWorkspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@ -459,7 +725,7 @@ model PlanningDraft { sourceSnapshot Json? createdBy String - creator User @relation("PlanningDraftCreator", fields: [createdBy], references: [id]) + creator User @relation("PlanningDraftCreator", fields: [createdBy], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -469,33 +735,34 @@ model PlanningDraft { } model Substrate { - id String @id @default(cuid()) - code String @unique - name String - description String? @db.Text - categoryId String - category SubstrateCategory @relation(fields: [categoryId], references: [id]) + id String @id @default(cuid()) + code String @unique + name String + description String? @db.Text + categoryId String + category SubstrateCategory @relation(fields: [categoryId], references: [id]) environmentGradeId String - environmentGrade SubstrateEnvironmentGrade @relation(fields: [environmentGradeId], references: [id]) + environmentGrade SubstrateEnvironmentGrade @relation(fields: [environmentGradeId], references: [id]) thicknessSpecId String - thicknessSpec SubstrateThicknessSpec @relation(fields: [thicknessSpecId], references: [id]) + thicknessSpec SubstrateThicknessSpec @relation(fields: [thicknessSpecId], references: [id]) surfaceOptionId String? - surfaceOption SubstrateSurfaceOption? @relation(fields: [surfaceOptionId], references: [id]) + surfaceOption SubstrateSurfaceOption? @relation(fields: [surfaceOptionId], references: [id]) systemNameOptionId String? systemNameOption SubstrateSystemNameOption? @relation(fields: [systemNameOptionId], references: [id]) - suggestedPrice Decimal? @db.Decimal(10, 2) - weightKg Float? - metadata Json? - isActive Boolean @default(true) + suggestedPrice Decimal? @db.Decimal(10, 2) + weightKg Float? + metadata Json? + isActive Boolean @default(true) createdBy String? - creator User? @relation("SubstrateCreator", fields: [createdBy], references: [id], onDelete: SetNull) + creator User? @relation("SubstrateCreator", fields: [createdBy], references: [id], onDelete: SetNull) assetVariants AssetVariant[] - finishes SubstrateFinish[] + finishes SubstrateFinish[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // 4字段规格由服务层收口:相同规格+相同花色拒绝重复,相同规格+不同花色并入已有基材 @@index([isActive]) @@index([createdBy]) @@ -504,15 +771,14 @@ model Substrate { @@index([thicknessSpecId]) @@index([surfaceOptionId]) @@index([systemNameOptionId]) - // 4字段规格由服务层收口:相同规格+相同花色拒绝重复,相同规格+不同花色并入已有基材 } model SubstrateFinish { - id String @id @default(cuid()) + id String @id @default(cuid()) substrateId String substrate Substrate @relation(fields: [substrateId], references: [id], onDelete: Cascade) finishId String - finish Material @relation(fields: [finishId], references: [id], onDelete: Cascade) + finish Material @relation(fields: [finishId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@ -522,9 +788,9 @@ model SubstrateFinish { } model SubstrateCategory { - id String @id @default(cuid()) - name String @unique - sortOrder Int @default(0) + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -537,9 +803,9 @@ model SubstrateCategory { } model SubstrateEnvironmentGrade { - id String @id @default(cuid()) - name String @unique - sortOrder Int @default(0) + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -552,12 +818,12 @@ model SubstrateEnvironmentGrade { } model SubstrateThicknessSpec { - id String @id @default(cuid()) - name String @unique + id String @id @default(cuid()) + name String @unique thicknessMm Float - sheetLengthMm Float @default(2440) - sheetWidthMm Float @default(1220) - sortOrder Int @default(0) + sheetLengthMm Float @default(2440) + sheetWidthMm Float @default(1220) + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -570,9 +836,9 @@ model SubstrateThicknessSpec { } model SubstrateSurfaceOption { - id String @id @default(cuid()) - name String @unique - sortOrder Int @default(0) + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -585,9 +851,9 @@ model SubstrateSurfaceOption { } model SubstrateSystemNameOption { - id String @id @default(cuid()) - name String @unique - sortOrder Int @default(0) + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) isActive Boolean @default(true) substrates Substrate[] @@ -600,38 +866,38 @@ model SubstrateSystemNameOption { } model AssetFamily { - id String @id @default(cuid()) - type AssetType - code String @unique - name String - description String? @db.Text + id String @id @default(cuid()) + type AssetType + code String @unique + name String + description String? @db.Text productCategoryId String? productCategory ProductCategory? @relation("AssetFamilyCategory", fields: [productCategoryId], references: [id]) assetSeriesId String? - assetSeries AssetSeries? @relation("AssetFamilySeries", fields: [assetSeriesId], references: [id], onDelete: SetNull) - connectionRoles String[] @default([]) - metadata Json? - status AssetStatus @default(DRAFT) + assetSeries AssetSeries? @relation("AssetFamilySeries", fields: [assetSeriesId], references: [id], onDelete: SetNull) + connectionRoles String[] @default([]) + metadata Json? + status AssetStatus @default(DRAFT) - createdBy String? - creator User? @relation("AssetFamilyCreator", fields: [createdBy], references: [id], onDelete: SetNull) - submittedAt DateTime? - submittedById String? - submitter User? @relation("AssetFamilySubmitter", fields: [submittedById], references: [id], onDelete: SetNull) - reviewedAt DateTime? - reviewedById String? - reviewer User? @relation("AssetFamilyReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) - rejectedAt DateTime? - rejectedById String? - rejecter User? @relation("AssetFamilyRejecter", fields: [rejectedById], references: [id], onDelete: SetNull) - rejectionReason String? @db.Text + createdBy String? + creator User? @relation("AssetFamilyCreator", fields: [createdBy], references: [id], onDelete: SetNull) + submittedAt DateTime? + submittedById String? + submitter User? @relation("AssetFamilySubmitter", fields: [submittedById], references: [id], onDelete: SetNull) + reviewedAt DateTime? + reviewedById String? + reviewer User? @relation("AssetFamilyReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) + rejectedAt DateTime? + rejectedById String? + rejecter User? @relation("AssetFamilyRejecter", fields: [rejectedById], references: [id], onDelete: SetNull) + rejectionReason String? @db.Text lastModifiedById String? - lastModifier User? @relation("AssetFamilyLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull) - deletedAt DateTime? - deletedById String? - deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull) + lastModifier User? @relation("AssetFamilyLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull) + deletedAt DateTime? + deletedById String? + deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull) - variants AssetVariant[] + variants AssetVariant[] compatibleSeries AssetFamilySeriesCompatibility[] createdAt DateTime @default(now()) @@ -664,10 +930,10 @@ model AssetSeries { metadata Json? createdBy String? - creator User? @relation("AssetSeriesCreator", fields: [createdBy], references: [id], onDelete: SetNull) + creator User? @relation("AssetSeriesCreator", fields: [createdBy], references: [id], onDelete: SetNull) compatibleFamilies AssetFamilySeriesCompatibility[] - assetFamilies AssetFamily[] @relation("AssetFamilySeries") + assetFamilies AssetFamily[] @relation("AssetFamilySeries") draftGroups DraftGroup[] createdAt DateTime @default(now()) @@ -680,7 +946,7 @@ model AssetSeries { } model AssetFamilySeriesCompatibility { - id String @id @default(cuid()) + id String @id @default(cuid()) assetFamilyId String assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) assetSeriesId String @@ -694,60 +960,60 @@ model AssetFamilySeriesCompatibility { } model AssetVariant { - id String @id @default(cuid()) - assetFamilyId String - assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) - code String @unique - name String - identityKey String - assetType AssetType - status AssetStatus @default(DRAFT) - sizeLabel String? - substrateId String? - substrate Substrate? @relation(fields: [substrateId], references: [id], onDelete: SetNull) - finishId String? - finish Material? @relation("AssetVariantFinish", fields: [finishId], references: [id], onDelete: SetNull) - dimensions Json - modelUrl String - thumbnailUrl String? - barcode String? - supplierName String? - supplierCode String? - versionNo String? - mountPoints Json - assemblyBasis Json? + id String @id @default(cuid()) + assetFamilyId String + assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) + code String @unique + name String + identityKey String + assetType AssetType + status AssetStatus @default(DRAFT) + sizeLabel String? + substrateId String? + substrate Substrate? @relation(fields: [substrateId], references: [id], onDelete: SetNull) + finishId String? + finish Material? @relation("AssetVariantFinish", fields: [finishId], references: [id], onDelete: SetNull) + dimensions Json + modelUrl String + thumbnailUrl String? + barcode String? + supplierName String? + supplierCode String? + versionNo String? + mountPoints Json + assemblyBasis Json? compatibilityRules Json? - namingFields Json? - businessData Json? - weightKg Float? - costPrice Decimal? @db.Decimal(10, 2) - lossFactor Float? - estimatedCostPrice Decimal? @db.Decimal(10, 2) + namingFields Json? + businessData Json? + weightKg Float? + costPrice Decimal? @db.Decimal(10, 2) + lossFactor Float? + estimatedCostPrice Decimal? @db.Decimal(10, 2) inheritedFromVariantId String? - inheritedFromVariant AssetVariant? @relation("AssetVariantInheritance", fields: [inheritedFromVariantId], references: [id], onDelete: SetNull) - derivedVariants AssetVariant[] @relation("AssetVariantInheritance") + inheritedFromVariant AssetVariant? @relation("AssetVariantInheritance", fields: [inheritedFromVariantId], references: [id], onDelete: SetNull) + derivedVariants AssetVariant[] @relation("AssetVariantInheritance") locationBindings AssetVariantLocationBinding[] - sourceProductSkus ProductSKU[] @relation("ProductSKUSourceAssetVariant") + sourceProductSkus ProductSKU[] @relation("ProductSKUSourceAssetVariant") skuSyncEvents AssetSkuSyncEvent[] - createdBy String? - creator User? @relation("AssetVariantCreator", fields: [createdBy], references: [id], onDelete: SetNull) - submittedAt DateTime? - submittedById String? - submitter User? @relation("AssetVariantSubmitter", fields: [submittedById], references: [id], onDelete: SetNull) - reviewedAt DateTime? - reviewedById String? - reviewer User? @relation("AssetVariantReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) - rejectedAt DateTime? - rejectedById String? - rejecter User? @relation("AssetVariantRejecter", fields: [rejectedById], references: [id], onDelete: SetNull) - rejectionReason String? @db.Text + createdBy String? + creator User? @relation("AssetVariantCreator", fields: [createdBy], references: [id], onDelete: SetNull) + submittedAt DateTime? + submittedById String? + submitter User? @relation("AssetVariantSubmitter", fields: [submittedById], references: [id], onDelete: SetNull) + reviewedAt DateTime? + reviewedById String? + reviewer User? @relation("AssetVariantReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) + rejectedAt DateTime? + rejectedById String? + rejecter User? @relation("AssetVariantRejecter", fields: [rejectedById], references: [id], onDelete: SetNull) + rejectionReason String? @db.Text lastModifiedById String? - lastModifier User? @relation("AssetVariantLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull) - deletedAt DateTime? - deletedById String? - deleter User? @relation("AssetVariantDeleter", fields: [deletedById], references: [id], onDelete: SetNull) + lastModifier User? @relation("AssetVariantLastModifier", fields: [lastModifiedById], references: [id], onDelete: SetNull) + deletedAt DateTime? + deletedById String? + deleter User? @relation("AssetVariantDeleter", fields: [deletedById], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -806,10 +1072,10 @@ model DraftGroup { status DraftGroupStatus @default(DRAFT) summary Json - notes String? @db.Text + notes String? @db.Text createdBy String - creator User @relation("DraftGroupCreator", fields: [createdBy], references: [id]) + creator User @relation("DraftGroupCreator", fields: [createdBy], references: [id]) preSkus PreSku[] @@ -833,7 +1099,7 @@ model AssistedAssemblyAiRender { params Json referenceImageUrl String? - resultImages Json @default("[]") + resultImages Json @default("[]") workspaceSnapshot Json assetSnapshot Json @@ -841,7 +1107,7 @@ model AssistedAssemblyAiRender { externalRequestId String? attempts Json? status AssistedAssemblyAiRenderStatus @default(PENDING) - errorMessage String? @db.Text + errorMessage String? @db.Text durationMs Int? createdAt DateTime @default(now()) @@ -852,30 +1118,31 @@ model AssistedAssemblyAiRender { } model PreSku { - id String @id @default(cuid()) + id String @id @default(cuid()) draftGroupId String draftGroup DraftGroup @relation(fields: [draftGroupId], references: [id], onDelete: Cascade) - sequence Int - defaultName String - customName String? - status PreSkuStatus @default(DRAFT) - sourceCandidateKey String - familySelection Json - variantSelection Json + sequence Int + defaultName String + customName String? + status PreSkuStatus @default(DRAFT) + sourceCandidateKey String + familySelection Json + variantSelection Json recommendedHardware Json? - previewNodes Json - displaySummary Json - bomPreview Json - weightPreview Float? - componentCodes String[] - explosionPreview Json? - installStepPreview Json - packagingSnapshot Json? - notes String? @db.Text + previewNodes Json + displaySummary Json + bomPreview Json + weightPreview Float? + componentCodes String[] + explosionPreview Json? + installStepPreview Json + packagingSnapshot Json? + notes String? @db.Text - packagingPlan PreSkuPackagingPlan? - formalizedSku ProductSKU? @relation("PreSkuFormalizedSku") + basicInfo PreSkuBasicInfo? + packagingPlan PreSkuPackagingPlan? + formalizedSku ProductSKU? @relation("PreSkuFormalizedSku") locationBindings PreSkuLocationBinding[] createdAt DateTime @default(now()) @@ -886,18 +1153,50 @@ model PreSku { @@index([status]) } -model PreSkuPackagingPlan { - id String @id @default(cuid()) +model PreSkuBasicInfo { + id String @id @default(cuid()) preSkuId String @unique preSku PreSku @relation(fields: [preSkuId], references: [id], onDelete: Cascade) - sourceType PackagingSourceType @default(PRE_SKU) - sourceRefId String? - status String @default("DRAFT") - boxes Json @default("[]") - costSummary Json? - processDraft Json? - notes String? @db.Text + model String? + formalSkuCode String? + barcode String? + size String? + color String? + material String? + specName String? + versionNo String? + setCount String? + attributeCode String? + drawingId String? + installMethod String? + + manualUpdatedBy String? + manualUpdatedByName String? + manualUpdatedAt DateTime? + source String @default("DRAFT_GROUP") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([model]) + @@index([formalSkuCode]) + @@index([barcode]) + @@index([specName, versionNo]) +} + +model PreSkuPackagingPlan { + id String @id @default(cuid()) + preSkuId String @unique + preSku PreSku @relation(fields: [preSkuId], references: [id], onDelete: Cascade) + + sourceType PackagingSourceType @default(PRE_SKU) + sourceRefId String? + status String @default("DRAFT") + boxes Json @default("[]") + costSummary Json? + processDraft Json? + notes String? @db.Text packagedItems PackagedItem[] @@ -908,21 +1207,21 @@ model PreSkuPackagingPlan { } model WarehouseLocation { - id String @id @default(cuid()) - code String @unique - name String - zone String? - aisle String? - shelf String? - bin String? - notes String? @db.Text + id String @id @default(cuid()) + code String @unique + name String + zone String? + aisle String? + shelf String? + bin String? + notes String? @db.Text isActive Boolean @default(true) - skus ProductSKU[] @relation("ProductSKUWarehouseLocation") - packagedItems PackagedItem[] @relation("PackagedItemWarehouseLocation") - assetVariantBindings AssetVariantLocationBinding[] + skus ProductSKU[] @relation("ProductSKUWarehouseLocation") + packagedItems PackagedItem[] @relation("PackagedItemWarehouseLocation") + assetVariantBindings AssetVariantLocationBinding[] brandMaterialBindings BrandMaterialBinding[] - preSkuBindings PreSkuLocationBinding[] + preSkuBindings PreSkuLocationBinding[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -931,7 +1230,7 @@ model WarehouseLocation { } model AssetVariantLocationBinding { - id String @id @default(cuid()) + id String @id @default(cuid()) assetVariantId String warehouseLocationId String @@ -946,7 +1245,7 @@ model AssetVariantLocationBinding { } model BrandMaterialBinding { - id String @id @default(cuid()) + id String @id @default(cuid()) brand String materialType String warehouseLocationId String? @@ -954,8 +1253,8 @@ model BrandMaterialBinding { fileUrl String? fileName String? fileSize Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([brand, materialType]) @@index([brand]) @@ -963,7 +1262,7 @@ model BrandMaterialBinding { } model PreSkuLocationBinding { - id String @id @default(cuid()) + id String @id @default(cuid()) preSkuId String warehouseLocationId String @@ -978,23 +1277,23 @@ model PreSkuLocationBinding { } model PackagedItem { - id String @id @default(cuid()) - code String @unique - name String + id String @id @default(cuid()) + code String @unique + name String preSkuPackagingPlanId String? preSkuPackagingPlan PreSkuPackagingPlan? @relation(fields: [preSkuPackagingPlanId], references: [id], onDelete: SetNull) - skuId String? - sku ProductSKU? @relation("PackagedItemSku", fields: [skuId], references: [id], onDelete: SetNull) - warehouseLocationId String? - warehouseLocation WarehouseLocation? @relation("PackagedItemWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) - quantityPerPack Int @default(1) - stockQuantity Int @default(0) - length Float? - width Float? - height Float? - weight Float? - payload Json? - status String @default("DRAFT") + skuId String? + sku ProductSKU? @relation("PackagedItemSku", fields: [skuId], references: [id], onDelete: SetNull) + warehouseLocationId String? + warehouseLocation WarehouseLocation? @relation("PackagedItemWarehouseLocation", fields: [warehouseLocationId], references: [id], onDelete: SetNull) + quantityPerPack Int @default(1) + stockQuantity Int @default(0) + length Float? + width Float? + height Float? + weight Float? + payload Json? + status String @default("DRAFT") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1125,6 +1424,17 @@ enum AssetSkuSyncEventStatus { FAILED } +enum ProductSkuAssetSourceStatus { + ACTIVE + DELETED + MISSING +} + +enum IntakeNonIntegratedSkuDataQualityStatus { + READY + INCOMPLETE +} + enum PackagingSourceType { PRODUCT PRE_SKU @@ -1136,14 +1446,14 @@ model User { id String @id @default(cuid()) // 登录凭证 - email String @unique // 邮箱(用于登录) - name String @unique // 姓名/花名(用于登录和显示) - password String - loginSource String @default("LOCAL") - dingtalkUserId String? @unique - dingtalkUnionId String? @unique - dingtalkBoundAt DateTime? - employeeDirectoryId String? @unique + email String @unique // 邮箱(用于登录) + name String @unique // 姓名/花名(用于登录和显示) + password String + loginSource String @default("LOCAL") + dingtalkUserId String? @unique + dingtalkUnionId String? @unique + dingtalkBoundAt DateTime? + employeeDirectoryId String? @unique employeeDirectory EmployeeDirectory? @relation(fields: [employeeDirectoryId], references: [id], onDelete: SetNull) // 首次登录标识 @@ -1154,7 +1464,7 @@ model User { phone String? // 角色与权限 - role Role @default(USER) + role Role @default(USER) businessRoles BusinessAccountType[] @default([]) // 组织架构 @@ -1172,33 +1482,33 @@ model User { status UserStatus @default(PENDING) // 关联 - createdProducts Product[] @relation("ProductCreator") - ownedSKUs ProductSKU[] @relation("SKUOwner") - createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator") - createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator") - createdPlanningDrafts PlanningDraft[] @relation("PlanningDraftCreator") - createdSubstrates Substrate[] @relation("SubstrateCreator") - createdAssetFamilies AssetFamily[] @relation("AssetFamilyCreator") - submittedAssetFamilies AssetFamily[] @relation("AssetFamilySubmitter") - reviewedAssetFamilies AssetFamily[] @relation("AssetFamilyReviewer") - rejectedAssetFamilies AssetFamily[] @relation("AssetFamilyRejecter") - modifiedAssetFamilies AssetFamily[] @relation("AssetFamilyLastModifier") - deletedAssetFamilies AssetFamily[] @relation("AssetFamilyDeleter") - createdAssetVariants AssetVariant[] @relation("AssetVariantCreator") - submittedAssetVariants AssetVariant[] @relation("AssetVariantSubmitter") - reviewedAssetVariants AssetVariant[] @relation("AssetVariantReviewer") - rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter") - modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier") - deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter") - createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator") - createdDraftGroups DraftGroup[] @relation("DraftGroupCreator") - assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator") - collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser") - addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy") - feedbacks Feedback[] @relation("FeedbackSubmitter") - assignedFeedbacks Feedback[] @relation("FeedbackAssignee") - createdApprovalWorkflows ApprovalWorkflow[] @relation("ApprovalWorkflowCreator") - approvalWorkflowChanges ApprovalWorkflowFieldChange[] + createdProducts Product[] @relation("ProductCreator") + ownedSKUs ProductSKU[] @relation("SKUOwner") + createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator") + createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator") + createdPlanningDrafts PlanningDraft[] @relation("PlanningDraftCreator") + createdSubstrates Substrate[] @relation("SubstrateCreator") + createdAssetFamilies AssetFamily[] @relation("AssetFamilyCreator") + submittedAssetFamilies AssetFamily[] @relation("AssetFamilySubmitter") + reviewedAssetFamilies AssetFamily[] @relation("AssetFamilyReviewer") + rejectedAssetFamilies AssetFamily[] @relation("AssetFamilyRejecter") + modifiedAssetFamilies AssetFamily[] @relation("AssetFamilyLastModifier") + deletedAssetFamilies AssetFamily[] @relation("AssetFamilyDeleter") + createdAssetVariants AssetVariant[] @relation("AssetVariantCreator") + submittedAssetVariants AssetVariant[] @relation("AssetVariantSubmitter") + reviewedAssetVariants AssetVariant[] @relation("AssetVariantReviewer") + rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter") + modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier") + deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter") + createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator") + createdDraftGroups DraftGroup[] @relation("DraftGroupCreator") + assistedAssemblyAiRenders AssistedAssemblyAiRender[] @relation("AssistedAssemblyAiRenderCreator") + collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser") + addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy") + feedbacks Feedback[] @relation("FeedbackSubmitter") + assignedFeedbacks Feedback[] @relation("FeedbackAssignee") + createdApprovalWorkflows ApprovalWorkflow[] @relation("ApprovalWorkflowCreator") + approvalWorkflowChanges ApprovalWorkflowFieldChange[] // 操作日志 activityLogs ActivityLog[] @@ -1219,12 +1529,12 @@ model User { model EmployeeLookupCache { id String @id @default(cuid()) - cacheKey String @unique - keyType String - lookupValue String - result Json? + cacheKey String @unique + keyType String + lookupValue String + result Json? sourceStatus EmployeeSourceStatus? - expiresAt DateTime + expiresAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1237,11 +1547,11 @@ model EmployeeLookupCache { model Department { id String @id @default(cuid()) - name String @unique - fullPath String // 完整路径,如 "设计部-3D组-建模小组" + name String @unique + fullPath String // 完整路径,如 "设计部-3D组-建模小组" description String? - externalId String? @unique - syncedAt DateTime? + externalId String? @unique + syncedAt DateTime? rawSnapshot Json? // 层级关系 @@ -1260,12 +1570,12 @@ model Department { teams Team[] // 产品 - products Product[] + products Product[] planningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceDepartment") // 排序 - orderIndex Int @default(0) - isActive Boolean @default(true) + orderIndex Int @default(0) + isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1293,8 +1603,8 @@ model Team { members User[] @relation("TeamMembers") // 排序 - orderIndex Int @default(0) - isActive Boolean @default(true) + orderIndex Int @default(0) + isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1306,20 +1616,20 @@ model Team { model ApprovalWorkflow { id String @id @default(cuid()) - approvalNumber String @unique - title String - status ApprovalWorkflowStatus @default(DRAFT) - productId String - product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) - accessKey String @unique - businessRoles BusinessAccountType[] @default([]) - participantUserIds String[] @default([]) - initiatorId String - initiator User @relation("ApprovalWorkflowCreator", fields: [initiatorId], references: [id]) - lastSyncedAt DateTime? - fieldMatrix Json? - summary Json? - metadata Json? + approvalNumber String @unique + title String + status ApprovalWorkflowStatus @default(DRAFT) + productId String + product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade) + accessKey String @unique + businessRoles BusinessAccountType[] @default([]) + participantUserIds String[] @default([]) + initiatorId String + initiator User @relation("ApprovalWorkflowCreator", fields: [initiatorId], references: [id]) + lastSyncedAt DateTime? + fieldMatrix Json? + summary Json? + metadata Json? fieldChanges ApprovalWorkflowFieldChange[] @@ -1334,17 +1644,17 @@ model ApprovalWorkflow { model ApprovalWorkflowFieldChange { id String @id @default(cuid()) - workflowId String - workflow ApprovalWorkflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - fieldKey String - oldValue Json? - newValue Json? - source String - sourcePage String? - fromDingtalkPage Boolean @default(false) - metadata Json? + workflowId String + workflow ApprovalWorkflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + fieldKey String + oldValue Json? + newValue Json? + source String + sourcePage String? + fromDingtalkPage Boolean @default(false) + metadata Json? createdAt DateTime @default(now()) @@ -1356,18 +1666,18 @@ model ApprovalWorkflowFieldChange { model EmployeeDirectory { id String @id @default(cuid()) - dingtalkUserId String? @unique - dingtalkUnionId String? @unique - jobNumber String? - name String - mobile String? - email String? - title String? - sourceStatus EmployeeSourceStatus @default(EMPLOYED) + dingtalkUserId String? @unique + dingtalkUnionId String? @unique + jobNumber String? + name String + mobile String? + email String? + title String? + sourceStatus EmployeeSourceStatus @default(EMPLOYED) primaryDepartmentExternalId String? - departmentExternalIds Json? - syncedAt DateTime @default(now()) - rawSnapshot Json? + departmentExternalIds Json? + syncedAt DateTime @default(now()) + rawSnapshot Json? user User? @@ -1535,7 +1845,7 @@ model Material { // 基本信息 name String // 材质名称,如 "橡木纹-自然色" - code String @unique // 花色编码 + code String @unique // 花色编码 categoryId String // 分类 ID category MaterialCategory @relation(fields: [categoryId], references: [id]) @@ -1565,8 +1875,8 @@ model Material { createdBy String? // 创建者 ID // 关联板件材质 - panelMaterials PanelMaterial[] - assetVariants AssetVariant[] @relation("AssetVariantFinish") + panelMaterials PanelMaterial[] + assetVariants AssetVariant[] @relation("AssetVariantFinish") substrateFinishes SubstrateFinish[] createdAt DateTime @default(now()) @@ -1639,10 +1949,10 @@ model ThemeConfig { model IntakeNodePermissionOverride { id String @id @default(cuid()) - targetKey String + targetKey String subjectKey String - action String - enabled Boolean @default(true) + action String + enabled Boolean @default(true) createdBy String? updatedBy String? @@ -1717,7 +2027,7 @@ model Hardware { // ===== 组合件相关 ===== // 是否为组合件主件(如把手、铰链等需要配套螺丝的五金件) isComposite Boolean @default(false) - + // 配件定义(仅当 isComposite=true 时有效) // 示例: [{ hardwareCode: "M4x25", quantity: 2, relativePositionTolerance: 50 }] // hardwareCode: 配件的五金件编码 @@ -1764,7 +2074,7 @@ model ProductHardware { // 关联产品 productId String - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) skuId String? sku ProductSKU? @relation(fields: [skuId], references: [id], onDelete: Cascade) @@ -1878,9 +2188,9 @@ model CommonComponent { modelUrl String thumbnailUrl String? - dimensions Json? + dimensions Json? specifications Json? - mountPoints Json? + mountPoints Json? isPreset Boolean @default(false) isActive Boolean @default(true) @@ -1906,7 +2216,7 @@ model Hole { // 关联板件 panelId String - panel Panel @relation(fields: [panelId], references: [id], onDelete: Cascade) + panel Panel @relation(fields: [panelId], references: [id], onDelete: Cascade) skuId String? sku ProductSKU? @relation(fields: [skuId], references: [id], onDelete: Cascade) @@ -1968,12 +2278,12 @@ model SystemConfig { model ProviderConfig { id String @id @default(cuid()) - serviceType String // ai-render | prompt-optimizer - name String // 用户自定义名称,如 "生产环境"、"测试 Key" + serviceType String // ai-render | prompt-optimizer + name String // 用户自定义名称,如 "生产环境"、"测试 Key" baseUrl String apiKey String model String? - extra Json? // 扩展字段 + extra Json? // 扩展字段 isActive Boolean @default(false) sortOrder Int @default(0) createdAt DateTime @default(now()) diff --git a/furniture-3d-system/prisma/seed.ts b/furniture-3d-system/prisma/seed.ts index 0015554e..1109d25f 100644 --- a/furniture-3d-system/prisma/seed.ts +++ b/furniture-3d-system/prisma/seed.ts @@ -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(); }); diff --git a/furniture-3d-system/scripts/add-more-skus.ts b/furniture-3d-system/scripts/add-more-skus.ts index 5cace852..eefeaaa4 100644 --- a/furniture-3d-system/scripts/add-more-skus.ts +++ b/furniture-3d-system/scripts/add-more-skus.ts @@ -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() + }) diff --git a/furniture-3d-system/scripts/apply-master-data-cleanup-plan.js b/furniture-3d-system/scripts/apply-master-data-cleanup-plan.js new file mode 100644 index 00000000..28cb0c84 --- /dev/null +++ b/furniture-3d-system/scripts/apply-master-data-cleanup-plan.js @@ -0,0 +1,1747 @@ +const fs = require('node:fs') +const crypto = require('node:crypto') +const path = require('node:path') +const { Pool } = require('pg') +const { + assertNoMasterDataTruthDrift, + buildReport, + getMigrationApplicationStatus, + getPreSkuFormalizationLifecycleMismatchSamples, + getRequiredMigrationReadiness, +} = require('./verify-master-data-foundation') +const { + REQUIRED_MIGRATIONS, + applyMissingMigrationFiles, + classifyAppliedMigrationPaths, +} = require('./master-data-required-migrations') + +const ROOT_DIR = path.resolve(__dirname, '..') +const APPLY_CONFIRMATION = 'I_UNDERSTAND_THIS_UPDATES_MASTER_DATA' +const CLEANUP_ACTOR_FALLBACK = 'master_data_cleanup' +const QUALITY_DECISION_STATUS_APPROVED_INCOMPLETE = 'APPROVED_INCOMPLETE' +const QUALITY_DECISION_STATUS_RESOLVED = 'RESOLVED' +const QUALITY_DECISION_TARGET_SKU_ITEM = 'IntakeNonIntegratedSkuItem' +const QUALITY_DECISION_TARGET_PRODUCT_PROFILE = 'IntakeNonIntegratedProductProfile' + +const SKU_ITEM_FILL_KEYS = ['skuCode', 'specName', 'model', 'barcode'] +const REQUIRED_SKU_ITEM_KEYS = ['skuCode', 'specName', 'model'] +const SKU_ITEM_NORMALIZED_CODE_KEYS = ['skuCode', 'barcode'] +const SKU_ITEM_CURRENT_EVIDENCE_KEYS = ['skuCode', 'specName', 'model', 'barcode', 'missingRequiredFields'] +const SKU_ITEM_CONTEXT_EVIDENCE_KEYS = ['productId', 'spu', 'skuItemId'] +const PRODUCT_PROFILE_FILL_KEYS = ['productName', 'productCategory', 'productDevelopmentKind'] +const REQUIRED_PRODUCT_PROFILE_KEYS = ['productName', 'productCategory', 'productDevelopmentKind'] +const PRODUCT_PROFILE_CURRENT_EVIDENCE_KEYS = [ + 'productName', + 'productCategory', + 'productDevelopmentKind', + 'missingFields', +] +const PRODUCT_PROFILE_CONTEXT_EVIDENCE_KEYS = ['spu'] +const QUALITY_DECISION_REPAIR_FILL_KEYS = ['reason', 'decidedBy', 'resolvedBy'] +const QUALITY_DECISION_REPAIR_CURRENT_EVIDENCE_KEYS = [ + 'reason', + 'decidedBy', + 'resolvedBy', + 'status', + 'placeholderFields', +] +const QUALITY_DECISION_REPAIR_CONTEXT_EVIDENCE_KEYS = ['targetType', 'targetId'] +const CLEANUP_PLAN_TOP_LEVEL_KEYS = new Set([ + 'mode', + 'appliedMigrations', + 'migrationReplay', + 'migrationApplicationStatus', + 'currentSummary', + 'rolledBack', + 'outputFile', + 'confirmation', + 'confirmedBy', + 'skuItems', + 'productProfiles', + 'qualityDecisionRepairs', + 'preSkuFormalizationMismatches', +]) +const DISALLOWED_CLEANUP_FILL_VALUES = new Set([ + 'unknown', + 'unk', + 'n/a', + 'na', + 'null', + 'none', + 'todo', + 'tbd', + 'default', + 'test', + '测试', + '待补', + '待补齐', + '待确认', + '未知', + '默认', + '默认值', + '暂无', +]) + +function loadDatabaseUrl() { + if (process.env.DATABASE_URL) { + return process.env.DATABASE_URL + } + + const envPath = path.join(ROOT_DIR, '.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 + + const key = trimmed.slice(0, separatorIndex).trim() + if (key !== '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 cleanupDatabaseIdentity(databaseUrl) { + 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 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 assertApplyExecutionEvidence(args, options = {}) { + if (!args.apply) { + return { actualDatabaseIdentity: '', backupFingerprint: null } + } + const actualDatabaseIdentity = cleanupDatabaseIdentity(options.databaseUrl) + const backupFingerprint = getBackupFingerprint(options.env, options.rootDir) + const blockers = [] + + 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 && args.expectedBackupSha256 !== 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 parseArgs(argv) { + const args = { + apply: false, + template: false, + planPath: '', + outputPath: '', + validatePlan: false, + allowMigrationDryRun: false, + 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 === '--validate-plan') { + args.validatePlan = true + continue + } + if (arg === '--allow-migration-dry-run') { + args.allowMigrationDryRun = true + continue + } + if (arg === '--expected-database-identity') { + args.expectedDatabaseIdentity = argv[index + 1] ?? '' + if (!args.expectedDatabaseIdentity || args.expectedDatabaseIdentity.startsWith('--')) { + throw new Error('缺少 --expected-database-identity 。') + } + index += 1 + continue + } + if (arg.startsWith('--expected-database-identity=')) { + args.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length) + continue + } + if (arg === '--expected-backup-sha256') { + args.expectedBackupSha256 = argv[index + 1] ?? '' + if (!args.expectedBackupSha256 || args.expectedBackupSha256.startsWith('--')) { + throw new Error('缺少 --expected-backup-sha256 。') + } + index += 1 + continue + } + if (arg.startsWith('--expected-backup-sha256=')) { + args.expectedBackupSha256 = arg.slice('--expected-backup-sha256='.length) + continue + } + if (arg === '--plan') { + args.planPath = argv[index + 1] ?? '' + if (!args.planPath || args.planPath.startsWith('--')) { + throw new Error('缺少 --plan 。') + } + index += 1 + continue + } + if (arg === '--output') { + args.outputPath = argv[index + 1] ?? '' + if (!args.outputPath || args.outputPath.startsWith('--')) { + throw new Error('缺少 --output 。') + } + index += 1 + continue + } + throw new Error(`未知参数:${arg}`) + } + if (args.template && args.apply) { + throw new Error('--template 只允许生成清洗模板,不能和 --apply 同时使用。') + } + if (args.template && args.validatePlan) { + throw new Error('--template 只允许生成清洗模板,不能和 --validate-plan 同时使用。') + } + if (args.apply && args.validatePlan) { + throw new Error('--apply 会执行数据库 dry-run 或落库,不能和只读 --validate-plan 同时使用。') + } + if (args.apply && args.allowMigrationDryRun) { + throw new Error('--allow-migration-dry-run 只允许模板或 dry-run 调试,不能和 --apply 同时使用。') + } + if (args.template && args.planPath) { + throw new Error('--template 只允许生成清洗模板,不能和 --plan 同时使用。') + } + if (args.outputPath && !args.template && !args.validatePlan) { + throw new Error('--output 只允许和 --template 或 --validate-plan 一起使用。') + } + if (args.apply && !args.planPath) { + throw new Error('--apply 必须同时提供 --plan 。') + } + if (args.validatePlan && !args.planPath) { + throw new Error('--validate-plan 必须同时提供 --plan 。') + } + + return args +} + +function writeJsonReport(outputPath, payload) { + const absolutePath = path.resolve(process.cwd(), outputPath) + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }) + fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + return absolutePath +} + +function assertNoApplyWithPendingMigrations( + args, + appliedMigrations, + migrationReplay = classifyAppliedMigrationPaths(appliedMigrations), +) { + const migrationApplicationStatus = getMigrationApplicationStatus( + appliedMigrations, + migrationReplay, + ) + if (!args.apply || migrationApplicationStatus.appliedMigrationCount === 0) { + return migrationApplicationStatus + } + throw new Error( + `当前数据库仍有治理迁移需要执行,不能在 --apply 清洗时顺带应用任何迁移:${appliedMigrations.join(', ')}`, + ) +} + +function resolveMigrationReplayMode(baseMode, migrationReplay) { + if (migrationReplay.structureMigrations.length > 0) { + return `${baseMode}-migration-dry-run` + } + if (migrationReplay.idempotentMigrations.length > 0) { + return `${baseMode}-idempotent-migration-replay` + } + return baseMode +} + +function formatPendingMigrationPaths(migrationReadiness) { + return [ + ...(migrationReadiness.pendingStructureMigrationPaths ?? []), + ...(migrationReadiness.pendingIdempotentMigrationPaths ?? []), + ].join(', ') +} + +function readPlan(planPath) { + if (!planPath) { + throw new Error('缺少 --plan 。') + } + const absolutePath = path.resolve(process.cwd(), planPath) + const raw = fs.readFileSync(absolutePath, 'utf8') + const plan = JSON.parse(raw) + assertNoUnknownCleanupPlanTopLevelKeys(plan) + return { + skuItems: Array.isArray(plan.skuItems) ? plan.skuItems : [], + productProfiles: Array.isArray(plan.productProfiles) ? plan.productProfiles : [], + qualityDecisionRepairs: Array.isArray(plan.qualityDecisionRepairs) + ? plan.qualityDecisionRepairs + : [], + preSkuFormalizationMismatches: Array.isArray(plan.preSkuFormalizationMismatches) + ? plan.preSkuFormalizationMismatches + : [], + confirmation: typeof plan.confirmation === 'string' ? plan.confirmation : '', + confirmedBy: typeof plan.confirmedBy === 'string' ? plan.confirmedBy : '', + } +} + +function readText(value) { + return typeof value === 'string' ? value.trim() : '' +} + +function assertNoUnknownCleanupPlanTopLevelKeys(plan) { + if (!plan || typeof plan !== 'object' || Array.isArray(plan)) { + throw new Error('清洗计划必须是 JSON 对象。') + } + const unknownKeys = Object.keys(plan).filter((key) => !CLEANUP_PLAN_TOP_LEVEL_KEYS.has(key)) + if (unknownKeys.length > 0) { + throw new Error(`清洗计划存在不允许的顶层字段:${unknownKeys.join(', ')}。`) + } +} + +function pickTextPatch(input, keys) { + const patch = {} + keys.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(input, key)) { + const value = readText(input[key]) + if (value) { + patch[key] = value + } + } + }) + return patch +} + +function normalizeSkuItemCodePatch(patch) { + const nextPatch = { ...patch } + SKU_ITEM_NORMALIZED_CODE_KEYS.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(nextPatch, key)) { + nextPatch[key] = readText(nextPatch[key]).toUpperCase() + } + }) + return nextPatch +} + +function assertNoUnknownFillKeys(input, allowedKeys, scope) { + if (!input || typeof input !== 'object' || Array.isArray(input)) return + const unknownKeys = Object.keys(input).filter((key) => !allowedKeys.includes(key)) + if (unknownKeys.length > 0) { + throw new Error(`${scope} 存在不允许的清洗字段:${unknownKeys.join(', ')}。`) + } +} + +function assertOnlyMissingFieldsArePatched(currentValues, patch, requiredKeys, scope) { + const overwrittenKeys = Object.keys(patch).filter((key) => readText(currentValues[key]).length > 0) + if (overwrittenKeys.length > 0) { + throw new Error( + `${scope} 只能补齐当前仍为空的字段,不能覆盖已有主数据:${overwrittenKeys.join(', ')}。`, + ) + } + + const patchedRequiredKeys = Object.keys(patch).filter((key) => requiredKeys.includes(key)) + if (patchedRequiredKeys.length === 0 && Object.keys(patch).length > 0) { + throw new Error(`${scope} 至少要补齐一个必填主数据字段,不能只补可选字段。`) + } +} + +function assertValidProductProfilePatch(patch, productId) { + if ( + Object.prototype.hasOwnProperty.call(patch, 'productDevelopmentKind') && + !['product', 'series'].includes(patch.productDevelopmentKind) + ) { + throw new Error( + `产品 ${productId} 的 productDevelopmentKind 无效:${patch.productDevelopmentKind},只能填写 product 或 series。`, + ) + } +} + +function isDisallowedCleanupTextValue(value) { + const compactValue = readText(value).replace(/\s+/g, '').toLowerCase() + return ( + DISALLOWED_CLEANUP_FILL_VALUES.has(compactValue) || + /待补|待确认|默认值/.test(compactValue) + ) +} + +function assertValidTextPatch(patch, scope) { + Object.entries(patch).forEach(([key, value]) => { + if (/[\u0000-\u001F\u007F]/.test(value)) { + throw new Error(`${scope} 的 ${key} 包含控制字符,不能写入主数据。`) + } + if (isDisallowedCleanupTextValue(value)) { + throw new Error( + `${scope} 的 ${key} 不能填写占位值或默认值:${value}。必须填写业务确认后的真实主数据。`, + ) + } + }) +} + +function assertValidAuditText(value, scope) { + assertValidTextPatch({ value }, scope) +} + +function assertValidIncompleteApproval(entry, scope) { + if (entry?.allowIncomplete !== true) return + if (!readText(entry.incompleteReason)) { + throw new Error(`${scope} 设置 allowIncomplete=true 时必须填写 incompleteReason。`) + } + assertValidAuditText(entry.incompleteReason, `${scope} 的 incompleteReason`) +} + +function validateSkuItemCleanupPlanEntry(entry) { + const id = readText(entry.id) + if (!id) { + throw new Error('skuItems 中存在缺少 id 的清洗项。') + } + + const fill = entry.fill && typeof entry.fill === 'object' ? entry.fill : entry + assertNoUnknownFillKeys(fill, SKU_ITEM_FILL_KEYS, `SKU 明细 ${id}`) + const patch = normalizeSkuItemCodePatch(pickTextPatch(fill, SKU_ITEM_FILL_KEYS)) + assertValidTextPatch(patch, `SKU 明细 ${id}`) + if (Object.keys(patch).length === 0) { + return { id, skipped: true, reason: '没有填写任何字段', appliedFields: [] } + } + + assertCleanupEvidencePresent( + entry, + SKU_ITEM_CURRENT_EVIDENCE_KEYS, + SKU_ITEM_CONTEXT_EVIDENCE_KEYS, + `SKU 明细 ${id}`, + ) + assertOnlyMissingFieldsArePatched( + entry.current ?? {}, + patch, + REQUIRED_SKU_ITEM_KEYS, + `SKU 明细 ${id}`, + ) + + const nextValues = { + skuCode: entry.current?.skuCode, + specName: entry.current?.specName, + model: entry.current?.model, + barcode: entry.current?.barcode, + ...patch, + } + const missingRequiredFields = resolveSkuMissingFields(nextValues) + if (missingRequiredFields.length > 0 && !hasExplicitIncompleteApproval(entry)) { + throw new Error( + `SKU 明细 ${id} 静态校验后仍未补齐:${missingRequiredFields.join(', ')}。如确需分阶段清洗,计划项必须设置 allowIncomplete=true 且填写 incompleteReason。`, + ) + } + assertValidIncompleteApproval(entry, `SKU 明细 ${id}`) + + return { id, appliedFields: Object.keys(patch), missingRequiredFields } +} + +function validateProductProfileCleanupPlanEntry(entry) { + const productId = readText(entry.productId) + if (!productId) { + throw new Error('productProfiles 中存在缺少 productId 的清洗项。') + } + + const fill = entry.fill && typeof entry.fill === 'object' ? entry.fill : entry + assertNoUnknownFillKeys(fill, PRODUCT_PROFILE_FILL_KEYS, `产品 ${productId}`) + const patch = pickTextPatch(fill, PRODUCT_PROFILE_FILL_KEYS) + assertValidTextPatch(patch, `产品 ${productId}`) + if (Object.keys(patch).length === 0) { + return { productId, skipped: true, reason: '没有填写任何字段', appliedFields: [] } + } + + assertCleanupEvidencePresent( + entry, + PRODUCT_PROFILE_CURRENT_EVIDENCE_KEYS, + PRODUCT_PROFILE_CONTEXT_EVIDENCE_KEYS, + `产品 ${productId}`, + ) + assertValidProductProfilePatch(patch, productId) + assertOnlyMissingFieldsArePatched( + entry.current ?? {}, + patch, + REQUIRED_PRODUCT_PROFILE_KEYS, + `产品 ${productId}`, + ) + + const nextValues = { + productName: entry.current?.productName, + productCategory: entry.current?.productCategory, + productDevelopmentKind: entry.current?.productDevelopmentKind, + ...patch, + } + const missingFields = resolveProductProfileMissingFields(nextValues) + if (missingFields.length > 0 && !hasExplicitIncompleteApproval(entry)) { + throw new Error( + `产品 ${productId} 静态校验后仍未补齐:${missingFields.join(', ')}。如确需分阶段清洗,计划项必须设置 allowIncomplete=true 且填写 incompleteReason。`, + ) + } + assertValidIncompleteApproval(entry, `产品 ${productId}`) + + return { productId, appliedFields: Object.keys(patch), missingFields } +} + +function validateQualityDecisionRepairPlanEntry(entry) { + const id = readText(entry.id) + if (!id) { + throw new Error('qualityDecisionRepairs 中存在缺少 id 的修复项。') + } + + const fill = entry.fill && typeof entry.fill === 'object' ? entry.fill : entry + assertNoUnknownFillKeys(fill, QUALITY_DECISION_REPAIR_FILL_KEYS, `质量决策 ${id}`) + const patch = pickTextPatch(fill, QUALITY_DECISION_REPAIR_FILL_KEYS) + assertValidTextPatch(patch, `质量决策 ${id}`) + if (Object.keys(patch).length === 0) { + return { id, skipped: true, reason: '没有填写任何字段', appliedFields: [] } + } + + assertCleanupEvidencePresent( + entry, + QUALITY_DECISION_REPAIR_CURRENT_EVIDENCE_KEYS, + QUALITY_DECISION_REPAIR_CONTEXT_EVIDENCE_KEYS, + `质量决策 ${id}`, + ) + + const invalidOverwriteKeys = Object.keys(patch).filter((key) => + !isDisallowedCleanupTextValue(entry.current?.[key]), + ) + if (invalidOverwriteKeys.length > 0) { + throw new Error( + `质量决策 ${id} 只能修正当前为占位值的审计字段,不能覆盖真实审计内容:${invalidOverwriteKeys.join(', ')}。`, + ) + } + + return { id, appliedFields: Object.keys(patch) } +} + +function getPreSkuFormalizationMismatchActions(plan) { + const rows = Array.isArray(plan?.preSkuFormalizationMismatches) + ? plan.preSkuFormalizationMismatches + : [] + return rows + .filter((row) => { + const action = row?.action && typeof row.action === 'object' ? row.action : row + return readText(action.decision).length > 0 + }) + .map((row) => ({ + preSkuId: readText(row.preSkuId), + draftGroupCode: readText(row.draftGroupCode), + mismatchReason: readText(row.mismatchReason), + decision: readText(row.action?.decision ?? row.decision), + })) +} + +function assertNoPreSkuFormalizationMismatchActions(plan) { + const actions = getPreSkuFormalizationMismatchActions(plan) + if (actions.length === 0) return actions + + const preview = actions + .slice(0, 5) + .map((action) => + [ + action.draftGroupCode || 'unknown-draft-group', + action.preSkuId || 'unknown-pre-sku', + action.mismatchReason || 'unknown-reason', + ].join('/'), + ) + .join(', ') + throw new Error( + `准 SKU 正式化生命周期错位不能由通用主数据清洗脚本处理,请走专用服务/计划:${preview}`, + ) +} + +function validateCleanupPlanForApply(plan) { + if (plan.confirmation !== APPLY_CONFIRMATION) { + throw new Error(`静态校验要求计划文件设置 confirmation="${APPLY_CONFIRMATION}"。`) + } + if (!readText(plan.confirmedBy)) { + throw new Error('静态校验要求计划文件填写 confirmedBy,记录业务确认人。') + } + assertValidAuditText(plan.confirmedBy, '清洗计划 confirmedBy') + assertNoPreSkuFormalizationMismatchActions(plan) + + const skuItems = (plan.skuItems ?? []).map(validateSkuItemCleanupPlanEntry) + const productProfiles = (plan.productProfiles ?? []).map(validateProductProfileCleanupPlanEntry) + const qualityDecisionRepairs = (plan.qualityDecisionRepairs ?? []) + .map(validateQualityDecisionRepairPlanEntry) + const skuAppliedFields = skuItems.reduce( + (total, item) => total + (item.appliedFields?.length ?? 0), + 0, + ) + const productProfileAppliedFields = productProfiles.reduce( + (total, item) => total + (item.appliedFields?.length ?? 0), + 0, + ) + const qualityDecisionRepairAppliedFields = qualityDecisionRepairs.reduce( + (total, item) => total + (item.appliedFields?.length ?? 0), + 0, + ) + const appliedFieldCount = + skuAppliedFields + productProfileAppliedFields + qualityDecisionRepairAppliedFields + if (appliedFieldCount === 0) { + throw new Error('静态校验要求计划至少补齐一个字段;当前计划没有任何有效清洗内容。') + } + + return { + mode: 'validate-plan', + valid: true, + committed: false, + requiresDatabase: false, + confirmedBy: readText(plan.confirmedBy), + skuItems: { + total: skuItems.length, + actionableRows: skuItems.filter((item) => !item.skipped).length, + appliedFields: skuAppliedFields, + approvedIncompleteRows: countApprovedIncompleteRows( + skuItems, + 'missingRequiredFields', + ), + remainingMissingFieldCounts: countRemainingMissingFields( + skuItems, + 'missingRequiredFields', + ), + }, + productProfiles: { + total: productProfiles.length, + actionableRows: productProfiles.filter((item) => !item.skipped).length, + appliedFields: productProfileAppliedFields, + approvedIncompleteRows: countApprovedIncompleteRows( + productProfiles, + 'missingFields', + ), + remainingMissingFieldCounts: countRemainingMissingFields( + productProfiles, + 'missingFields', + ), + }, + qualityDecisionRepairs: { + total: qualityDecisionRepairs.length, + actionableRows: qualityDecisionRepairs.filter((item) => !item.skipped).length, + appliedFields: qualityDecisionRepairAppliedFields, + }, + } +} + +function assertExpectedCurrentMatches(actualValues, expectedValues, keys, scope) { + if (!expectedValues || typeof expectedValues !== 'object' || Array.isArray(expectedValues)) return + const normalize = (value) => { + if (Array.isArray(value)) return JSON.stringify(value.map((item) => readText(item))) + return readText(value) + } + const changedKeys = keys.filter((key) => { + if (!Object.prototype.hasOwnProperty.call(expectedValues, key)) return false + return normalize(actualValues[key]) !== normalize(expectedValues[key]) + }) + if (changedKeys.length > 0) { + throw new Error( + `${scope} 的当前数据已经变化,清洗计划已过期,请重新导出模板:${changedKeys.join(', ')}。`, + ) + } +} + +function assertCleanupEvidencePresent(entry, currentKeys, contextKeys, scope) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`${scope} 清洗计划格式无效。`) + } + + const current = entry.current + if (!current || typeof current !== 'object' || Array.isArray(current)) { + throw new Error(`${scope} 真正落库必须携带模板导出的 current 当前值证据。`) + } + + const missingCurrentKeys = currentKeys.filter((key) => !Object.prototype.hasOwnProperty.call(current, key)) + if (missingCurrentKeys.length > 0) { + throw new Error(`${scope} current 缺少当前值证据字段:${missingCurrentKeys.join(', ')}。`) + } + + const missingContextKeys = contextKeys.filter((key) => readText(entry[key]).length === 0) + if (missingContextKeys.length > 0) { + throw new Error(`${scope} 缺少模板上下文字段:${missingContextKeys.join(', ')}。`) + } +} + +function countAppliedFields(results) { + return results.reduce((total, result) => total + (result.appliedFields?.length ?? 0), 0) +} + +function countRemainingMissingFields(results, fieldName) { + const counts = {} + for (const result of results) { + const fields = Array.isArray(result?.[fieldName]) ? result[fieldName] : [] + for (const field of fields) { + const key = readText(field) || 'UNKNOWN' + counts[key] = (counts[key] || 0) + 1 + } + } + return counts +} + +function countApprovedIncompleteRows(results, fieldName) { + return results.filter((result) => { + const fields = Array.isArray(result?.[fieldName]) ? result[fieldName] : [] + return !result?.skipped && fields.length > 0 + }).length +} + +function buildResultSummary(skuResults, productProfileResults, qualityDecisionRepairResults = []) { + return { + skuItems: { + total: skuResults.length, + appliedRows: skuResults.filter((result) => !result.skipped).length, + skippedRows: skuResults.filter((result) => result.skipped).length, + appliedFields: countAppliedFields(skuResults), + approvedIncompleteRows: countApprovedIncompleteRows( + skuResults, + 'missingRequiredFields', + ), + remainingMissingFieldCounts: countRemainingMissingFields( + skuResults, + 'missingRequiredFields', + ), + }, + productProfiles: { + total: productProfileResults.length, + appliedRows: productProfileResults.filter((result) => !result.skipped).length, + skippedRows: productProfileResults.filter((result) => result.skipped).length, + appliedFields: countAppliedFields(productProfileResults), + approvedIncompleteRows: countApprovedIncompleteRows( + productProfileResults, + 'missingFields', + ), + remainingMissingFieldCounts: countRemainingMissingFields( + productProfileResults, + 'missingFields', + ), + }, + qualityDecisionRepairs: { + total: qualityDecisionRepairResults.length, + appliedRows: qualityDecisionRepairResults.filter((result) => !result.skipped).length, + skippedRows: qualityDecisionRepairResults.filter((result) => result.skipped).length, + appliedFields: countAppliedFields(qualityDecisionRepairResults), + }, + } +} + +async function assertPostCleanupMasterDataTruth( + client, + mode, + appliedMigrations, + buildReportImpl = buildReport, +) { + const report = await buildReportImpl(client, mode, appliedMigrations) + assertNoMasterDataTruthDrift(report) + const qualityDecisionRisks = report.masterDataQualityDecisions || {} + const qualityDecisionPlaceholderRisk = + Number(qualityDecisionRisks.placeholderReasonRisk ?? 0) + + Number(qualityDecisionRisks.placeholderDecidedByRisk ?? 0) + + Number(qualityDecisionRisks.placeholderResolvedByRisk ?? 0) + if (qualityDecisionPlaceholderRisk > 0) { + throw new Error('主数据质量决策仍存在占位审计文本,详见 masterDataQualityDecisions。') + } + return { + payloadSyncDiffCount: report.payloadSyncDiffCount, + productSkuProfileSnapshotDiffCount: report.productSkuProfileSnapshotDiffCount, + preSkuBasicInfoSnapshotDiffCount: report.preSkuBasicInfoSnapshotDiffCount, + nonIntegratedProductProfilePayloadDiffCount: + report.nonIntegratedProductProfilePayloadDiffCount, + nonIntegratedProductStockSourcePayloadDiffCount: + report.nonIntegratedProductStockSourcePayloadDiffCount, + duplicateSkuCodeGroups: report.duplicateSkuCodeGroups, + duplicateBarcodeGroups: report.duplicateBarcodeGroups, + masterDataQualityDecisionPlaceholderRisk: qualityDecisionPlaceholderRisk, + } +} + +function resolveSkuMissingFields(values) { + return [ + ['skuCode', 'sku_code'], + ['specName', 'spec_name'], + ['model', 'model'], + ] + .filter(([key]) => readText(values[key]).length === 0) + .map(([, missingField]) => missingField) +} + +function resolveProductProfileMissingFields(values) { + return REQUIRED_PRODUCT_PROFILE_KEYS.filter((key) => readText(values[key]).length === 0) +} + +function updateSkuItemPayload(payload, skuItemId, patch) { + const nextPayload = payload && typeof payload === 'object' && !Array.isArray(payload) + ? { ...payload } + : {} + const skuItems = Array.isArray(nextPayload.skuItems) ? nextPayload.skuItems : [] + + nextPayload.skuItems = skuItems.map((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item + if (readText(item.id) !== skuItemId) return item + return { ...item, ...patch } + }) + + return nextPayload +} + +function updateProductProfilePayload(payload, patch) { + const nextPayload = payload && typeof payload === 'object' && !Array.isArray(payload) + ? { ...payload } + : {} + const openingReference = + nextPayload.openingReference && + typeof nextPayload.openingReference === 'object' && + !Array.isArray(nextPayload.openingReference) + ? { ...nextPayload.openingReference } + : {} + + if (Object.prototype.hasOwnProperty.call(patch, 'productCategory')) { + nextPayload.productCategory = patch.productCategory + openingReference.productCategory = patch.productCategory + } + if (Object.prototype.hasOwnProperty.call(patch, 'productName')) { + openingReference.productName = patch.productName + } + if (Object.prototype.hasOwnProperty.call(patch, 'productDevelopmentKind')) { + nextPayload.productDevelopmentKind = patch.productDevelopmentKind + } + + nextPayload.openingReference = openingReference + return nextPayload +} + +async function getCleanupHealthSummary(client) { + const result = await client.query(` + SELECT + ( + SELECT COUNT(*)::integer + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + AND sku."dataQualityStatus" = 'INCOMPLETE' + ) AS incomplete_sku_items, + ( + SELECT COUNT(*)::integer + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + ) AS sku_items_total, + ( + SELECT COUNT(*)::integer + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + AND sku."dataQualityStatus" = 'READY' + ) AS sku_items_ready, + ( + SELECT COUNT(*)::integer + FROM "IntakeNonIntegratedProduct" product + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."deletedAt" IS NULL + AND ( + NULLIF(btrim(profile."productName"), '') IS NULL + OR NULLIF(btrim(profile."productCategory"), '') IS NULL + OR NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL + ) + ) AS incomplete_product_profiles + , + ( + SELECT COUNT(*)::integer + FROM "IntakeNonIntegratedProduct" product + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."deletedAt" IS NULL + ) AS product_profiles_total, + ( + SELECT COUNT(*)::integer + FROM "IntakeNonIntegratedProduct" product + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."deletedAt" IS NULL + AND NULLIF(btrim(profile."productName"), '') IS NULL + ) AS product_profiles_missing_product_name, + ( + SELECT COUNT(*)::integer + FROM "IntakeNonIntegratedProduct" product + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."deletedAt" IS NULL + AND NULLIF(btrim(profile."productCategory"), '') IS NULL + ) AS product_profiles_missing_product_category, + ( + SELECT COUNT(*)::integer + FROM "IntakeNonIntegratedProduct" product + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."deletedAt" IS NULL + AND NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL + ) AS product_profiles_missing_development_kind + `) + const row = result.rows[0] + return { + incompleteSkuItems: row.incomplete_sku_items, + incompleteProductProfiles: row.incomplete_product_profiles, + structuredSkuItems: { + total: row.sku_items_total, + ready: row.sku_items_ready, + incomplete: row.incomplete_sku_items, + }, + nonIntegratedProductProfiles: { + total: row.product_profiles_total, + missing_product_name: row.product_profiles_missing_product_name, + missing_product_category: row.product_profiles_missing_product_category, + missing_development_kind: row.product_profiles_missing_development_kind, + }, + } +} + +async function assertUniqueSkuItemValue(client, id, fieldName, value) { + if (!['skuCode', 'barcode'].includes(fieldName) || !readText(value)) return + const normalizedValue = readText(value).toUpperCase() + + const result = await client.query(` + SELECT sku."id", product."spu" + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + AND sku."id" <> $1 + AND upper(btrim(sku."${fieldName}")) = $2 + LIMIT 1 + `, [id, normalizedValue]) + + if (result.rowCount > 0) { + const conflict = result.rows[0] + throw new Error( + `SKU 明细 ${id} 的 ${fieldName} 与 ${conflict.spu}/${conflict.id} 冲突:${value}。`, + ) + } +} + +async function buildCleanupTemplate(client) { + const incompleteSkuItems = await client.query(` + SELECT + sku."id", + sku."productId", + product."spu", + sku."skuItemId", + sku."skuCode", + sku."specName", + sku."model", + sku."barcode", + sku."missingRequiredFields" + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + AND sku."dataQualityStatus" = 'INCOMPLETE' + ORDER BY product."spu", sku."sortOrder", sku."id" + `) + + const productProfiles = await client.query(` + SELECT + product."id" AS "productId", + product."spu", + profile."productName", + profile."productCategory", + profile."productDevelopmentKind", + ARRAY_REMOVE(ARRAY[ + CASE WHEN NULLIF(btrim(profile."productName"), '') IS NULL THEN 'productName' END, + CASE WHEN NULLIF(btrim(profile."productCategory"), '') IS NULL THEN 'productCategory' END, + CASE WHEN NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL THEN 'productDevelopmentKind' END + ], NULL) AS "missingFields" + FROM "IntakeNonIntegratedProduct" product + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."deletedAt" IS NULL + AND ( + NULLIF(btrim(profile."productName"), '') IS NULL + OR NULLIF(btrim(profile."productCategory"), '') IS NULL + OR NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL + ) + ORDER BY product."spu", product."id" + `) + const qualityDecisionRepairs = await client.query(` + WITH placeholder_tokens AS ( + SELECT unnest(ARRAY[ + 'unknown', 'unk', 'n/a', 'na', 'null', 'none', 'todo', 'tbd', + 'default', 'test', '测试', '待补', '待补齐', '待确认', '未知', + '默认', '默认值', '暂无' + ]) AS token + ), + decision_rows AS ( + SELECT + "id", + "targetType", + "targetId", + "targetBusinessKey", + "status", + "reason", + "decidedBy", + "resolvedBy", + ARRAY_REMOVE(ARRAY[ + CASE WHEN NULLIF(btrim(COALESCE("reason", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("reason"), '\\s+', '', 'g')) IN (SELECT token FROM placeholder_tokens) + OR regexp_replace(btrim("reason"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + THEN 'reason' END, + CASE WHEN NULLIF(btrim(COALESCE("decidedBy", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("decidedBy"), '\\s+', '', 'g')) IN (SELECT token FROM placeholder_tokens) + OR regexp_replace(btrim("decidedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + THEN 'decidedBy' END, + CASE WHEN NULLIF(btrim(COALESCE("resolvedBy", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g')) IN (SELECT token FROM placeholder_tokens) + OR regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + THEN 'resolvedBy' END + ], NULL) AS "placeholderFields" + FROM "MasterDataQualityDecision" + ) + SELECT * + FROM decision_rows + WHERE cardinality("placeholderFields") > 0 + ORDER BY "targetType", "targetId", "id" + `) + const preSkuFormalizationMismatches = await getPreSkuFormalizationLifecycleMismatchSamples(client) + + return { + confirmation: '', + confirmedBy: '', + skuItems: incompleteSkuItems.rows.map((row) => ({ + id: row.id, + productId: row.productId, + spu: row.spu, + skuItemId: row.skuItemId, + current: { + skuCode: row.skuCode, + specName: row.specName, + model: row.model, + barcode: row.barcode, + missingRequiredFields: row.missingRequiredFields, + }, + fill: { + skuCode: '', + specName: '', + model: '', + barcode: '', + }, + note: '只填写业务确认后的真实值;不要用默认值。', + })), + productProfiles: productProfiles.rows.map((row) => ({ + productId: row.productId, + spu: row.spu, + productName: row.productName, + current: { + productName: row.productName, + productCategory: row.productCategory, + productDevelopmentKind: row.productDevelopmentKind, + missingFields: row.missingFields, + }, + fill: { + productName: '', + productCategory: '', + productDevelopmentKind: '', + }, + allowedProductDevelopmentKind: ['product', 'series'], + note: '只填写业务确认后的真实值;不要用默认值。', + })), + qualityDecisionRepairs: qualityDecisionRepairs.rows.map((row) => ({ + id: row.id, + targetType: row.targetType, + targetId: row.targetId, + targetBusinessKey: row.targetBusinessKey, + current: { + reason: row.reason, + decidedBy: row.decidedBy, + resolvedBy: row.resolvedBy, + status: row.status, + placeholderFields: row.placeholderFields, + }, + fill: { + reason: '', + decidedBy: '', + resolvedBy: '', + }, + note: '只允许把 current.placeholderFields 中的占位审计文本改成真实业务确认内容。', + })), + preSkuFormalizationMismatches: preSkuFormalizationMismatches.map((row) => ({ + ...row, + action: { + decision: '', + note: '这类错位不能由通用清洗脚本猜修;请先确认是撤回准 SKU 状态、补正式 SKU 关系,还是按业务作废后走专用服务/计划处理。', + }, + })), + } +} + +function hasExplicitIncompleteApproval(entry) { + return entry.allowIncomplete === true && readText(entry.incompleteReason).length > 0 +} + +async function recordMasterDataQualityDecision(client, input) { + const reason = readText(input.reason) + const decidedBy = readText(input.decidedBy) + if (!reason) { + throw new Error('记录主数据不完整决策必须填写 reason。') + } + if (!decidedBy) { + throw new Error('记录主数据不完整决策必须填写 decidedBy。') + } + assertValidAuditText(reason, '主数据不完整决策 reason') + assertValidAuditText(decidedBy, '主数据不完整决策 decidedBy') + + await client.query(` + INSERT INTO "MasterDataQualityDecision" ( + "id", + "targetType", + "targetId", + "targetBusinessKey", + "status", + "missingFields", + "reason", + "decidedBy", + "decidedAt", + "createdAt", + "updatedAt" + ) + VALUES ( + 'mdqd_' || md5($1 || ':' || $2 || ':' || clock_timestamp()::text), + $1, + $2, + $3, + $4, + $5, + $6, + $7, + NOW(), + NOW(), + NOW() + ) + ON CONFLICT ("targetType", "targetId") + WHERE "status" = 'APPROVED_INCOMPLETE' + DO UPDATE SET + "targetBusinessKey" = EXCLUDED."targetBusinessKey", + "missingFields" = EXCLUDED."missingFields", + "reason" = EXCLUDED."reason", + "decidedBy" = EXCLUDED."decidedBy", + "decidedAt" = NOW(), + "updatedAt" = NOW() + `, [ + input.targetType, + input.targetId, + readText(input.targetBusinessKey), + QUALITY_DECISION_STATUS_APPROVED_INCOMPLETE, + input.missingFields, + reason, + decidedBy, + ]) +} + +async function resolveMasterDataQualityDecisions(client, input) { + await client.query(` + UPDATE "MasterDataQualityDecision" + SET + "status" = $3, + "resolvedBy" = $4, + "resolvedAt" = NOW(), + "updatedAt" = NOW() + WHERE "targetType" = $1 + AND "targetId" = $2 + AND "status" = $5 + `, [ + input.targetType, + input.targetId, + QUALITY_DECISION_STATUS_RESOLVED, + readText(input.resolvedBy) || CLEANUP_ACTOR_FALLBACK, + QUALITY_DECISION_STATUS_APPROVED_INCOMPLETE, + ]) +} + +async function applySkuItemPatch(client, entry, options = {}) { + const id = readText(entry.id) + const actor = readText(options.actor) || CLEANUP_ACTOR_FALLBACK + if (!id) { + throw new Error('skuItems 中存在缺少 id 的清洗项。') + } + + const fill = entry.fill && typeof entry.fill === 'object' ? entry.fill : entry + assertNoUnknownFillKeys(fill, SKU_ITEM_FILL_KEYS, `SKU 明细 ${id}`) + const patch = normalizeSkuItemCodePatch(pickTextPatch(fill, SKU_ITEM_FILL_KEYS)) + assertValidTextPatch(patch, `SKU 明细 ${id}`) + if (Object.keys(patch).length === 0) { + return { id, skipped: true, reason: '没有填写任何字段' } + } + if (options.requireCurrentEvidence) { + assertCleanupEvidencePresent( + entry, + SKU_ITEM_CURRENT_EVIDENCE_KEYS, + SKU_ITEM_CONTEXT_EVIDENCE_KEYS, + `SKU 明细 ${id}`, + ) + } + + const result = await client.query(` + SELECT + sku.*, + product."payload", + product."spu" + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE sku."id" = $1 + AND product."deletedAt" IS NULL + FOR UPDATE OF sku, product + `, [id]) + const row = result.rows[0] + if (!row) { + throw new Error(`找不到可清洗的 SKU 明细:${id}`) + } + assertExpectedCurrentMatches( + row, + entry.current, + ['skuCode', 'specName', 'model', 'barcode', 'missingRequiredFields'], + `SKU 明细 ${id}`, + ) + assertExpectedCurrentMatches( + row, + entry, + ['productId', 'spu', 'skuItemId'], + `SKU 明细 ${id}`, + ) + assertOnlyMissingFieldsArePatched(row, patch, REQUIRED_SKU_ITEM_KEYS, `SKU 明细 ${id}`) + await assertUniqueSkuItemValue(client, id, 'skuCode', patch.skuCode) + await assertUniqueSkuItemValue(client, id, 'barcode', patch.barcode) + + const nextValues = { + skuCode: row.skuCode, + specName: row.specName, + model: row.model, + barcode: row.barcode, + ...patch, + } + const missingRequiredFields = resolveSkuMissingFields(nextValues) + const dataQualityStatus = missingRequiredFields.length === 0 ? 'READY' : 'INCOMPLETE' + if (options.requireComplete && dataQualityStatus !== 'READY' && !hasExplicitIncompleteApproval(entry)) { + throw new Error( + `SKU 明细 ${id} 清洗后仍未补齐:${missingRequiredFields.join(', ')}。如确需分阶段清洗,计划项必须设置 allowIncomplete=true 且填写 incompleteReason。`, + ) + } + assertValidIncompleteApproval(entry, `SKU 明细 ${id}`) + + await client.query(` + UPDATE "IntakeNonIntegratedSkuItem" + SET + "skuCode" = $2, + "specName" = $3, + "model" = $4, + "barcode" = $5, + "dataQualityStatus" = $6::"IntakeNonIntegratedSkuDataQualityStatus", + "missingRequiredFields" = $7, + "updatedAt" = NOW() + WHERE "id" = $1 + `, [ + id, + nextValues.skuCode, + nextValues.specName, + nextValues.model, + nextValues.barcode, + dataQualityStatus, + missingRequiredFields, + ]) + + const nextPayload = updateSkuItemPayload(row.payload, row.skuItemId, patch) + await client.query(` + UPDATE "IntakeNonIntegratedProduct" + SET "payload" = $2::jsonb, + "updatedBy" = $3, + "updatedAt" = NOW() + WHERE "id" = $1 + `, [row.productId, JSON.stringify(nextPayload), actor]) + + if (dataQualityStatus === 'READY') { + await resolveMasterDataQualityDecisions(client, { + targetType: QUALITY_DECISION_TARGET_SKU_ITEM, + targetId: id, + resolvedBy: actor, + }) + } else if (hasExplicitIncompleteApproval(entry)) { + await recordMasterDataQualityDecision(client, { + targetType: QUALITY_DECISION_TARGET_SKU_ITEM, + targetId: id, + targetBusinessKey: `${row.spu}/${row.skuItemId}`, + missingFields: missingRequiredFields, + reason: entry.incompleteReason, + decidedBy: actor, + }) + } + + return { + id, + spu: row.spu, + dataQualityStatus, + missingRequiredFields, + appliedFields: Object.keys(patch), + } +} + +async function applyProductProfilePatch(client, entry, options = {}) { + const productId = readText(entry.productId) + const actor = readText(options.actor) || CLEANUP_ACTOR_FALLBACK + if (!productId) { + throw new Error('productProfiles 中存在缺少 productId 的清洗项。') + } + + const fill = entry.fill && typeof entry.fill === 'object' ? entry.fill : entry + assertNoUnknownFillKeys(fill, PRODUCT_PROFILE_FILL_KEYS, `产品 ${productId}`) + const patch = pickTextPatch(fill, PRODUCT_PROFILE_FILL_KEYS) + assertValidTextPatch(patch, `产品 ${productId}`) + if (Object.keys(patch).length === 0) { + return { productId, skipped: true, reason: '没有填写任何字段' } + } + if (options.requireCurrentEvidence) { + assertCleanupEvidencePresent( + entry, + PRODUCT_PROFILE_CURRENT_EVIDENCE_KEYS, + PRODUCT_PROFILE_CONTEXT_EVIDENCE_KEYS, + `产品 ${productId}`, + ) + } + assertValidProductProfilePatch(patch, productId) + + const result = await client.query(` + SELECT + product."id", + product."spu", + product."payload", + profile."productName", + profile."productCategory", + profile."productDevelopmentKind" + FROM "IntakeNonIntegratedProduct" product + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."id" = $1 + AND product."deletedAt" IS NULL + FOR UPDATE OF product, profile + `, [productId]) + const row = result.rows[0] + if (!row) { + throw new Error(`找不到可清洗的非集成产品 profile:${productId}`) + } + assertExpectedCurrentMatches( + row, + entry.current, + PRODUCT_PROFILE_FILL_KEYS, + `产品 ${productId}`, + ) + assertExpectedCurrentMatches(row, entry, ['spu'], `产品 ${productId}`) + assertOnlyMissingFieldsArePatched(row, patch, REQUIRED_PRODUCT_PROFILE_KEYS, `产品 ${productId}`) + const nextProfileValues = { + productName: row.productName, + productCategory: row.productCategory, + productDevelopmentKind: row.productDevelopmentKind, + ...patch, + } + const missingRequiredFields = resolveProductProfileMissingFields(nextProfileValues) + if (options.requireComplete && missingRequiredFields.length > 0 && !hasExplicitIncompleteApproval(entry)) { + throw new Error( + `产品 ${productId} 清洗后仍未补齐:${missingRequiredFields.join(', ')}。如确需分阶段清洗,计划项必须设置 allowIncomplete=true 且填写 incompleteReason。`, + ) + } + assertValidIncompleteApproval(entry, `产品 ${productId}`) + + const assignments = [] + const values = [productId] + Object.entries(patch).forEach(([key, value]) => { + values.push(value) + assignments.push(`"${key}" = $${values.length}`) + }) + assignments.push('"source" = \'MANUAL_CLEANUP\'') + assignments.push('"syncedAt" = NOW()') + assignments.push('"updatedAt" = NOW()') + + await client.query(` + UPDATE "IntakeNonIntegratedProductProfile" + SET ${assignments.join(', ')} + WHERE "productId" = $1 + `, values) + + const nextPayload = updateProductProfilePayload(row.payload, patch) + await client.query(` + UPDATE "IntakeNonIntegratedProduct" + SET "payload" = $2::jsonb, + "updatedBy" = $3, + "updatedAt" = NOW() + WHERE "id" = $1 + `, [row.id, JSON.stringify(nextPayload), actor]) + + if (missingRequiredFields.length === 0) { + await resolveMasterDataQualityDecisions(client, { + targetType: QUALITY_DECISION_TARGET_PRODUCT_PROFILE, + targetId: productId, + resolvedBy: actor, + }) + } else if (hasExplicitIncompleteApproval(entry)) { + await recordMasterDataQualityDecision(client, { + targetType: QUALITY_DECISION_TARGET_PRODUCT_PROFILE, + targetId: productId, + targetBusinessKey: row.spu, + missingFields: missingRequiredFields, + reason: entry.incompleteReason, + decidedBy: actor, + }) + } + + return { + productId, + spu: row.spu, + missingFields: missingRequiredFields, + appliedFields: Object.keys(patch), + } +} + +async function applyQualityDecisionRepairPatch(client, entry) { + const id = readText(entry.id) + if (!id) { + throw new Error('qualityDecisionRepairs 中存在缺少 id 的修复项。') + } + + const fill = entry.fill && typeof entry.fill === 'object' ? entry.fill : entry + assertNoUnknownFillKeys(fill, QUALITY_DECISION_REPAIR_FILL_KEYS, `质量决策 ${id}`) + const patch = pickTextPatch(fill, QUALITY_DECISION_REPAIR_FILL_KEYS) + assertValidTextPatch(patch, `质量决策 ${id}`) + if (Object.keys(patch).length === 0) { + return { id, skipped: true, reason: '没有填写任何字段', appliedFields: [] } + } + if (entry.current) { + assertCleanupEvidencePresent( + entry, + QUALITY_DECISION_REPAIR_CURRENT_EVIDENCE_KEYS, + QUALITY_DECISION_REPAIR_CONTEXT_EVIDENCE_KEYS, + `质量决策 ${id}`, + ) + } + + const result = await client.query(` + SELECT + "id", + "targetType", + "targetId", + "targetBusinessKey", + "status", + "reason", + "decidedBy", + "resolvedBy" + FROM "MasterDataQualityDecision" + WHERE "id" = $1 + FOR UPDATE + `, [id]) + const row = result.rows[0] + if (!row) { + throw new Error(`找不到可修复的主数据质量决策:${id}`) + } + assertExpectedCurrentMatches( + row, + entry.current, + ['reason', 'decidedBy', 'resolvedBy', 'status'], + `质量决策 ${id}`, + ) + assertExpectedCurrentMatches( + row, + entry, + ['targetType', 'targetId', 'targetBusinessKey'], + `质量决策 ${id}`, + ) + + const invalidOverwriteKeys = Object.keys(patch).filter((key) => + !isDisallowedCleanupTextValue(row[key]), + ) + if (invalidOverwriteKeys.length > 0) { + throw new Error( + `质量决策 ${id} 只能修正当前为占位值的审计字段,不能覆盖真实审计内容:${invalidOverwriteKeys.join(', ')}。`, + ) + } + + const assignments = [] + const values = [id] + Object.entries(patch).forEach(([key, value]) => { + values.push(value) + assignments.push(`"${key}" = $${values.length}`) + }) + assignments.push('"updatedAt" = NOW()') + await client.query(` + UPDATE "MasterDataQualityDecision" + SET ${assignments.join(', ')} + WHERE "id" = $1 + `, values) + + return { + id, + targetType: row.targetType, + targetId: row.targetId, + appliedFields: Object.keys(patch), + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + if (args.validatePlan) { + const plan = readPlan(args.planPath) + const validation = validateCleanupPlanForApply(plan) + const outputFile = args.outputPath ? writeJsonReport(args.outputPath, validation) : '' + console.log(JSON.stringify({ ...validation, outputFile }, null, 2)) + return + } + + const databaseUrl = loadDatabaseUrl() + if (!databaseUrl) { + throw new Error('DATABASE_URL 未配置,无法执行主数据清洗。') + } + const executionEvidence = assertApplyExecutionEvidence(args, { databaseUrl }) + + const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 }) + const client = await pool.connect() + + try { + const migrationReadiness = await getRequiredMigrationReadiness(client) + if (!migrationReadiness.fullyReady && (args.apply || !args.allowMigrationDryRun)) { + console.log(JSON.stringify({ + mode: args.apply ? 'apply-migration-status-blocked' : 'cleanup-migration-status-blocked', + migrationApplicationStatus: migrationReadiness, + committed: false, + rolledBack: true, + }, null, 2)) + throw new Error( + `当前数据库治理迁移尚未全部真实落库,清洗入口默认拒绝继续:${formatPendingMigrationPaths(migrationReadiness)}。如只是本地调试模板或 dry-run,可追加 --allow-migration-dry-run。`, + ) + } + + await client.query('BEGIN') + const appliedMigrations = await applyMissingMigrationFiles(client) + const migrationReplay = classifyAppliedMigrationPaths(appliedMigrations) + const migrationApplicationStatus = getMigrationApplicationStatus( + appliedMigrations, + migrationReplay, + ) + if (args.apply && migrationApplicationStatus.appliedMigrationCount > 0) { + await client.query('ROLLBACK') + console.log(JSON.stringify({ + mode: resolveMigrationReplayMode('apply', migrationReplay), + appliedMigrations, + migrationReplay, + migrationApplicationStatus, + committed: false, + rolledBack: true, + }, null, 2)) + assertNoApplyWithPendingMigrations(args, appliedMigrations, migrationReplay) + } + assertNoApplyWithPendingMigrations(args, appliedMigrations, migrationReplay) + + if (args.template) { + const template = await buildCleanupTemplate(client) + const currentSummary = await getCleanupHealthSummary(client) + await client.query('ROLLBACK') + const templateReport = { + mode: resolveMigrationReplayMode('template', migrationReplay), + appliedMigrations, + migrationReplay, + migrationApplicationStatus, + currentSummary, + rolledBack: true, + ...template, + } + const outputFile = args.outputPath ? writeJsonReport(args.outputPath, templateReport) : '' + console.log(JSON.stringify({ + ...templateReport, + outputFile, + }, null, 2)) + return + } + + const plan = readPlan(args.planPath) + assertNoPreSkuFormalizationMismatchActions(plan) + if (args.apply && plan.confirmation !== APPLY_CONFIRMATION) { + throw new Error(`真正落库必须在计划文件中设置 confirmation="${APPLY_CONFIRMATION}"。`) + } + if (args.apply && !readText(plan.confirmedBy)) { + throw new Error('真正落库必须在计划文件中填写 confirmedBy,记录业务确认人。') + } + if (args.apply) { + assertValidAuditText(plan.confirmedBy, '清洗计划 confirmedBy') + } + const cleanupActor = readText(plan.confirmedBy) || CLEANUP_ACTOR_FALLBACK + + const skuResults = [] + const productProfileResults = [] + const qualityDecisionRepairResults = [] + const beforeSummary = await getCleanupHealthSummary(client) + + for (const entry of plan.skuItems) { + skuResults.push(await applySkuItemPatch(client, entry, { + requireComplete: args.apply, + requireCurrentEvidence: args.apply, + actor: cleanupActor, + })) + } + for (const entry of plan.productProfiles) { + productProfileResults.push(await applyProductProfilePatch(client, entry, { + requireComplete: args.apply, + requireCurrentEvidence: args.apply, + actor: cleanupActor, + })) + } + for (const entry of plan.qualityDecisionRepairs) { + qualityDecisionRepairResults.push(await applyQualityDecisionRepairPatch(client, entry)) + } + const resultSummary = buildResultSummary( + skuResults, + productProfileResults, + qualityDecisionRepairResults, + ) + const appliedFieldCount = + resultSummary.skuItems.appliedFields + + resultSummary.productProfiles.appliedFields + + resultSummary.qualityDecisionRepairs.appliedFields + if (args.apply && appliedFieldCount === 0) { + throw new Error('真正落库必须至少补齐一个字段;当前计划没有任何有效清洗内容。') + } + const afterSummary = await getCleanupHealthSummary(client) + const postCleanupTruth = await assertPostCleanupMasterDataTruth( + client, + args.apply ? 'post-cleanup-apply' : 'post-cleanup-dry-run', + appliedMigrations, + ) + + if (args.apply) { + await client.query('COMMIT') + } else { + await client.query('ROLLBACK') + } + + console.log(JSON.stringify({ + mode: args.apply ? 'apply' : 'dry-run', + appliedMigrations, + migrationReplay, + migrationApplicationStatus, + committed: args.apply, + rolledBack: !args.apply, + beforeSummary, + afterSummary, + postCleanupTruth, + executionEvidence: args.apply ? executionEvidence : null, + resultSummary, + confirmedBy: args.apply ? cleanupActor : '', + skuResults, + productProfileResults, + qualityDecisionRepairResults, + }, 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, + CLEANUP_ACTOR_FALLBACK, + QUALITY_DECISION_TARGET_SKU_ITEM, + QUALITY_DECISION_TARGET_PRODUCT_PROFILE, + REQUIRED_MIGRATIONS, + SKU_ITEM_FILL_KEYS, + PRODUCT_PROFILE_FILL_KEYS, + parseArgs, + writeJsonReport, + assertNoApplyWithPendingMigrations, + assertApplyExecutionEvidence, + cleanupDatabaseIdentity, + getBackupFingerprint, + resolveMigrationReplayMode, + readPlan, + readText, + assertNoUnknownCleanupPlanTopLevelKeys, + pickTextPatch, + assertNoUnknownFillKeys, + assertOnlyMissingFieldsArePatched, + assertExpectedCurrentMatches, + assertCleanupEvidencePresent, + assertValidProductProfilePatch, + assertValidTextPatch, + assertValidAuditText, + assertValidIncompleteApproval, + validateCleanupPlanForApply, + validateQualityDecisionRepairPlanEntry, + getPreSkuFormalizationMismatchActions, + assertNoPreSkuFormalizationMismatchActions, + validateSkuItemCleanupPlanEntry, + validateProductProfileCleanupPlanEntry, + resolveSkuMissingFields, + resolveProductProfileMissingFields, + updateSkuItemPayload, + updateProductProfilePayload, + buildResultSummary, + countApprovedIncompleteRows, + countRemainingMissingFields, + assertPostCleanupMasterDataTruth, + recordMasterDataQualityDecision, + resolveMasterDataQualityDecisions, + applySkuItemPatch, + applyProductProfilePatch, + applyQualityDecisionRepairPatch, + buildCleanupTemplate, +} diff --git a/furniture-3d-system/scripts/apply-master-data-foundation-migrations.js b/furniture-3d-system/scripts/apply-master-data-foundation-migrations.js new file mode 100644 index 00000000..d8330ec5 --- /dev/null +++ b/furniture-3d-system/scripts/apply-master-data-foundation-migrations.js @@ -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 。') + } + 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 。') + } + 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 。') + } + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('缺少 --output 。') + } + continue + } + if (arg === '--phase') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --phase 。') + } + 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 --expected-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 --expected-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, +} diff --git a/furniture-3d-system/scripts/apply-pre-sku-formalization-repair.js b/furniture-3d-system/scripts/apply-pre-sku-formalization-repair.js new file mode 100644 index 00000000..01c63156 --- /dev/null +++ b/furniture-3d-system/scripts/apply-pre-sku-formalization-repair.js @@ -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 。') + args.planPath = value + index += 1 + continue + } + if (arg.startsWith('--plan=')) { + args.planPath = arg.slice('--plan='.length) + if (!args.planPath) throw new Error('缺少 --plan 。') + continue + } + if (arg === '--output') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --output 。') + args.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + args.outputPath = arg.slice('--output='.length) + if (!args.outputPath) throw new Error('缺少 --output 。') + continue + } + if (arg === '--confirm') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --confirm 。') + 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 。') + } + 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 。') + } + 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 。') + 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, +} diff --git a/furniture-3d-system/scripts/apply-prisma-baseline-resolve.js b/furniture-3d-system/scripts/apply-prisma-baseline-resolve.js new file mode 100644 index 00000000..2fdc972d --- /dev/null +++ b/furniture-3d-system/scripts/apply-prisma-baseline-resolve.js @@ -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 。') + options.preflightPath = value + index += 1 + continue + } + if (arg.startsWith('--preflight=')) { + options.preflightPath = arg.slice('--preflight='.length) + if (!options.preflightPath) throw new Error('缺少 --preflight 。') + continue + } + if (arg === '--migration-id') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --migration-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 。') + continue + } + if (arg === '--expected-database-identity') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --expected-database-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 。') + 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 。') + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) throw new Error('缺少 --output 。') + 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 --migration-id --expected-database-identity --expected-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, +} diff --git a/furniture-3d-system/scripts/apply-prisma-structure-repair.js b/furniture-3d-system/scripts/apply-prisma-structure-repair.js new file mode 100644 index 00000000..3e5be6a6 --- /dev/null +++ b/furniture-3d-system/scripts/apply-prisma-structure-repair.js @@ -0,0 +1,1000 @@ +const crypto = require('node:crypto') +const fs = require('node:fs') +const path = require('node:path') +const { Pool } = require('pg') +const { + describeDatabaseUrl, + listMigrationDirectories, + loadDatabaseUrl, + loadShadowDatabaseUrl, + runCurrentSchemaDiff, + runMigrationDiffWithShadow, + runPrismaCommandWithEnv, +} = require('./diagnose-prisma-migration-baseline') + +const ROOT_DIR = path.resolve(__dirname, '..') +const CONFIRM_TOKEN = 'apply-prisma-structure-repair' +const PHASES = new Set(['plan', 'apply-structure', 'resolve-baseline']) + +function parseArgs(argv = process.argv.slice(2)) { + const options = { + phase: 'plan', + apply: false, + confirm: '', + preflightPath: '', + structureRehearsalPath: '', + repairSqlPath: '', + expectedSha256: '', + expectedDatabaseIdentity: '', + expectedBackupSha256: '', + maxPreflightAgeMinutes: 60, + 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 === '--phase') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --phase 。') + options.phase = value + index += 1 + continue + } + if (arg.startsWith('--phase=')) { + options.phase = arg.slice('--phase='.length) + continue + } + if (arg === '--preflight') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --preflight 。') + options.preflightPath = value + index += 1 + continue + } + if (arg.startsWith('--preflight=')) { + options.preflightPath = arg.slice('--preflight='.length) + continue + } + if (arg === '--structure-rehearsal') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --structure-rehearsal 。') + options.structureRehearsalPath = value + index += 1 + continue + } + if (arg.startsWith('--structure-rehearsal=')) { + options.structureRehearsalPath = arg.slice('--structure-rehearsal='.length) + continue + } + if (arg === '--repair-sql') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --repair-sql 。') + options.repairSqlPath = value + index += 1 + continue + } + if (arg.startsWith('--repair-sql=')) { + options.repairSqlPath = arg.slice('--repair-sql='.length) + continue + } + if (arg === '--expected-sha256') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --expected-sha256 。') + options.expectedSha256 = value + index += 1 + continue + } + if (arg.startsWith('--expected-sha256=')) { + options.expectedSha256 = arg.slice('--expected-sha256='.length) + continue + } + if (arg === '--expected-database-identity') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --expected-database-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 。') + options.expectedBackupSha256 = value + index += 1 + continue + } + if (arg.startsWith('--expected-backup-sha256=')) { + options.expectedBackupSha256 = arg.slice('--expected-backup-sha256='.length) + continue + } + if (arg === '--max-preflight-age-minutes') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --max-preflight-age-minutes 。') + options.maxPreflightAgeMinutes = Number(value) + index += 1 + continue + } + if (arg.startsWith('--max-preflight-age-minutes=')) { + options.maxPreflightAgeMinutes = Number(arg.slice('--max-preflight-age-minutes='.length)) + continue + } + if (arg === '--output') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error('缺少 --output 。') + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + continue + } + throw new Error(`未知参数:${arg}`) + } + + if (!PHASES.has(options.phase)) { + throw new Error(`未知 phase:${options.phase}`) + } + if (!Number.isFinite(options.maxPreflightAgeMinutes) || options.maxPreflightAgeMinutes <= 0) { + throw new Error(`--max-preflight-age-minutes 必须是正数:${options.maxPreflightAgeMinutes}`) + } + + 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 fileFingerprint(filePath, rootDir = ROOT_DIR) { + if (!filePath) return null + const absolutePath = path.resolve(rootDir, filePath) + if (!fs.existsSync(absolutePath)) { + return { + provided: true, + exists: false, + sizeBytes: 0, + sha256: '', + safety: summarizeSqlSafety(''), + } + } + const buffer = fs.readFileSync(absolutePath) + return { + provided: true, + exists: true, + sizeBytes: buffer.length, + sha256: crypto.createHash('sha256').update(buffer).digest('hex'), + safety: summarizeSqlSafety(buffer.toString('utf8')), + } +} + +function summarizeSqlSafety(sql) { + const text = String(sql || '') + const counts = { + dropTable: (text.match(/\bDROP\s+TABLE\b/gi) || []).length, + dropColumn: (text.match(/\bDROP\s+COLUMN\b/gi) || []).length, + dropType: (text.match(/\bDROP\s+TYPE\b/gi) || []).length, + dropSchema: (text.match(/\bDROP\s+SCHEMA\b/gi) || []).length, + clearTable: (text.match(new RegExp('\\bTRUN' + 'CATE\\b', 'gi')) || []).length, + } + return { + hasDestructiveStatements: + counts.dropTable > 0 || + counts.dropColumn > 0 || + counts.dropType > 0 || + counts.dropSchema > 0 || + counts.clearTable > 0, + destructiveStatementCounts: counts, + } +} + +function diffIsEmpty(diff) { + if (!diff) return false + if (diff.skipped) return false + if (diff.status === 0) return true + return /empty migration/i.test(String(diff.stdout || '')) && !/\b(?:CREATE|ALTER|DROP)\b/i.test(String(diff.stdout || '')) +} + +function migrationSchemaDiffEvidenceIsSatisfied(preflight, structureRehearsal) { + if (diffIsEmpty(preflight?.migrationSchemaDiff)) return true + + const skippedOnlyBecauseNoShadow = preflight?.migrationSchemaDiff?.skipped === true + if (!skippedOnlyBecauseNoShadow) return false + + return ( + structureRehearsal?.status === 'structure-repair-rehearsal-passed' && + diffIsEmpty(structureRehearsal?.currentSchemaDiffAfter) && + diffIsEmpty(structureRehearsal?.migrationDiffAfter) + ) +} + +function command(name, commandText, purpose, dangerous = false) { + return { + name, + command: commandText, + purpose, + dangerous, + } +} + +function commandArgs(name, args, purpose, dangerous = false) { + return { + name, + executable: 'node', + args, + purpose, + dangerous, + } +} + +function resolveRepairSqlFingerprint(options, structureRehearsal) { + const explicit = fileFingerprint(options.repairSqlPath) + if (explicit) return explicit + const artifactPath = structureRehearsal?.repairSqlArtifact?.path || '' + if (!artifactPath) return null + return fileFingerprint(artifactPath, path.parse(artifactPath).root) +} + +function databaseIdentity(database = {}) { + return [ + String(database.protocol || '').toLowerCase(), + String(database.username || '').toLowerCase(), + String(database.host || '').toLowerCase(), + String(database.port || ''), + String(database.databaseName || '').toLowerCase(), + String(database.schema || '').toLowerCase(), + ].join('|') +} + +function databaseMatches(left, right) { + if (!left || !right) return false + return databaseIdentity(left) === databaseIdentity(right) +} + +function reportAgeMinutes(checkedAt, nowIso) { + if (!checkedAt) return null + const checkedAtTime = Date.parse(checkedAt) + const nowTime = Date.parse(nowIso) + if (!Number.isFinite(checkedAtTime) || !Number.isFinite(nowTime)) return null + return Math.max(0, (nowTime - checkedAtTime) / 60000) +} + +function backupFingerprintFromPreflight(preflight = {}) { + const backupPath = preflight?.backupEvidence?.path || '' + if (!backupPath) { + return { + provided: false, + exists: false, + sizeBytes: 0, + sha256: '', + } + } + if (!fs.existsSync(backupPath)) { + return { + provided: true, + exists: false, + sizeBytes: 0, + sha256: '', + } + } + const buffer = fs.readFileSync(backupPath) + return { + provided: true, + exists: true, + 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 compareMigrationManifests(expected = [], actual = []) { + const expectedById = new Map(expected.map((item) => [item.migrationId, item])) + const actualById = new Map(actual.map((item) => [item.migrationId, item])) + const missing = expected.filter((item) => !actualById.has(item.migrationId)).map((item) => item.migrationId) + const extra = actual.filter((item) => !expectedById.has(item.migrationId)).map((item) => item.migrationId) + const changed = expected + .filter((item) => { + const current = actualById.get(item.migrationId) + return current && ( + current.exists !== item.exists || + current.sizeBytes !== item.sizeBytes || + current.sha256 !== item.sha256 + ) + }) + .map((item) => item.migrationId) + return { + matches: missing.length === 0 && extra.length === 0 && changed.length === 0, + expectedCount: expected.length, + actualCount: actual.length, + missing, + extra, + changed, + } +} + +function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, options) { + if (!preflight) { + blockers.push({ key: 'preflight.missing', message: '缺少真实施工前 preflight 报告。' }) + } + if (!structureRehearsal) { + blockers.push({ key: 'structureRehearsal.missing', message: '缺少克隆库结构修复演练报告。' }) + return + } + + if (preflight?.backupEvidence?.usable !== true) { + blockers.push({ key: 'backupEvidence', message: '真实施工前必须有可用的当前数据库备份。' }) + } + if (preflight?.backupVerification && preflight.backupVerification.ok !== true) { + blockers.push({ key: 'backupVerification', message: '当前数据库备份未通过 pg_restore 可读性校验。' }) + } + if (!migrationSchemaDiffEvidenceIsSatisfied(preflight, structureRehearsal)) { + blockers.push({ + key: 'migrationSchemaDiff.notEmpty', + message: 'migrations 重放结果必须先和 schema.prisma 对齐;如本机缺 shadow 库,必须提供已通过的克隆库结构修复演练证据。', + }) + } + if (structureRehearsal.status !== 'structure-repair-rehearsal-passed') { + blockers.push({ + key: 'structureRehearsal.notPassed', + message: '克隆库结构修复演练未通过,不能生成真实施工授权。', + }) + } + if (structureRehearsal.repairApply?.ok !== true) { + blockers.push({ key: 'repairApply.notOk', message: '结构修复 SQL 在克隆库未成功执行。' }) + } + if (structureRehearsal.repairSqlSummary?.hasDestructiveStatements === true) { + blockers.push({ + key: 'repairSql.destructive', + message: '结构修复 SQL 含 DROP 类破坏性语句,禁止自动授权真实施工。', + }) + } + if (!diffIsEmpty(structureRehearsal.currentSchemaDiffAfter)) { + blockers.push({ + key: 'currentSchemaDiffAfter.notEmpty', + message: '克隆库修复后,当前库到 schema.prisma 的 diff 仍不为空。', + }) + } + if (!diffIsEmpty(structureRehearsal.migrationDiffAfter)) { + blockers.push({ + key: 'migrationDiffAfter.notEmpty', + message: '克隆库修复后,migrations 到当前库的 diff 仍不为空。', + }) + } + + const baselineCount = structureRehearsal.baselineResolvePlan?.migrationCount ?? null + if (baselineCount == null || structureRehearsal.baselineResolvePlan?.candidate !== true) { + blockers.push({ key: 'baselineResolvePlan.notCandidate', message: '演练报告未给出可 baseline resolve 的迁移清单。' }) + } + if (structureRehearsal.baselineResolveRehearsal?.ok !== true) { + blockers.push({ key: 'baselineResolveRehearsal.notOk', message: '克隆库 baseline resolve 演练未通过。' }) + } + if ( + baselineCount != null && + structureRehearsal.baselineResolveRehearsal?.appliedCount != null && + structureRehearsal.baselineResolveRehearsal.appliedCount !== baselineCount + ) { + blockers.push({ + key: 'baselineResolveRehearsal.countMismatch', + message: 'baseline resolve 演练成功数量和迁移清单数量不一致。', + }) + } + if (preflight?.pendingMigrationCount != null && baselineCount != null && preflight.pendingMigrationCount !== baselineCount) { + blockers.push({ + key: 'preflight.pendingMigrationCountMismatch', + message: `preflight pending migration 数量为 ${preflight.pendingMigrationCount},演练 baseline 数量为 ${baselineCount}。`, + }) + } + + const artifact = structureRehearsal.repairSqlArtifact || null + if (!repairSql) { + blockers.push({ key: 'repairSql.missing', message: '缺少待执行结构修复 SQL 文件。' }) + } else if (!repairSql.exists) { + blockers.push({ key: 'repairSql.notFound', message: '待执行结构修复 SQL 文件不存在。' }) + } else if (repairSql.safety?.hasDestructiveStatements === true) { + blockers.push({ + key: 'repairSql.fileDestructive', + message: '待执行结构修复 SQL 文件本体含 DROP/清表类破坏性语句,禁止真实施工。', + }) + } else if (artifact) { + if (artifact.sizeBytes != null && repairSql.sizeBytes !== artifact.sizeBytes) { + blockers.push({ key: 'repairSql.sizeMismatch', message: '待执行 SQL 文件大小和演练 artifact 不一致。' }) + } + if (artifact.sha256 && repairSql.sha256 !== artifact.sha256) { + blockers.push({ key: 'repairSql.sha256Mismatch', message: '待执行 SQL 文件 sha256 和演练 artifact 不一致。' }) + } + } + if (options.expectedSha256 && repairSql?.sha256 !== options.expectedSha256) { + blockers.push({ key: 'repairSql.expectedSha256Mismatch', message: '待执行 SQL 文件 sha256 和命令行指定值不一致。' }) + } +} + +function addPhaseBlockers(blockers, preflight, structureRehearsal, options, context = {}) { + if (options.phase === 'plan') return + if (options.confirm !== CONFIRM_TOKEN) { + blockers.push({ key: 'confirm', message: `真实执行必须显式传入 --confirm=${CONFIRM_TOKEN}。` }) + } + if (!options.expectedSha256) { + blockers.push({ + key: 'expectedSha256.missing', + message: '真实执行必须显式传入 --expected-sha256,避免执行错的结构修复 SQL。', + }) + } + if (!context.actualDatabase) { + blockers.push({ + key: 'databaseIdentity.missing', + message: '真实执行必须先读取当前 DATABASE_URL 并和 preflight 数据库身份比对。', + }) + } else if (!databaseMatches(preflight?.database, context.actualDatabase)) { + blockers.push({ + key: 'databaseIdentity.mismatch', + message: '当前 DATABASE_URL 指向的数据库和 preflight 报告不一致,禁止施工。', + }) + } + const actualDatabaseIdentity = context.actualDatabase ? databaseIdentity(context.actualDatabase) : '' + if (!options.expectedDatabaseIdentity) { + blockers.push({ + key: 'expectedDatabaseIdentity.missing', + message: '真实执行必须显式传入 --expected-database-identity,避免误连错库。', + }) + } else if (options.expectedDatabaseIdentity !== actualDatabaseIdentity) { + blockers.push({ + key: 'expectedDatabaseIdentity.mismatch', + message: '命令行指定的目标库身份和当前 DATABASE_URL 不一致,禁止施工。', + }) + } + + const backupFingerprint = context.backupFingerprint || null + if (!backupFingerprint?.provided) { + blockers.push({ + key: 'backupFingerprint.missing', + message: '真实执行必须能从 preflight 报告重新定位备份文件并计算指纹。', + }) + } else if (!backupFingerprint.exists) { + blockers.push({ + key: 'backupFingerprint.notFound', + message: 'preflight 记录的备份文件已不存在,必须重新生成备份和 preflight。', + }) + } + if ( + backupFingerprint?.exists && + preflight?.backupEvidence?.sizeBytes != null && + backupFingerprint.sizeBytes !== preflight.backupEvidence.sizeBytes + ) { + blockers.push({ + key: 'backupFingerprint.sizeMismatch', + message: '备份文件当前大小和 preflight 记录不一致,必须重新生成备份和 preflight。', + }) + } + if ( + backupFingerprint?.sha256 && + preflight?.backupEvidence?.sha256 && + backupFingerprint.sha256 !== preflight.backupEvidence.sha256 + ) { + blockers.push({ + key: 'backupFingerprint.sha256Mismatch', + message: '备份文件当前 SHA-256 和 preflight 记录不一致,必须重新生成备份和 preflight。', + }) + } + if (!options.expectedBackupSha256) { + blockers.push({ + key: 'expectedBackupSha256.missing', + message: '真实执行必须显式传入 --expected-backup-sha256,避免错用备份文件。', + }) + } else if (backupFingerprint?.sha256 && options.expectedBackupSha256 !== backupFingerprint.sha256) { + blockers.push({ + key: 'expectedBackupSha256.mismatch', + message: '命令行指定的备份 SHA-256 和当前备份文件不一致,禁止施工。', + }) + } + + const rehearsalManifest = structureRehearsal?.migrationManifest || [] + const currentManifest = context.migrationManifest || [] + if (!Array.isArray(rehearsalManifest) || rehearsalManifest.length === 0) { + blockers.push({ + key: 'migrationManifest.missing', + message: '真实执行要求结构修复演练报告带 migration manifest,避免演练后迁移文件被改动。', + }) + } else { + const manifestComparison = compareMigrationManifests(rehearsalManifest, currentManifest) + if (!manifestComparison.matches) { + blockers.push({ + key: 'migrationManifest.mismatch', + message: '当前 prisma/migrations 文件清单或内容和结构修复演练时不一致,必须重新演练。', + value: { + expectedCount: manifestComparison.expectedCount, + actualCount: manifestComparison.actualCount, + missing: manifestComparison.missing.slice(0, 5), + extra: manifestComparison.extra.slice(0, 5), + changed: manifestComparison.changed.slice(0, 5), + }, + }) + } + } + + const preflightAge = reportAgeMinutes(preflight?.checkedAt, context.checkedAt) + if (preflightAge == null) { + blockers.push({ key: 'preflight.checkedAt', message: '真实执行要求 preflight 报告带 checkedAt 时间。' }) + } else if (preflightAge > options.maxPreflightAgeMinutes) { + blockers.push({ + key: 'preflight.stale', + value: Math.round(preflightAge), + message: `preflight 报告超过 ${options.maxPreflightAgeMinutes} 分钟,必须重新生成。`, + }) + } + + const rehearsalAge = reportAgeMinutes(structureRehearsal?.checkedAt, context.checkedAt) + if (rehearsalAge == null) { + blockers.push({ key: 'structureRehearsal.checkedAt', message: '真实执行要求结构修复演练报告带 checkedAt 时间。' }) + } else if (rehearsalAge > options.maxPreflightAgeMinutes) { + blockers.push({ + key: 'structureRehearsal.stale', + value: Math.round(rehearsalAge), + message: `结构修复演练报告超过 ${options.maxPreflightAgeMinutes} 分钟,必须重新演练或显式调整门禁。`, + }) + } + + if (options.phase === 'apply-structure') { + if (preflight?.recommendedAction !== 'baseline-and-structure-repair-required') { + blockers.push({ + key: 'preflight.recommendedAction', + message: '结构 SQL 写入阶段要求最新 preflight 仍为 baseline-and-structure-repair-required。', + }) + } + if (preflight?.currentSchemaDiffHasSql !== true) { + blockers.push({ + key: 'preflight.currentSchemaDiffHasSql', + message: '结构 SQL 写入阶段要求最新 preflight 仍证明当前库缺结构差异。', + }) + } + } + if (options.phase === 'resolve-baseline') { + if (preflight?.currentSchemaDiffHasSql === true) { + blockers.push({ + key: 'postRepairPreflight.currentSchemaDiffHasSql', + message: 'baseline resolve 前必须先证明真实库到 schema.prisma 已无结构 diff。', + }) + } + if (!diffIsEmpty(preflight?.migrationDiff)) { + blockers.push({ + key: 'postRepairPreflight.migrationDiff.notEmpty', + message: 'baseline resolve 前必须先证明 migrations 到真实库已无结构 diff。', + }) + } + const migrationIds = structureRehearsal?.baselineResolvePlan?.migrationIds || [] + if (migrationIds.length === 0) { + blockers.push({ key: 'baselineResolvePlan.migrationIds', message: '缺少逐项 baseline resolve 的 migrationId 清单。' }) + } + } +} + +function buildApplyStructureArgs({ + apply, + preflightPath, + structureRehearsalPath, + repairSqlPath, + repairSqlSha256, + databaseIdentityValue, + backupSha256, +}) { + const args = [ + 'scripts/apply-prisma-structure-repair.js', + '--phase=apply-structure', + ] + if (apply) args.push('--apply') + args.push( + `--confirm=${CONFIRM_TOKEN}`, + '--expected-sha256', + repairSqlSha256, + '--expected-database-identity', + databaseIdentityValue, + '--expected-backup-sha256', + backupSha256, + '--preflight', + preflightPath, + '--structure-rehearsal', + structureRehearsalPath, + '--repair-sql', + repairSqlPath, + ) + return args +} + +function buildExactPhaseCommands({ + preflight, + structureRehearsal, + repairSql, + options = parseArgs([]), +}) { + const repairSqlSha256 = repairSql?.sha256 || structureRehearsal?.repairSqlArtifact?.sha256 || '' + const databaseIdentityValue = preflight?.database ? databaseIdentity(preflight.database) : '' + const backupSha256 = preflight?.backupEvidence?.sha256 || '' + const preflightPath = options.preflightPath || '' + const structureRehearsalPath = options.structureRehearsalPath || '' + const repairSqlPath = options.repairSqlPath || structureRehearsal?.repairSqlArtifact?.path || '' + const missing = [] + + if (!repairSqlSha256) missing.push('repairSqlSha256') + if (!databaseIdentityValue) missing.push('databaseIdentity') + if (!backupSha256) missing.push('backupSha256') + if (!preflightPath) missing.push('preflightPath') + if (!structureRehearsalPath) missing.push('structureRehearsalPath') + if (!repairSqlPath) missing.push('repairSqlPath') + + if (missing.length > 0) { + return { + available: false, + missing, + applyStructureDryRun: null, + applyStructure: null, + resolveBaselineTemplate: null, + } + } + + return { + available: true, + missing: [], + applyStructureDryRun: commandArgs( + 'apply-structure-dry-run', + buildApplyStructureArgs({ + apply: false, + preflightPath, + structureRehearsalPath, + repairSqlPath, + repairSqlSha256, + databaseIdentityValue, + backupSha256, + }), + '真实写库前的最后复核:校验目标库身份、备份 SHA、SQL SHA、迁移清单和证据新鲜度,但不写库。', + ), + applyStructure: commandArgs( + 'apply-structure', + buildApplyStructureArgs({ + apply: true, + preflightPath, + structureRehearsalPath, + repairSqlPath, + repairSqlSha256, + databaseIdentityValue, + backupSha256, + }), + '取得明确授权后,写入已演练且指纹匹配的结构修复 SQL。', + true, + ), + resolveBaselineTemplate: commandArgs( + 'resolve-baseline-template', + [ + 'scripts/apply-prisma-structure-repair.js', + '--phase=resolve-baseline', + '--apply', + `--confirm=${CONFIRM_TOKEN}`, + '--expected-sha256', + repairSqlSha256, + '--expected-database-identity', + databaseIdentityValue, + '--expected-backup-sha256', + backupSha256, + '--preflight', + '', + '--structure-rehearsal', + structureRehearsalPath, + '--repair-sql', + repairSqlPath, + ], + '结构修复后重新生成 post-repair preflight,再用该模板逐项补齐 Prisma 迁移历史。', + true, + ), + } +} + +function buildAuthorizedStructureRepairPlan({ + preflight, + structureRehearsal, + repairSql, + options = parseArgs([]), + actualDatabase = null, + backupFingerprint = null, + migrationManifest = null, + checkedAt = new Date().toISOString(), +}) { + const blockers = [] + addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, options) + addPhaseBlockers(blockers, preflight, structureRehearsal, options, { + actualDatabase, + backupFingerprint, + migrationManifest, + checkedAt, + }) + + const baselineResolveMigrationIds = structureRehearsal?.baselineResolvePlan?.migrationIds || [] + const readyToApply = options.phase !== 'plan' && options.apply === true && blockers.length === 0 + const plannedDatabaseIdentity = + options.expectedDatabaseIdentity || + (preflight?.database ? databaseIdentity(preflight.database) : '') + const plannedBackupSha256 = + options.expectedBackupSha256 || preflight?.backupEvidence?.sha256 || '' + return { + mode: 'authorized-prisma-structure-repair', + phase: options.phase, + dryRun: !options.apply, + readyToApply, + confirmToken: CONFIRM_TOKEN, + checkedAt, + database: preflight?.database || null, + actualDatabase, + expectedDatabaseIdentity: plannedDatabaseIdentity, + evidenceFreshness: { + maxAgeMinutes: options.maxPreflightAgeMinutes, + preflightAgeMinutes: reportAgeMinutes(preflight?.checkedAt, checkedAt), + structureRehearsalAgeMinutes: reportAgeMinutes(structureRehearsal?.checkedAt, checkedAt), + }, + backupArtifact: backupFingerprint + ? { + provided: backupFingerprint.provided === true, + exists: backupFingerprint.exists === true, + sizeBytes: backupFingerprint.sizeBytes, + sha256: backupFingerprint.sha256, + } + : null, + expectedBackupSha256: plannedBackupSha256, + migrationManifestSummary: migrationManifest + ? { + count: migrationManifest.length, + sample: migrationManifest.slice(0, 5).map((item) => ({ + migrationId: item.migrationId, + exists: item.exists, + sizeBytes: item.sizeBytes, + sha256: item.sha256, + })), + } + : null, + repairSqlArtifact: repairSql + ? { + provided: repairSql.provided === true, + exists: repairSql.exists === true, + sizeBytes: repairSql.sizeBytes, + sha256: repairSql.sha256, + } + : null, + repairSqlSafety: repairSql?.safety || null, + expectedSha256: options.expectedSha256 || structureRehearsal?.repairSqlArtifact?.sha256 || '', + baselineResolveMigrationCount: baselineResolveMigrationIds.length, + baselineResolveMigrationSample: baselineResolveMigrationIds.slice(0, 8), + blockers, + exactPhaseCommands: buildExactPhaseCommands({ + preflight, + structureRehearsal, + repairSql, + options, + }), + commands: [ + command( + 'dry-run-plan', + 'node scripts/apply-prisma-structure-repair.js --preflight --structure-rehearsal --repair-sql --output ', + '真实施工前先生成授权执行计划,核对 SQL sha256、pending migration 数量和演练证据。', + ), + command( + 'apply-structure', + `node scripts/apply-prisma-structure-repair.js --phase=apply-structure --apply --confirm=${CONFIRM_TOKEN} --expected-sha256 --expected-database-identity --expected-backup-sha256 --preflight --structure-rehearsal --repair-sql `, + '仅写入已演练并校验 sha256 的结构修复 SQL;成功后重新跑 preflight。', + true, + ), + command( + 'resolve-baseline', + `node scripts/apply-prisma-structure-repair.js --phase=resolve-baseline --apply --confirm=${CONFIRM_TOKEN} --expected-sha256 --expected-database-identity --expected-backup-sha256 --preflight --structure-rehearsal --repair-sql `, + '真实库结构 diff 归零后,按演练清单逐项 baseline resolve。', + true, + ), + command( + 'apply-foundation-guards', + 'npm run db:foundation:apply-required -- --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity --expected-backup-sha256 ', + 'baseline 完成后,事务性应用治理迁移、外部视图和数据库护栏。', + true, + ), + ], + } +} + +function summarizeCommandResult(result) { + if (!result) return null + return { + ok: result.ok === true, + status: result.status ?? null, + stdoutPreview: String(result.stdout ?? '').slice(0, 1000), + stderrPreview: String(result.stderr ?? '').slice(0, 1000), + error: result.error || '', + } +} + +async function applyStructureSql(databaseUrl, sql) { + const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 }) + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query(sql) + await client.query('COMMIT') + return { ok: true, error: '' } + } catch (error) { + try { + await client.query('ROLLBACK') + } catch { + // ignore rollback failure; original error is more useful. + } + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + client.release() + await pool.end() + } +} + +function resolveBaselineMigrationIds(migrationIds, env = process.env) { + const results = [] + for (const migrationId of migrationIds) { + const result = runPrismaCommandWithEnv(['migrate', 'resolve', '--applied', migrationId], env) + results.push({ migrationId, ...result }) + if (result.status !== 0) break + } + return { + attemptedCount: results.length, + appliedCount: results.filter((result) => result.status === 0).length, + failedCount: results.filter((result) => result.status !== 0).length, + ok: results.length === migrationIds.length && results.every((result) => result.status === 0), + firstFailed: results.find((result) => result.status !== 0) || null, + } +} + +async function runApply(options = parseArgs(), env = process.env) { + const preflight = readJsonFile(options.preflightPath) + const structureRehearsal = readJsonFile(options.structureRehearsalPath) + const repairSql = resolveRepairSqlFingerprint(options, structureRehearsal) + const databaseUrl = options.phase === 'plan' ? '' : loadDatabaseUrl(ROOT_DIR, env) + const actualDatabase = databaseUrl ? describeDatabaseUrl(databaseUrl) : null + const backupFingerprint = options.phase === 'plan' ? null : backupFingerprintFromPreflight(preflight) + const migrationManifest = options.phase === 'plan' ? null : buildMigrationManifest(ROOT_DIR) + const plan = buildAuthorizedStructureRepairPlan({ + preflight, + structureRehearsal, + repairSql, + options, + actualDatabase, + backupFingerprint, + migrationManifest, + }) + + const reportBase = { + ...plan, + applied: null, + verificationAfterApply: null, + baselineResolve: null, + } + + if (!plan.readyToApply) return reportBase + + if (options.phase === 'apply-structure') { + const sqlPath = path.resolve(ROOT_DIR, options.repairSqlPath || structureRehearsal.repairSqlArtifact.path) + const sql = fs.readFileSync(sqlPath, 'utf8') + const applied = await applyStructureSql(databaseUrl, sql) + if (!applied.ok) { + return { + ...reportBase, + applied, + } + } + const verifiedEnv = { ...env, DATABASE_URL: databaseUrl } + const currentSchemaDiff = runCurrentSchemaDiff(verifiedEnv) + const migrationDiff = runMigrationDiffWithShadow(loadShadowDatabaseUrl(ROOT_DIR, env), databaseUrl) + return { + ...reportBase, + database: describeDatabaseUrl(databaseUrl), + applied, + verificationAfterApply: { + currentSchemaDiff: summarizeCommandResult(currentSchemaDiff), + migrationDiff: summarizeCommandResult(migrationDiff), + structureAligned: diffIsEmpty(currentSchemaDiff) && diffIsEmpty(migrationDiff), + }, + } + } + + if (options.phase === 'resolve-baseline') { + const migrationIds = structureRehearsal.baselineResolvePlan.migrationIds + const verifiedEnv = { ...env, DATABASE_URL: databaseUrl } + const baselineResolve = resolveBaselineMigrationIds(migrationIds, verifiedEnv) + const prismaMigrateStatus = runPrismaCommandWithEnv(['migrate', 'status'], verifiedEnv) + return { + ...reportBase, + baselineResolve, + prismaMigrateStatus: summarizeCommandResult(prismaMigrateStatus), + } + } + + return reportBase +} + +async function main() { + const options = parseArgs() + const report = await runApply(options) + writeJsonReport(options.outputPath, report) + console.log(JSON.stringify(report, null, 2)) + if ( + options.phase !== 'plan' && + ( + (Array.isArray(report.blockers) && report.blockers.length > 0) || + (options.apply && (report.applied?.ok === false || report.baselineResolve?.ok === false)) + ) + ) { + 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, + addCommonBlockers, + addPhaseBlockers, + backupFingerprintFromPreflight, + buildAuthorizedStructureRepairPlan, + buildExactPhaseCommands, + buildApplyStructureArgs, + buildMigrationManifest, + compareMigrationManifests, + databaseIdentity, + databaseMatches, + diffIsEmpty, + migrationSchemaDiffEvidenceIsSatisfied, + fileFingerprint, + parseArgs, + reportAgeMinutes, + resolveBaselineMigrationIds, + runApply, + summarizeSqlSafety, + writeJsonReport, +} diff --git a/furniture-3d-system/scripts/backfill-asset-variant-thumbnails.ts b/furniture-3d-system/scripts/backfill-asset-variant-thumbnails.ts index cee09225..6af417f2 100644 --- a/furniture-3d-system/scripts/backfill-asset-variant-thumbnails.ts +++ b/furniture-3d-system/scripts/backfill-asset-variant-thumbnails.ts @@ -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( 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() }) diff --git a/furniture-3d-system/scripts/backfill-sku-structural-data.ts b/furniture-3d-system/scripts/backfill-sku-structural-data.ts index 5e690c24..2b134913 100644 --- a/furniture-3d-system/scripts/backfill-sku-structural-data.ts +++ b/furniture-3d-system/scripts/backfill-sku-structural-data.ts @@ -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 | null - ) + try { + for (const sku of skus) { + scanned += 1 + const cadData = readSkuCadDataFromAttributes( + (sku.attributes ?? null) as Record | 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() - }) diff --git a/furniture-3d-system/scripts/cleanup-legacy-product-structure.ts b/furniture-3d-system/scripts/cleanup-legacy-product-structure.ts index a96778ec..7470ff2b 100644 --- a/furniture-3d-system/scripts/cleanup-legacy-product-structure.ts +++ b/furniture-3d-system/scripts/cleanup-legacy-product-structure.ts @@ -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([ diff --git a/furniture-3d-system/scripts/create-demo-data.js b/furniture-3d-system/scripts/create-demo-data.js index 37b29cc4..b291eddb 100644 --- a/furniture-3d-system/scripts/create-demo-data.js +++ b/furniture-3d-system/scripts/create-demo-data.js @@ -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() } } diff --git a/furniture-3d-system/scripts/create-postgres-backup.js b/furniture-3d-system/scripts/create-postgres-backup.js new file mode 100644 index 00000000..c68208df --- /dev/null +++ b/furniture-3d-system/scripts/create-postgres-backup.js @@ -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, +} diff --git a/furniture-3d-system/scripts/diagnose-prisma-migration-baseline.js b/furniture-3d-system/scripts/diagnose-prisma-migration-baseline.js new file mode 100644 index 00000000..503ee8dc --- /dev/null +++ b/furniture-3d-system/scripts/diagnose-prisma-migration-baseline.js @@ -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, +} diff --git a/furniture-3d-system/scripts/install-employee-directory-sync-timer.sh b/furniture-3d-system/scripts/install-employee-directory-sync-timer.sh index 1b68a88d..68d6adbe 100644 --- a/furniture-3d-system/scripts/install-employee-directory-sync-timer.sh +++ b/furniture-3d-system/scripts/install-employee-directory-sync-timer.sh @@ -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 diff --git a/furniture-3d-system/scripts/master-data-required-migrations.js b/furniture-3d-system/scripts/master-data-required-migrations.js new file mode 100644 index 00000000..b3bf7f41 --- /dev/null +++ b/furniture-3d-system/scripts/master-data-required-migrations.js @@ -0,0 +1,2002 @@ +const fs = require('node:fs') +const path = require('node:path') + +const ROOT_DIR = path.resolve(__dirname, '..') +const STANDARD_PRISMA_MIGRATION_DIR_PATTERN = /^\d{14}_[A-Za-z0-9_]+$/ +const LEGACY_NON_STANDARD_PRISMA_MIGRATION_DIRS = new Set([ + '20251213_performance_optimization', + 'add_packaging_plan', +]) +const TARGET_ONLY_READINESS_ASSURANCES = new Set([ + 'external-readonly-contract', + 'database-guard-summary', + 'foundation-quality-summary', +]) +const MIGRATION_GUARD_CHECK_TYPES = new Set([ + 'trigger', + 'check-constraint', + 'unique-index', + 'partial-unique-index', +]) + +function hasExplicitReadinessChecks(migration) { + return Boolean( + migration.columnCheck || + (Array.isArray(migration.columnDefaultChecks) && migration.columnDefaultChecks.length > 0) || + (Array.isArray(migration.guardChecks) && migration.guardChecks.length > 0) || + (Array.isArray(migration.relationColumnChecks) && migration.relationColumnChecks.length > 0) || + (Array.isArray(migration.dataChecks) && migration.dataChecks.length > 0), + ) +} + +const REQUIRED_MIGRATIONS = [ + { + targetRelation: 'external.product_skus_v1', + targetRelations: ['external.product_skus_v1', 'external.intake_products_v1'], + path: 'prisma/migrations/20260529213000_add_external_readonly_contract/migration.sql', + }, + { + targetRelation: '"IntakeNonIntegratedSkuItem"', + path: 'prisma/migrations/20260530093000_add_intake_non_integrated_sku_items/migration.sql', + guardChecks: [ + { + type: 'trigger', + name: 'IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items', + tableName: 'IntakeNonIntegratedProduct', + }, + ], + dataChecks: [ + { + name: 'IntakeNonIntegratedSkuItem_payload_missing_rows_zero', + expectedValue: 0, + sql: ` +WITH source_sku_items AS ( + SELECT + p."id" AS product_id, + 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 +) +SELECT COUNT(*)::integer AS row_count +FROM deduped_sku_items source +LEFT JOIN "IntakeNonIntegratedSkuItem" sku + ON sku."productId" = source.product_id + AND sku."skuItemId" = source.sku_item_id +WHERE sku."id" IS NULL +`, + }, + ], + repairSql: ` +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", + "createdAt", + "updatedAt" +) +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, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM normalized_sku_items +ON CONFLICT ("productId", "skuItemId") DO NOTHING; + +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; + +DROP TRIGGER IF EXISTS "IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items" + ON "IntakeNonIntegratedProduct"; + +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"(); +`, + }, + { + targetRelation: '"ProductSkuAssetProfile"', + targetRelations: ['"ProductSkuAssetProfile"', '"ProductSKU"'], + path: 'prisma/migrations/20260530103000_add_product_sku_asset_profile/migration.sql', + columnCheck: { + tableName: 'ProductSKU', + columnName: 'name', + }, + dataChecks: [ + { + name: 'ProductSKU_name_backfill_missing_zero', + expectedValue: 0, + sql: ` +SELECT COUNT(*)::integer AS row_count +FROM "ProductSKU" sku +JOIN "Product" product ON product."id" = sku."productId" +WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND NULLIF(btrim(COALESCE(sku."name", '')), '') IS NULL +`, + }, + { + name: 'ProductSkuAssetProfile_asset_snapshot_missing_rows_zero', + expectedValue: 0, + sql: ` +WITH sku_source AS ( + SELECT sku."id" AS sku_id + 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' +) +SELECT COUNT(*)::integer AS row_count +FROM sku_source source +LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = source.sku_id +WHERE profile."id" IS NULL +`, + }, + ], + repairSql: ` +UPDATE "ProductSKU" sku +SET "name" = COALESCE( + NULLIF(sku."attributes"::jsonb ->> 'skuName', ''), + NULLIF(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'specName', ''), + sku."skuCode" +) +FROM "Product" product +WHERE product."id" = sku."productId" + AND sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND NULLIF(btrim(COALESCE(sku."name", '')), '') IS NULL; + +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; +`, + }, + { + targetRelation: '"PreSkuBasicInfo"', + path: 'prisma/migrations/20260530130000_add_pre_sku_basic_info/migration.sql', + dataChecks: [ + { + name: 'PreSkuBasicInfo_display_summary_missing_rows_zero', + expectedValue: 0, + sql: ` +WITH pre_sku_source AS ( + SELECT pre_sku."id" + 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 + ) +) +SELECT COUNT(*)::integer AS row_count +FROM pre_sku_source source +LEFT JOIN "PreSkuBasicInfo" info ON info."preSkuId" = source."id" +WHERE info."id" IS NULL +`, + }, + ], + repairSql: ` +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; +`, + }, + { + targetRelation: '"ProductSkuQualityInspection"', + path: 'prisma/migrations/20260530143000_add_sku_quality_inspections/migration.sql', + columnCheck: { + tableName: 'ProductSkuQualityInspection', + columnName: 'recordKey', + }, + }, + { + targetRelation: 'external.product_skus_v1', + path: 'prisma/migrations/20260530143000_tighten_external_readonly_view_sources/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'external-readonly-contract', + }, + { + targetRelation: '"IntakeNonIntegratedProductProfile"', + path: 'prisma/migrations/20260530150000_add_intake_non_integrated_product_profile/migration.sql', + dataChecks: [ + { + name: 'IntakeNonIntegratedProductProfile_missing_rows_zero', + expectedValue: 0, + sql: ` +SELECT COUNT(*)::integer AS row_count +FROM "IntakeNonIntegratedProduct" product +LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" +WHERE product."deletedAt" IS NULL + AND profile."id" IS NULL +`, + }, + ], + repairSql: ` +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_' || product."id", + product."id", + COALESCE( + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productName'), ''), + NULLIF(btrim(product."payload"::jsonb ->> 'previewImageName'), ''), + product."spu" + ), + COALESCE( + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productImage'), ''), + NULLIF(btrim(product."payload"::jsonb ->> 'previewImage'), '') + ), + COALESCE( + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productCategory'), ''), + NULLIF(btrim(product."payload"::jsonb ->> 'productCategory'), ''), + '' + ), + COALESCE( + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'salesCategory'), ''), + NULLIF(btrim(product."payload"::jsonb ->> 'salesCategory'), ''), + '' + ), + COALESCE( + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'year'), ''), + NULLIF(btrim(product."payload"::jsonb ->> 'year'), ''), + '' + ), + COALESCE( + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'currentStage'), ''), + NULLIF(btrim(product."payload"::jsonb ->> 'projectStatus'), ''), + '' + ), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productDevelopmentKind'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'goodsType'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'materialType'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productionType'), ''), ''), + COALESCE( + NULLIF(btrim(product."payload"::jsonb ->> 'size'), ''), + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'size'), ''), + '' + ), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'color'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'tagMaterial'), ''), ''), + COALESCE( + NULLIF(btrim(product."payload"::jsonb ->> 'priceBand'), ''), + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'targetPriceBand'), ''), + '' + ), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandFlowNo'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'processTemplate'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productPlanning'), ''), + NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'planningOwner'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'brandPlanning'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandDepartment'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'spuOwner'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'operationsOwner'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'purchaseOwner'), ''), NULLIF(btrim(product."purchaser"), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualDirector'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualPlanning'), ''), ''), + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'structureDesigner'), ''), ''), + 'MIGRATED_FROM_PAYLOAD', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "IntakeNonIntegratedProduct" product +WHERE product."deletedAt" IS NULL +ON CONFLICT ("productId") DO NOTHING; +`, + }, + { + targetRelation: '"ProductPatentRecord"', + path: 'prisma/migrations/20260530162000_add_product_patent_records/migration.sql', + }, + { + targetRelation: '"IntakeNonIntegratedProductProfile"', + path: 'prisma/migrations/20260530172000_add_stock_source_fields_to_intake_product_profile/migration.sql', + columnCheck: { + tableName: 'IntakeNonIntegratedProductProfile', + columnName: 'sourceSpu', + }, + dataChecks: [ + { + name: 'IntakeNonIntegratedProductProfile_stock_source_payload_diff_zero', + expectedValue: 0, + sql: ` +SELECT COUNT(*)::integer AS row_count +FROM "IntakeNonIntegratedProductProfile" profile +JOIN "IntakeNonIntegratedProduct" product ON product."id" = profile."productId" +WHERE product."deletedAt" IS NULL + AND ( + COALESCE(profile."sourceSpu", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSpu'), ''), '') + OR COALESCE(profile."sourceSkuId", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSkuId'), ''), '') + OR COALESCE(profile."sourceCategory", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceCategory'), ''), '') + OR COALESCE(profile."sourceItemType", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceItemType'), ''), '') + OR COALESCE(profile."sourceSalesStatus", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') + OR COALESCE(profile."sourceEnabled", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceEnabled'), ''), '') + OR COALESCE(profile."sourceStockDisabled", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceStockDisabled'), ''), '') + OR profile."stockSkuCount" IS DISTINCT FROM CASE + WHEN jsonb_typeof(product."payload"::jsonb -> 'skuItems') = 'array' + THEN jsonb_array_length(product."payload"::jsonb -> 'skuItems') + ELSE 0 + END + OR profile."stockHasOnSaleSku" IS DISTINCT FROM ( + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') = '销售' + ) + ) +`, + }, + ], + repairSql: ` +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" + AND product."deletedAt" IS NULL; +`, + }, + { + targetRelation: '"IntakeReferenceLibraryItem"', + path: 'prisma/migrations/20260530190000_add_intake_reference_library/migration.sql', + }, + { + targetRelation: 'external.intake_sku_items_v1', + path: 'prisma/migrations/20260531103000_tighten_external_intake_views_to_structured_sources/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'external-readonly-contract', + }, + { + targetRelation: '"SpuNumberCounter"', + path: 'prisma/migrations/20260531113000_add_spu_number_counter/migration.sql', + dataChecks: [ + { + name: 'SpuNumberCounter_active_rule_missing_rows_zero', + expectedValue: 0, + sql: ` +SELECT COUNT(*)::integer AS row_count +FROM "IntakeReferenceLibraryItem" row +LEFT JOIN "SpuNumberCounter" counter ON counter."productCategory" = row."name" +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 + AND counter."id" IS NULL +`, + }, + ], + repairSql: ` +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; +`, + }, + { + targetRelation: '"IntakeNonIntegratedSkuItem"', + path: 'prisma/migrations/20260531143000_add_intake_master_data_check_constraints/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"SpuNumberCounter"', + path: 'prisma/migrations/20260531153000_add_spu_number_counter_check_constraints/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"IntakeReferenceLibraryItem"', + path: 'prisma/migrations/20260531162000_add_spu_rule_product_category_unique_guard/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: 'external.intake_products_v1', + path: 'prisma/migrations/20260531170000_tighten_external_intake_product_people_sources/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'external-readonly-contract', + }, + { + targetRelation: '"ProductSkuAssetProfile"', + path: 'prisma/migrations/20260531173000_add_product_sku_asset_profile_barcode_unique_guard/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ProductSKU"', + path: 'prisma/migrations/20260531181000_add_product_sku_active_code_marker_guard/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ProductSkuAssetProfile"', + path: 'prisma/migrations/20260531190000_add_product_sku_asset_profile_sku_deleted_guard/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'foundation-quality-summary', + }, + { + targetRelation: '"ProductSKU"', + path: 'prisma/migrations/20260531203000_normalize_core_code_identity_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ProductSkuAssetProfile"', + path: 'prisma/migrations/20260531213000_add_product_sku_asset_profile_identity_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ProductPatentRecord"', + path: 'prisma/migrations/20260531223000_add_product_patent_record_identity_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"IntakeReferenceLibraryItem"', + path: 'prisma/migrations/20260531233000_add_intake_reference_library_identity_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"SpuNumberCounter"', + path: 'prisma/migrations/20260531234000_add_spu_number_counter_identity_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"PreSkuBasicInfo"', + path: 'prisma/migrations/20260531235000_add_pre_sku_basic_info_extended_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"AssetSkuSyncEvent"', + path: 'prisma/migrations/20260601000000_add_asset_sku_sync_event_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ProductSkuAssetProfile"', + path: 'prisma/migrations/20260601001000_add_product_sku_asset_profile_source_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"PreSkuPackagingPlan"', + path: 'prisma/migrations/20260601002000_add_packaging_warehouse_foundation_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ApprovalWorkflow"', + path: 'prisma/migrations/20260601003000_add_approval_workflow_foundation_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"User"', + path: 'prisma/migrations/20260601004000_add_people_org_foundation_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"PlanningWorkspace"', + path: 'prisma/migrations/20260601005000_add_planning_foundation_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ProductCollaborator"', + path: 'prisma/migrations/20260601006000_add_share_upload_collaboration_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"Panel"', + path: 'prisma/migrations/20260601007000_add_legacy_cad_structure_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ProductCategory"', + path: 'prisma/migrations/20260601008000_add_reference_option_and_mapping_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"SystemConfig"', + path: 'prisma/migrations/20260601009000_add_config_audit_permission_guards/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: 'external.intake_sku_items_v1', + path: 'prisma/migrations/20260601010000_add_external_intake_stable_business_keys/migration.sql', + alwaysApply: true, + relationColumnChecks: [ + { + relationName: 'external.intake_products_v1', + columnNames: ['product_business_key'], + }, + { + relationName: 'external.intake_products_ready_v1', + columnNames: ['product_business_key'], + }, + { + relationName: 'external.intake_sku_items_v1', + columnNames: ['product_business_key', 'sku_business_key'], + }, + { + relationName: 'external.intake_sku_items_ready_v1', + columnNames: ['product_business_key', 'sku_business_key'], + }, + ], + }, + { + targetRelation: 'external.product_skus_v1', + path: 'prisma/migrations/20260601011000_add_external_product_sku_stable_business_keys/migration.sql', + alwaysApply: true, + relationColumnChecks: [ + { + relationName: 'external.product_skus_v1', + columnNames: ['product_business_key', 'sku_business_key'], + }, + ], + }, + { + targetRelation: 'external.product_assets_v1', + path: 'prisma/migrations/20260601012000_add_external_product_asset_bom_business_keys/migration.sql', + alwaysApply: true, + relationColumnChecks: [ + { + relationName: 'external.product_assets_v1', + columnNames: ['product_business_key', 'sku_business_key'], + }, + { + relationName: 'external.product_bom_v1', + columnNames: ['product_business_key', 'sku_business_key'], + }, + ], + }, + { + targetRelation: '"IntakeReferenceLibraryItem"', + path: 'prisma/migrations/20260601013000_add_spu_rule_normalized_category_guard/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"IntakeReferenceLibraryItem"', + path: 'prisma/migrations/20260601014000_add_reference_library_code_required_guard/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'database-guard-summary', + }, + { + targetRelation: '"ProductSKU"', + path: 'prisma/migrations/20260601015000_add_product_sku_soft_delete_identity_fields/migration.sql', + alwaysApply: true, + columnCheck: { + tableName: 'ProductSKU', + columnName: 'originalSkuCode', + }, + guardChecks: [ + { + type: 'check-constraint', + name: 'ProductSKU_soft_delete_identity_valid', + tableName: 'ProductSKU', + }, + { + type: 'check-constraint', + name: 'ProductSKU_no_soft_delete_lifecycle_attributes', + tableName: 'ProductSKU', + }, + ], + repairSql: ` +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' + ) + ); +`, + }, + { + targetRelation: 'external.materials_v1', + targetRelations: ['external.materials_v1', 'external.product_bom_v1'], + path: 'prisma/migrations/20260601016000_tighten_external_material_and_bom_contract/migration.sql', + alwaysApply: true, + targetOnlyReadinessAssurance: 'external-readonly-contract', + }, + { + targetRelation: '"Hardware"', + targetRelations: ['"Hardware"', '"Product"', '"ProductSKU"', '"Substrate"'], + path: 'prisma/migrations/20260601017000_align_migration_replay_to_current_schema/migration.sql', + columnCheck: { + tableName: 'Hardware', + columnName: 'geoWidth', + }, + columnDefaultChecks: [ + { + tableName: 'AssemblyStep', + columnName: 'partIds', + expectedDefault: null, + }, + { + tableName: 'AssemblyStep', + columnName: 'hardwareIds', + expectedDefault: null, + }, + ], + }, + { + targetRelation: '"MasterDataQualityDecision"', + path: 'prisma/migrations/20260601018000_add_master_data_quality_decisions/migration.sql', + guardChecks: [ + { + type: 'partial-unique-index', + name: 'MasterDataQualityDecision_open_target_key', + tableName: 'MasterDataQualityDecision', + predicatePattern: 'status = \'APPROVED_INCOMPLETE\'', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_target_not_blank', + tableName: 'MasterDataQualityDecision', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_status_allowed', + tableName: 'MasterDataQualityDecision', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_reason_not_blank', + tableName: 'MasterDataQualityDecision', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_decidedBy_not_blank', + tableName: 'MasterDataQualityDecision', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_resolved_state_valid', + tableName: 'MasterDataQualityDecision', + }, + ], + repairSql: ` +ALTER TABLE "MasterDataQualityDecision" + DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_target_not_blank", + DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_status_allowed", + DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_reason_not_blank", + DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_decidedBy_not_blank", + DROP CONSTRAINT IF EXISTS "MasterDataQualityDecision_resolved_state_valid", + ADD CONSTRAINT "MasterDataQualityDecision_target_not_blank" CHECK ( + NULLIF(btrim("targetType"), '') IS NOT NULL + AND NULLIF(btrim("targetId"), '') IS NOT NULL + ), + ADD CONSTRAINT "MasterDataQualityDecision_status_allowed" CHECK ( + "status" IN ('APPROVED_INCOMPLETE', 'RESOLVED') + ), + ADD CONSTRAINT "MasterDataQualityDecision_reason_not_blank" CHECK ( + NULLIF(btrim("reason"), '') IS NOT NULL + ), + ADD CONSTRAINT "MasterDataQualityDecision_decidedBy_not_blank" CHECK ( + NULLIF(btrim("decidedBy"), '') IS NOT NULL + ), + ADD 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 UNIQUE INDEX IF NOT EXISTS "MasterDataQualityDecision_open_target_key" + ON "MasterDataQualityDecision"("targetType", "targetId") + WHERE "status" = 'APPROVED_INCOMPLETE'; +`, + }, + { + targetRelation: '"ProductSKU"', + path: 'prisma/migrations/20260601019000_add_formal_cost_bom_guards/migration.sql', + alwaysApply: true, + guardChecks: [ + { + type: 'check-constraint', + name: 'ProductSKU_price_cost_stock_non_negative', + tableName: 'ProductSKU', + }, + ], + }, + { + targetRelation: '"AssetVariant"', + targetRelations: ['"AssetSeries"', '"AssetFamily"', '"AssetVariant"'], + path: 'prisma/migrations/20260601020000_add_asset_center_source_guards/migration.sql', + alwaysApply: true, + guardChecks: [ + { + type: 'check-constraint', + name: 'AssetSeries_identity_valid', + tableName: 'AssetSeries', + }, + { + type: 'check-constraint', + name: 'AssetSeries_metadata_object', + tableName: 'AssetSeries', + }, + { + type: 'check-constraint', + name: 'AssetFamily_identity_valid', + tableName: 'AssetFamily', + }, + { + type: 'check-constraint', + name: 'AssetFamily_metadata_object', + tableName: 'AssetFamily', + }, + { + type: 'check-constraint', + name: 'AssetFamily_lifecycle_state_valid', + tableName: 'AssetFamily', + }, + { + type: 'check-constraint', + name: 'AssetVariant_identity_valid', + tableName: 'AssetVariant', + }, + { + type: 'check-constraint', + name: 'AssetVariant_json_shape_valid', + tableName: 'AssetVariant', + }, + { + type: 'check-constraint', + name: 'AssetVariant_cost_weight_valid', + tableName: 'AssetVariant', + }, + { + type: 'check-constraint', + name: 'AssetVariant_lifecycle_state_valid', + tableName: 'AssetVariant', + }, + { + type: 'check-constraint', + name: 'AssetVariant_inheritance_not_self', + tableName: 'AssetVariant', + }, + ], + }, + { + targetRelation: '"PreSku"', + targetRelations: ['"DraftGroup"', '"PreSku"'], + path: 'prisma/migrations/20260601021000_add_draft_pre_sku_source_guards/migration.sql', + alwaysApply: true, + guardChecks: [ + { + type: 'check-constraint', + name: 'DraftGroup_identity_valid', + tableName: 'DraftGroup', + }, + { + type: 'check-constraint', + name: 'DraftGroup_summary_object', + tableName: 'DraftGroup', + }, + { + type: 'check-constraint', + name: 'DraftGroup_optional_text_not_blank', + tableName: 'DraftGroup', + }, + { + type: 'check-constraint', + name: 'PreSku_identity_valid', + tableName: 'PreSku', + }, + { + type: 'check-constraint', + name: 'PreSku_process_snapshots_shape_valid', + tableName: 'PreSku', + }, + { + type: 'check-constraint', + name: 'PreSku_weight_valid', + tableName: 'PreSku', + }, + { + type: 'check-constraint', + name: 'PreSku_component_codes_valid', + tableName: 'PreSku', + }, + { + type: 'check-constraint', + name: 'PreSku_optional_text_not_blank', + tableName: 'PreSku', + }, + ], + }, + { + targetRelation: '"ProductSKU"', + path: 'prisma/migrations/20260601022000_add_product_sku_attributes_truth_guard/migration.sql', + alwaysApply: true, + guardChecks: [ + { + type: 'check-constraint', + name: 'ProductSKU_no_formal_truth_attributes', + tableName: 'ProductSKU', + }, + ], + }, + { + targetRelation: '"ProductSKU"', + path: 'prisma/migrations/20260601023000_add_product_sku_asset_profile_deleted_at_trigger/migration.sql', + alwaysApply: true, + guardChecks: [ + { + type: 'trigger', + name: 'ProductSKU_sync_asset_profile_deleted_at', + tableName: 'ProductSKU', + }, + ], + }, + { + targetRelation: '"ProductSKU"', + path: 'prisma/migrations/20260601024000_add_product_sku_soft_delete_tombstone_guard/migration.sql', + alwaysApply: true, + guardChecks: [ + { + type: 'check-constraint', + name: 'ProductSKU_soft_delete_tombstone_valid', + tableName: 'ProductSKU', + }, + ], + repairSql: ` +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 + ) + ) + ) + ); +`, + }, + { + targetRelation: '"MasterDataQualityDecision"', + path: 'prisma/migrations/20260601025000_tighten_master_data_quality_decision_text_guards/migration.sql', + alwaysApply: true, + guardChecks: [ + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_reason_not_placeholder', + tableName: 'MasterDataQualityDecision', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_decidedBy_not_placeholder', + tableName: 'MasterDataQualityDecision', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_resolvedBy_not_placeholder', + tableName: 'MasterDataQualityDecision', + }, + ], + dataChecks: [ + { + name: 'master_data_quality_decision_placeholder_text_zero', + expectedValue: 0, + sql: ` +WITH placeholder_tokens AS ( + SELECT unnest(ARRAY[ + 'unknown', 'unk', 'n/a', 'na', 'null', 'none', 'todo', 'tbd', + 'default', 'test', '测试', '待补', '待补齐', '待确认', '未知', + '默认', '默认值', '暂无' + ]) AS token +) +SELECT COUNT(*)::integer AS row_count /* data-check */ +FROM "MasterDataQualityDecision" +WHERE ( + NULLIF(btrim(COALESCE("reason", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("reason"), '\\s+', '', 'g')) IN ( + SELECT token FROM placeholder_tokens + ) + OR regexp_replace(btrim("reason"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + ) + OR ( + NULLIF(btrim(COALESCE("decidedBy", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("decidedBy"), '\\s+', '', 'g')) IN ( + SELECT token FROM placeholder_tokens + ) + OR regexp_replace(btrim("decidedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + ) + OR ( + NULLIF(btrim(COALESCE("resolvedBy", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g')) IN ( + SELECT token FROM placeholder_tokens + ) + OR regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + ); +`, + }, + ], + repairSql: ` +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') !~ '(待补|待确认|默认值)' + ) + ); +`, + }, +] + +function getMigrationTimestamp(migrationPath) { + const match = String(migrationPath).match(/prisma\/migrations\/(\d{14})_[^/]+\/migration\.sql$/) + return match?.[1] ?? null +} + +function getMigrationTargetRelations(migration) { + if (Array.isArray(migration.targetRelations)) { + return migration.targetRelations + } + return migration.targetRelation ? [migration.targetRelation] : [] +} + +function getRelationSearchTerms(relationName) { + const raw = String(relationName) + const unquoted = raw.replace(/"/g, '') + const relationOnly = unquoted.split('.').pop() + return [...new Set([raw, unquoted, relationOnly].filter(Boolean).map((term) => term.toLowerCase()))] +} + +function validateRequiredMigrationCatalog(options = {}) { + const rootDir = options.rootDir ?? ROOT_DIR + const migrations = options.migrations ?? REQUIRED_MIGRATIONS + const errors = [] + const seenPaths = new Set() + let previousPath = '' + + validatePrismaMigrationDirectories({ rootDir }) + + migrations.forEach((migration, index) => { + const label = migration.path || `#${index + 1}` + if (!migration.path || typeof migration.path !== 'string') { + errors.push(`迁移清单第 ${index + 1} 项缺少 path。`) + return + } + if (seenPaths.has(migration.path)) { + errors.push(`迁移清单存在重复 path:${migration.path}`) + } + seenPaths.add(migration.path) + if (previousPath && migration.path < previousPath) { + errors.push(`迁移清单顺序必须按 path 升序排列:${previousPath} -> ${migration.path}`) + } + previousPath = migration.path + + if (!getMigrationTimestamp(migration.path)) { + errors.push(`迁移 path 不符合 prisma/migrations/_name/migration.sql:${migration.path}`) + } + + const targetRelations = getMigrationTargetRelations(migration) + if (targetRelations.length === 0) { + errors.push(`迁移缺少 targetRelation / targetRelations:${label}`) + } + if (migration.columnCheck) { + if (!migration.columnCheck.tableName || !migration.columnCheck.columnName) { + errors.push(`迁移 columnCheck 必须同时声明 tableName 和 columnName:${label}`) + } + const normalizedTargets = targetRelations + .map((relationName) => String(relationName).replace(/"/g, '').split('.').pop()) + if (!normalizedTargets.includes(migration.columnCheck.tableName)) { + errors.push(`迁移 columnCheck.tableName 必须属于 targetRelation:${label}`) + } + } + if (Array.isArray(migration.columnDefaultChecks)) { + migration.columnDefaultChecks.forEach((check, checkIndex) => { + if (!check.tableName || !check.columnName || !Object.hasOwn(check, 'expectedDefault')) { + errors.push(`迁移 columnDefaultChecks[${checkIndex}] 必须声明 tableName、columnName 和 expectedDefault:${label}`) + } + }) + } + if (Array.isArray(migration.guardChecks)) { + migration.guardChecks.forEach((check, checkIndex) => { + if (!MIGRATION_GUARD_CHECK_TYPES.has(check.type) || !check.name || !check.tableName) { + errors.push(`迁移 guardChecks[${checkIndex}] 必须声明 type、name 和 tableName:${label}`) + } + if (check.type === 'partial-unique-index' && !check.predicatePattern) { + errors.push(`迁移 guardChecks[${checkIndex}] 的 partial-unique-index 必须声明 predicatePattern:${label}`) + } + }) + } + if (Array.isArray(migration.relationColumnChecks)) { + migration.relationColumnChecks.forEach((check, checkIndex) => { + if (!check.relationName || !Array.isArray(check.columnNames) || check.columnNames.length === 0) { + errors.push(`迁移 relationColumnChecks[${checkIndex}] 必须声明 relationName 和 columnNames:${label}`) + } + }) + } + if (Array.isArray(migration.dataChecks)) { + migration.dataChecks.forEach((check, checkIndex) => { + if (!check.name || typeof check.sql !== 'string' || !Object.hasOwn(check, 'expectedValue')) { + errors.push(`迁移 dataChecks[${checkIndex}] 必须声明 name、sql 和 expectedValue:${label}`) + } + }) + } + if (migration.alwaysApply && !hasExplicitReadinessChecks(migration)) { + if (!TARGET_ONLY_READINESS_ASSURANCES.has(migration.targetOnlyReadinessAssurance)) { + errors.push( + `alwaysApply 迁移没有显式 readiness check 时,必须声明 targetOnlyReadinessAssurance:${label}`, + ) + } + } + + const absolutePath = path.join(rootDir, migration.path) + if (!fs.existsSync(absolutePath)) { + errors.push(`迁移文件不存在:${migration.path}`) + return + } + + const sql = fs.readFileSync(absolutePath, 'utf8').toLowerCase() + targetRelations.forEach((relationName) => { + const hasRelation = getRelationSearchTerms(relationName).some((term) => sql.includes(term)) + if (!hasRelation) { + errors.push(`迁移文件未引用声明的 targetRelation:${migration.path} -> ${relationName}`) + } + }) + }) + + if (errors.length > 0) { + throw new Error(`主数据治理迁移清单无效:\n${errors.join('\n')}`) + } + + return { + migrationCount: migrations.length, + structureMigrationCount: migrations.filter((migration) => !migration.alwaysApply).length, + guardMigrationCount: migrations.filter((migration) => migration.alwaysApply).length, + targetOnlyAlwaysApplyMigrationCount: migrations.filter( + (migration) => migration.alwaysApply && !hasExplicitReadinessChecks(migration), + ).length, + } +} + +function validatePrismaMigrationDirectories(options = {}) { + const rootDir = options.rootDir ?? ROOT_DIR + const migrationsDir = path.join(rootDir, 'prisma/migrations') + const errors = [] + + if (!fs.existsSync(migrationsDir)) { + throw new Error(`Prisma migrations 目录不存在:${path.relative(rootDir, migrationsDir)}`) + } + + const dirs = fs + .readdirSync(migrationsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + + dirs.forEach((dirName) => { + const migrationSqlPath = path.join(migrationsDir, dirName, 'migration.sql') + if (!fs.existsSync(migrationSqlPath)) { + errors.push(`Prisma migration 缺少 migration.sql:prisma/migrations/${dirName}`) + } + if ( + !STANDARD_PRISMA_MIGRATION_DIR_PATTERN.test(dirName) && + !LEGACY_NON_STANDARD_PRISMA_MIGRATION_DIRS.has(dirName) + ) { + errors.push( + `Prisma migration 目录名不符合 _name 且未登记为历史例外:prisma/migrations/${dirName}`, + ) + } + }) + + if (errors.length > 0) { + throw new Error(`Prisma migration 目录无效:\n${errors.join('\n')}`) + } + + return { + migrationDirectoryCount: dirs.length, + legacyNonStandardMigrationDirectories: dirs.filter((dirName) => + LEGACY_NON_STANDARD_PRISMA_MIGRATION_DIRS.has(dirName), + ), + } +} + +function classifyAppliedMigrationPaths(appliedMigrationPaths, migrations = REQUIRED_MIGRATIONS) { + const idempotentMigrationPaths = new Set( + migrations + .filter((migration) => migration.alwaysApply) + .map((migration) => migration.path), + ) + return { + structureMigrations: appliedMigrationPaths.filter( + (migrationPath) => !idempotentMigrationPaths.has(migrationPath), + ), + idempotentMigrations: appliedMigrationPaths.filter( + (migrationPath) => idempotentMigrationPaths.has(migrationPath), + ), + } +} + +function getPendingStructureMigrationPaths(appliedMigrationPaths, migrations = REQUIRED_MIGRATIONS) { + return classifyAppliedMigrationPaths(appliedMigrationPaths, migrations).structureMigrations +} + +async function relationExists(client, relationName) { + const result = await client.query('SELECT to_regclass($1) AS relation_name', [relationName]) + return result.rows[0]?.relation_name !== null +} + +async function columnExists(client, tableName, columnName) { + const result = await client.query(` + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name = $2 + LIMIT 1 + `, [tableName, columnName]) + return result.rowCount > 0 +} + +async function columnDefaultMatches(client, tableName, columnName, expectedDefault) { + const result = await client.query(` + SELECT column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name = $2 + LIMIT 1 + `, [tableName, columnName]) + if (result.rowCount === 0) return false + const actualDefault = result.rows[0]?.column_default ?? null + return actualDefault === expectedDefault +} + +function parseRelationName(relationName) { + const normalized = String(relationName).replace(/"/g, '') + const parts = normalized.split('.').filter(Boolean) + if (parts.length === 1) { + return { schemaName: 'public', tableName: parts[0] } + } + return { + schemaName: parts[parts.length - 2], + tableName: parts[parts.length - 1], + } +} + +async function relationColumnExists(client, relationName, columnName) { + const { schemaName, tableName } = parseRelationName(relationName) + const result = await client.query(` + SELECT 1 + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name = $2 + AND column_name = $3 + LIMIT 1 + `, [schemaName, tableName, columnName]) + return result.rowCount > 0 +} + +async function dataCheckPasses(client, check) { + let result + try { + result = await client.query(check.sql) + } catch (error) { + if (error?.code === '42P01') { + return false + } + throw error + } + const row = result.rows[0] ?? {} + const firstValue = Object.values(row)[0] + return firstValue === check.expectedValue +} + +function normalizeSqlFragment(value) { + return String(value ?? '') + .replace(/\s+/g, ' ') + .replace(/""/g, '"') + .trim() +} + +async function guardExists(client, guard) { + if (guard.type === 'trigger') { + const result = await client.query(` + SELECT 1 + FROM information_schema.triggers + WHERE event_object_schema = 'public' + AND event_object_table = $1 + AND trigger_name = $2 + LIMIT 1 + `, [guard.tableName, guard.name]) + return result.rowCount > 0 + } + if (guard.type === 'check-constraint') { + const result = await client.query(` + SELECT 1 + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + AND t.relname = $1 + AND c.conname = $2 + AND c.contype = 'c' + LIMIT 1 + `, [guard.tableName, guard.name]) + return result.rowCount > 0 + } + if (guard.type === 'unique-index' || guard.type === 'partial-unique-index') { + const result = await client.query(` + SELECT + idx.indisunique AS is_unique, + pg_get_expr(idx.indpred, idx.indrelid) AS predicate, + pg_get_indexdef(cls.oid) AS definition + FROM pg_index idx + JOIN pg_class cls ON cls.oid = idx.indexrelid + JOIN pg_class table_cls ON table_cls.oid = idx.indrelid + JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace + WHERE ns.nspname = 'public' + AND cls.relname = $1 + AND table_cls.relname = $2 + LIMIT 1 + `, [guard.name, guard.tableName]) + const row = result.rows[0] + const predicate = normalizeSqlFragment(row?.predicate) + const definition = normalizeSqlFragment(row?.definition) + const hasExpectedPredicate = + guard.type !== 'partial-unique-index' || + (predicate && predicate.includes(normalizeSqlFragment(guard.predicatePattern))) + const hasExpectedDefinition = + !guard.definitionPattern || definition.includes(normalizeSqlFragment(guard.definitionPattern)) + + return Boolean(row?.is_unique) && hasExpectedPredicate && hasExpectedDefinition + } + return false +} + +async function assertMigrationTargetReady(client, migration) { + const targetRelations = getMigrationTargetRelations(migration) + const relationChecks = await Promise.all( + targetRelations.map(async (relationName) => ({ + relationName, + exists: await relationExists(client, relationName), + })), + ) + const missingRelations = relationChecks + .filter((check) => !check.exists) + .map((check) => check.relationName) + + if (missingRelations.length > 0) { + throw new Error( + `治理迁移执行后未生成声明的 targetRelation:${migration.path} -> ${missingRelations.join(', ')}`, + ) + } + + if ( + migration.columnCheck && + !await columnExists(client, migration.columnCheck.tableName, migration.columnCheck.columnName) + ) { + throw new Error( + `治理迁移执行后未生成声明的 columnCheck:${migration.path} -> ${migration.columnCheck.tableName}.${migration.columnCheck.columnName}`, + ) + } + if (Array.isArray(migration.columnDefaultChecks)) { + for (const check of migration.columnDefaultChecks) { + if (!await columnDefaultMatches(client, check.tableName, check.columnName, check.expectedDefault)) { + throw new Error( + `治理迁移执行后未满足声明的 columnDefaultCheck:${migration.path} -> ${check.tableName}.${check.columnName}`, + ) + } + } + } + if (Array.isArray(migration.guardChecks)) { + for (const check of migration.guardChecks) { + if (!await guardExists(client, check)) { + throw new Error( + `治理迁移执行后未满足声明的 guardCheck:${migration.path} -> ${check.type}:${check.tableName}.${check.name}`, + ) + } + } + } + if (Array.isArray(migration.relationColumnChecks)) { + for (const check of migration.relationColumnChecks) { + for (const columnName of check.columnNames) { + if (!await relationColumnExists(client, check.relationName, columnName)) { + throw new Error( + `治理迁移执行后未满足声明的 relationColumnCheck:${migration.path} -> ${check.relationName}.${columnName}`, + ) + } + } + } + } + if (Array.isArray(migration.dataChecks)) { + for (const check of migration.dataChecks) { + if (!await dataCheckPasses(client, check)) { + throw new Error( + `治理迁移执行后未满足声明的 dataCheck:${migration.path} -> ${check.name}`, + ) + } + } + } +} + +async function applyMissingMigrationFiles(client, migrations = REQUIRED_MIGRATIONS) { + const applied = [] + const readinessByPath = new Map() + for (const migration of migrations) { + readinessByPath.set(migration.path, await getMigrationApplyReadiness(client, migration)) + } + + const shouldReplayIdempotentMigrations = Array.from(readinessByPath.values()).some( + (readiness) => !readiness.ready, + ) + + for (const migration of migrations) { + const readiness = readinessByPath.get(migration.path) + const shouldReplayCurrentIdempotent = Boolean( + migration.alwaysApply && + shouldReplayIdempotentMigrations && + !hasExplicitReadinessChecks(migration), + ) + + if (readiness.ready && !shouldReplayCurrentIdempotent) { + continue + } + + const canUseRepairSql = Boolean( + readiness.targetExists && + readiness.columnReady && + readiness.columnDefaultReady && + readiness.relationColumnReady && + !readiness.dataReady && + migration.repairSql, + ) + const canUseRepairSqlForGuard = Boolean( + readiness.targetExists && + readiness.columnReady && + readiness.columnDefaultReady && + readiness.relationColumnReady && + readiness.dataReady && + !readiness.guardReady && + migration.repairSql, + ) + const absolutePath = path.join(ROOT_DIR, migration.path) + await client.query( + canUseRepairSql || canUseRepairSqlForGuard + ? migration.repairSql + : fs.readFileSync(absolutePath, 'utf8'), + ) + await assertMigrationTargetReady(client, migration) + applied.push(migration.path) + } + + const replayedIdempotentMigrations = applied.some((migrationPath) => { + const migration = migrations.find((item) => item.path === migrationPath) + return migration?.alwaysApply + }) + if (!replayedIdempotentMigrations) { + return applied + } + + for (const migration of migrations.filter((item) => !item.alwaysApply)) { + const readiness = await getMigrationApplyReadiness(client, migration) + if (readiness.ready) continue + + const canUseRepairSql = Boolean( + readiness.targetExists && + readiness.columnReady && + readiness.columnDefaultReady && + readiness.relationColumnReady && + migration.repairSql, + ) + const absolutePath = path.join(ROOT_DIR, migration.path) + await client.query(canUseRepairSql ? migration.repairSql : fs.readFileSync(absolutePath, 'utf8')) + await assertMigrationTargetReady(client, migration) + if (!applied.includes(migration.path)) { + applied.push(migration.path) + } + } + return applied +} + +async function getMigrationApplyReadiness(client, migration) { + const targetRelations = getMigrationTargetRelations(migration) + const targetExists = ( + await Promise.all(targetRelations.map((relationName) => relationExists(client, relationName))) + ).every(Boolean) + const columnReady = !migration.columnCheck || + await columnExists(client, migration.columnCheck.tableName, migration.columnCheck.columnName) + const columnDefaultReady = await migrationColumnDefaultChecksPass(client, migration) + const guardReady = await migrationGuardChecksPass(client, migration) + const relationColumnReady = await migrationRelationColumnChecksPass(client, migration) + const dataReady = targetExists + ? await migrationDataChecksPass(client, migration) + : false + return { + targetExists, + columnReady, + columnDefaultReady, + guardReady, + relationColumnReady, + dataReady, + ready: + targetExists && + columnReady && + columnDefaultReady && + guardReady && + relationColumnReady && + dataReady, + } +} + +async function migrationColumnDefaultChecksPass(client, migration) { + if (!Array.isArray(migration.columnDefaultChecks) || migration.columnDefaultChecks.length === 0) { + return true + } + const results = await Promise.all( + migration.columnDefaultChecks.map((check) => + columnDefaultMatches(client, check.tableName, check.columnName, check.expectedDefault), + ), + ) + return results.every(Boolean) +} + +async function migrationGuardChecksPass(client, migration) { + if (!Array.isArray(migration.guardChecks) || migration.guardChecks.length === 0) { + return true + } + const results = await Promise.all(migration.guardChecks.map((check) => guardExists(client, check))) + return results.every(Boolean) +} + +async function migrationRelationColumnChecksPass(client, migration) { + if (!Array.isArray(migration.relationColumnChecks) || migration.relationColumnChecks.length === 0) { + return true + } + const results = [] + for (const check of migration.relationColumnChecks) { + for (const columnName of check.columnNames) { + results.push(await relationColumnExists(client, check.relationName, columnName)) + } + } + return results.every(Boolean) +} + +async function migrationDataChecksPass(client, migration) { + if (!Array.isArray(migration.dataChecks) || migration.dataChecks.length === 0) { + return true + } + const results = [] + for (const check of migration.dataChecks) { + results.push(await dataCheckPasses(client, check)) + } + return results.every(Boolean) +} + +module.exports = { + REQUIRED_MIGRATIONS, + LEGACY_NON_STANDARD_PRISMA_MIGRATION_DIRS, + applyMissingMigrationFiles, + assertMigrationTargetReady, + classifyAppliedMigrationPaths, + columnDefaultMatches, + columnExists, + dataCheckPasses, + guardExists, + hasExplicitReadinessChecks, + getPendingStructureMigrationPaths, + getMigrationTargetRelations, + getMigrationTimestamp, + relationColumnExists, + relationExists, + validateRequiredMigrationCatalog, + validatePrismaMigrationDirectories, +} diff --git a/furniture-3d-system/scripts/master-data-truth-sources.js b/furniture-3d-system/scripts/master-data-truth-sources.js new file mode 100644 index 00000000..23fbc8e3 --- /dev/null +++ b/furniture-3d-system/scripts/master-data-truth-sources.js @@ -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, +} diff --git a/furniture-3d-system/scripts/performance-test.ts b/furniture-3d-system/scripts/performance-test.ts index 86e0db12..bee4b4a2 100644 --- a/furniture-3d-system/scripts/performance-test.ts +++ b/furniture-3d-system/scripts/performance-test.ts @@ -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): 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(); }); diff --git a/furniture-3d-system/scripts/plan-pre-sku-formalization-repair.js b/furniture-3d-system/scripts/plan-pre-sku-formalization-repair.js new file mode 100644 index 00000000..468982eb --- /dev/null +++ b/furniture-3d-system/scripts/plan-pre-sku-formalization-repair.js @@ -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 。') + } + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('缺少 --output 。') + } + continue + } + if (arg === '--plan') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --plan 。') + } + options.planPath = value + index += 1 + continue + } + if (arg.startsWith('--plan=')) { + options.planPath = arg.slice('--plan='.length) + if (!options.planPath) { + throw new Error('缺少 --plan 。') + } + 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 。') + } + 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 。') + } + 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, +} diff --git a/furniture-3d-system/scripts/plan-prisma-baseline-repair.js b/furniture-3d-system/scripts/plan-prisma-baseline-repair.js new file mode 100644 index 00000000..968ec540 --- /dev/null +++ b/furniture-3d-system/scripts/plan-prisma-baseline-repair.js @@ -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 。') + } + options.preflightPath = value + index += 1 + continue + } + if (arg.startsWith('--preflight=')) { + options.preflightPath = arg.slice('--preflight='.length) + if (!options.preflightPath) { + throw new Error('缺少 --preflight 。') + } + continue + } + if (arg === '--rehearsal') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --rehearsal 。') + } + options.rehearsalPath = value + index += 1 + continue + } + if (arg.startsWith('--rehearsal=')) { + options.rehearsalPath = arg.slice('--rehearsal='.length) + if (!options.rehearsalPath) { + throw new Error('缺少 --rehearsal 。') + } + continue + } + if (arg === '--structure-rehearsal') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --structure-rehearsal 。') + } + 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 。') + } + continue + } + if (arg === '--output') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --output 。') + } + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('缺少 --output 。') + } + 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 --expected-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 --migration-id --expected-database-identity --expected-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/.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 --expected-database-identity --expected-backup-sha256 --preflight --structure-rehearsal --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 --expected-database-identity --expected-backup-sha256 --preflight --structure-rehearsal --repair-sql ', + '结构等价后由授权施工脚本按演练清单逐项补迁移历史;目标库、备份和数量必须和演练报告一致。', + true, + ), + command( + 'apply-foundation-guards', + 'npm run db:foundation:apply-required -- --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity --expected-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, +} diff --git a/furniture-3d-system/scripts/preflight-database-foundation-change.js b/furniture-3d-system/scripts/preflight-database-foundation-change.js new file mode 100644 index 00000000..39571352 --- /dev/null +++ b/furniture-3d-system/scripts/preflight-database-foundation-change.js @@ -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 。') + } + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('缺少 --output 。') + } + 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, +} diff --git a/furniture-3d-system/scripts/print-master-data-foundation-checklist.js b/furniture-3d-system/scripts/print-master-data-foundation-checklist.js new file mode 100644 index 00000000..351e9a5f --- /dev/null +++ b/furniture-3d-system/scripts/print-master-data-foundation-checklist.js @@ -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 。') + } + options.manifestFile = value + index += 1 + continue + } + if (arg.startsWith('--manifest=')) { + options.manifestFile = arg.slice('--manifest='.length) + if (!options.manifestFile) { + throw new Error('缺少 --manifest 。') + } + continue + } + if (arg === '--output') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --output 。') + } + options.outputFile = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputFile = arg.slice('--output='.length) + if (!options.outputFile) { + throw new Error('缺少 --output 。') + } + continue + } + if (arg === '--max-age-minutes') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --max-age-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 。') + } + 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, +} diff --git a/furniture-3d-system/scripts/print-master-data-foundation-decision-package.js b/furniture-3d-system/scripts/print-master-data-foundation-decision-package.js new file mode 100644 index 00000000..a0806495 --- /dev/null +++ b/furniture-3d-system/scripts/print-master-data-foundation-decision-package.js @@ -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 。') + } + options.manifestFile = value + index += 1 + continue + } + if (arg.startsWith('--manifest=')) { + options.manifestFile = arg.slice('--manifest='.length) + if (!options.manifestFile) { + throw new Error('缺少 --manifest 。') + } + continue + } + if (arg === '--output') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --output 。') + } + options.outputFile = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputFile = arg.slice('--output='.length) + if (!options.outputFile) { + throw new Error('缺少 --output 。') + } + 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 --output --repair-sql-output ', + '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 --expected-database-identity --expected-backup-sha256 --preflight --structure-rehearsal --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 --expected-database-identity --expected-backup-sha256 --preflight --structure-rehearsal --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 --expected-database-identity --expected-backup-sha256 --preflight --structure-rehearsal --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 --expected-backup-sha256 ', + 'npm run db:foundation:apply-required -- --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity --expected-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 ', + 'npm run db:cleanup-master-data -- --plan ', + 'npm run db:cleanup-master-data -- --apply --plan --expected-database-identity --expected-backup-sha256 ', + 'npm run db:foundation:apply-pre-sku-formalization-repair -- --apply --confirm=I_UNDERSTAND_THIS_UPDATES_PRE_SKU_LIFECYCLE --plan --expected-database-identity --expected-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, +} diff --git a/furniture-3d-system/scripts/prisma-db-push-local.js b/furniture-3d-system/scripts/prisma-db-push-local.js new file mode 100644 index 00000000..fa534ff2 --- /dev/null +++ b/furniture-3d-system/scripts/prisma-db-push-local.js @@ -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, +} diff --git a/furniture-3d-system/scripts/rehearse-database-foundation-migration.js b/furniture-3d-system/scripts/rehearse-database-foundation-migration.js new file mode 100644 index 00000000..7ae70e51 --- /dev/null +++ b/furniture-3d-system/scripts/rehearse-database-foundation-migration.js @@ -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 。') + } + options.backupPath = value + index += 1 + continue + } + if (arg.startsWith('--backup=')) { + options.backupPath = arg.slice('--backup='.length) + if (!options.backupPath) { + throw new Error('缺少 --backup 。') + } + continue + } + if (arg === '--output') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --output 。') + } + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('缺少 --output 。') + } + continue + } + if (arg === '--docker-port') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --docker-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 。') + } + 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, +} diff --git a/furniture-3d-system/scripts/rehearse-prisma-structure-repair.js b/furniture-3d-system/scripts/rehearse-prisma-structure-repair.js new file mode 100644 index 00000000..f9852a17 --- /dev/null +++ b/furniture-3d-system/scripts/rehearse-prisma-structure-repair.js @@ -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 。') + } + options.backupPath = value + index += 1 + continue + } + if (arg.startsWith('--backup=')) { + options.backupPath = arg.slice('--backup='.length) + if (!options.backupPath) { + throw new Error('缺少 --backup 。') + } + continue + } + if (arg === '--output') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --output 。') + } + 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 。') + } + 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 。') + } + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('缺少 --output 。') + } + continue + } + if (arg === '--docker-port') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --docker-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 。') + } + 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, +} diff --git a/furniture-3d-system/scripts/run-database-foundation-migrations.js b/furniture-3d-system/scripts/run-database-foundation-migrations.js new file mode 100644 index 00000000..f55f8940 --- /dev/null +++ b/furniture-3d-system/scripts/run-database-foundation-migrations.js @@ -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 。') + } + 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 。') + } + 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 。') + } + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('缺少 --output 。') + } + 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 --expected-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, +} diff --git a/furniture-3d-system/scripts/run-master-data-foundation-audit.js b/furniture-3d-system/scripts/run-master-data-foundation-audit.js new file mode 100644 index 00000000..adb38ca7 --- /dev/null +++ b/furniture-3d-system/scripts/run-master-data-foundation-audit.js @@ -0,0 +1,1639 @@ +const fs = require('node:fs') +const path = require('node:path') +const { spawnSync } = require('node:child_process') + +const ROOT_DIR = path.resolve(__dirname, '..') + +function timestampForDirectory(date = new Date()) { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z') +} + +function defaultOutputDir(now = new Date()) { + return path.join('.master-data-audits', `foundation-audit-${timestampForDirectory(now)}`) +} + +function parseArgs(argv = process.argv.slice(2), env = process.env) { + const options = { + includeCleanupTemplate: env.MASTER_DATA_AUDIT_INCLUDE_CLEANUP_TEMPLATE === '1', + includeMigrationRehearsal: env.MASTER_DATA_AUDIT_INCLUDE_MIGRATION_REHEARSAL === '1', + includeStructureRepairRehearsal: + env.MASTER_DATA_AUDIT_INCLUDE_STRUCTURE_REPAIR_REHEARSAL === '1', + outputDir: env.MASTER_DATA_AUDIT_DIR || defaultOutputDir(), + useDockerShadow: env.MASTER_DATA_AUDIT_USE_DOCKER_SHADOW === '1', + dockerShadowPort: env.MASTER_DATA_AUDIT_DOCKER_SHADOW_PORT || '65436', + dockerRehearsalPort: env.MASTER_DATA_AUDIT_DOCKER_REHEARSAL_PORT || '65447', + dockerStructureRepairPort: + env.MASTER_DATA_AUDIT_DOCKER_STRUCTURE_REPAIR_PORT || '65448', + dockerShadowImage: env.POSTGRES_DOCKER_IMAGE || 'postgres:16', + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--output-dir') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --output-dir 。') + } + options.outputDir = value + index += 1 + continue + } + if (arg.startsWith('--output-dir=')) { + options.outputDir = arg.slice('--output-dir='.length) + if (!options.outputDir) { + throw new Error('缺少 --output-dir 。') + } + continue + } + if (arg === '--include-cleanup-template') { + options.includeCleanupTemplate = true + continue + } + if (arg === '--include-migration-rehearsal') { + options.includeMigrationRehearsal = true + continue + } + if (arg === '--include-structure-repair-rehearsal') { + options.includeStructureRepairRehearsal = true + continue + } + if (arg === '--use-docker-shadow') { + options.useDockerShadow = true + continue + } + if (arg === '--docker-shadow-port') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --docker-shadow-port 。') + } + options.dockerShadowPort = value + index += 1 + continue + } + if (arg.startsWith('--docker-shadow-port=')) { + options.dockerShadowPort = arg.slice('--docker-shadow-port='.length) + if (!options.dockerShadowPort) { + throw new Error('缺少 --docker-shadow-port 。') + } + continue + } + if (arg === '--docker-rehearsal-port') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --docker-rehearsal-port 。') + } + options.dockerRehearsalPort = value + index += 1 + continue + } + if (arg.startsWith('--docker-rehearsal-port=')) { + options.dockerRehearsalPort = arg.slice('--docker-rehearsal-port='.length) + if (!options.dockerRehearsalPort) { + throw new Error('缺少 --docker-rehearsal-port 。') + } + continue + } + if (arg === '--docker-structure-repair-port') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('缺少 --docker-structure-repair-port 。') + } + options.dockerStructureRepairPort = value + index += 1 + continue + } + if (arg.startsWith('--docker-structure-repair-port=')) { + options.dockerStructureRepairPort = arg.slice('--docker-structure-repair-port='.length) + if (!options.dockerStructureRepairPort) { + throw new Error('缺少 --docker-structure-repair-port 。') + } + continue + } + throw new Error(`未知参数:${arg}`) + } + + return { + includeCleanupTemplate: options.includeCleanupTemplate, + includeMigrationRehearsal: options.includeMigrationRehearsal, + includeStructureRepairRehearsal: options.includeStructureRepairRehearsal, + outputDir: path.resolve(ROOT_DIR, options.outputDir), + useDockerShadow: options.useDockerShadow, + dockerShadowPort: String(options.dockerShadowPort), + dockerRehearsalPort: String(options.dockerRehearsalPort), + dockerStructureRepairPort: String(options.dockerStructureRepairPort), + dockerShadowImage: options.dockerShadowImage, + } +} + +function auditSteps(outputDir, options = {}) { + const steps = [ + { + id: 'readiness', + label: '地基总就绪度', + outputFile: path.join(outputDir, 'readiness.json'), + args: ['scripts/verify-master-data-readiness.js', '--output', path.join(outputDir, 'readiness.json')], + }, + { + id: 'foundation-hard-ready-gate', + label: '地基总门禁', + outputFile: path.join(outputDir, 'foundation-hard-ready-gate.json'), + sensitive: true, + args: [ + 'scripts/verify-master-data-foundation.js', + '--output', + path.join(outputDir, 'foundation-hard-ready-gate.json'), + ], + }, + { + id: 'truth-sources', + label: '核心主数据真相源矩阵', + outputFile: path.join(outputDir, 'truth-sources.json'), + args: [ + 'scripts/verify-master-data-truth-sources.js', + '--output', + path.join(outputDir, 'truth-sources.json'), + ], + }, + { + id: 'code-boundaries', + label: '代码层主数据边界', + outputFile: path.join(outputDir, 'code-boundaries.json'), + args: ['scripts/verify-master-data-code-boundaries.js', '--output', path.join(outputDir, 'code-boundaries.json')], + }, + { + id: 'compatibility-snapshot-read', + label: '兼容快照读点专项', + outputFile: path.join(outputDir, 'compatibility-snapshot-read.json'), + args: [ + 'scripts/verify-master-data-code-boundaries.js', + '--mode', + 'compatibility-snapshot-read', + '--output', + path.join(outputDir, 'compatibility-snapshot-read.json'), + ], + }, + { + id: 'process-snapshot-read', + label: '过程快照读点专项', + outputFile: path.join(outputDir, 'process-snapshot-read.json'), + args: [ + 'scripts/verify-master-data-code-boundaries.js', + '--mode', + 'process-snapshot-read', + '--output', + path.join(outputDir, 'process-snapshot-read.json'), + ], + }, + { + id: 'product-sku-attributes-write', + label: '正式 SKU attributes 写入专项', + outputFile: path.join(outputDir, 'product-sku-attributes-write.json'), + args: [ + 'scripts/verify-master-data-code-boundaries.js', + '--mode', + 'product-sku-attributes-write', + '--output', + path.join(outputDir, 'product-sku-attributes-write.json'), + ], + }, + { + id: 'reference-estimator-cost-boundary', + label: '参考图预估成本边界专项', + outputFile: path.join(outputDir, 'reference-estimator-cost-boundary.json'), + args: [ + 'scripts/verify-master-data-code-boundaries.js', + '--mode', + 'reference-estimator-cost-boundary', + '--output', + path.join(outputDir, 'reference-estimator-cost-boundary.json'), + ], + }, + { + id: 'filesystem-master-data-source', + label: '文件系统反向造数边界专项', + outputFile: path.join(outputDir, 'filesystem-master-data-source.json'), + args: [ + 'scripts/verify-master-data-code-boundaries.js', + '--mode', + 'filesystem-master-data-source', + '--output', + path.join(outputDir, 'filesystem-master-data-source.json'), + ], + }, + { + id: 'foundation-preflight', + label: '结构变更前置预检', + outputFile: path.join(outputDir, 'foundation-preflight.json'), + args: [ + 'scripts/preflight-database-foundation-change.js', + '--output', + path.join(outputDir, 'foundation-preflight.json'), + ], + }, + { + id: 'foundation-migrate-plan', + label: '治理迁移执行计划', + outputFile: path.join(outputDir, 'foundation-migrate-plan.json'), + args: [ + 'scripts/run-database-foundation-migrations.js', + '--output', + path.join(outputDir, 'foundation-migrate-plan.json'), + ], + }, + { + id: 'foundation-required-migration-apply-plan', + label: '治理迁移/护栏应用计划', + outputFile: path.join(outputDir, 'foundation-required-migration-apply-plan.json'), + args: [ + 'scripts/apply-master-data-foundation-migrations.js', + '--output', + path.join(outputDir, 'foundation-required-migration-apply-plan.json'), + ], + }, + { + id: 'pre-sku-formalization-repair-plan', + label: '准 SKU 正式化生命周期修复计划', + outputFile: path.join(outputDir, 'pre-sku-formalization-repair-plan.json'), + sensitive: true, + args: [ + 'scripts/plan-pre-sku-formalization-repair.js', + '--output', + path.join(outputDir, 'pre-sku-formalization-repair-plan.json'), + ], + }, + { + id: 'pre-sku-formalization-repair-plan-validation', + label: '准 SKU 正式化修复计划当前证据校验', + outputFile: path.join(outputDir, 'pre-sku-formalization-repair-plan-validation.json'), + args: [ + 'scripts/plan-pre-sku-formalization-repair.js', + '--validate-plan', + '--plan', + path.join(outputDir, 'pre-sku-formalization-repair-plan.json'), + '--output', + path.join(outputDir, 'pre-sku-formalization-repair-plan-validation.json'), + ], + }, + { + id: 'pre-sku-formalization-repair-execution-template', + label: '准 SKU 正式化生命周期执行模板', + outputFile: path.join(outputDir, 'pre-sku-formalization-repair-execution-template.json'), + sensitive: true, + args: [ + 'scripts/apply-pre-sku-formalization-repair.js', + '--template', + '--output', + path.join(outputDir, 'pre-sku-formalization-repair-execution-template.json'), + ], + }, + ] + if (options.includeCleanupTemplate) { + steps.push({ + id: 'cleanup-template', + label: '历史质量债清洗模板', + outputFile: path.join(outputDir, 'cleanup-template.json'), + sensitive: true, + args: [ + 'scripts/apply-master-data-cleanup-plan.js', + '--template', + '--allow-migration-dry-run', + '--output', + path.join(outputDir, 'cleanup-template.json'), + ], + }) + } + if (options.includeMigrationRehearsal) { + steps.push({ + id: 'migration-rehearsal', + label: '克隆库迁移演练', + outputFile: path.join(outputDir, 'migration-rehearsal.json'), + args: [ + 'scripts/rehearse-database-foundation-migration.js', + '--output', + path.join(outputDir, 'migration-rehearsal.json'), + '--docker-port', + String(options.dockerRehearsalPort || '65447'), + ], + }) + } + if (options.includeStructureRepairRehearsal) { + steps.push({ + id: 'structure-repair-rehearsal', + label: '克隆库结构修复演练', + outputFile: path.join(outputDir, 'structure-repair-rehearsal.json'), + args: [ + 'scripts/rehearse-prisma-structure-repair.js', + '--output', + path.join(outputDir, 'structure-repair-rehearsal.json'), + '--repair-sql-output', + path.join(outputDir, 'structure-repair.sql'), + '--docker-port', + String(options.dockerStructureRepairPort || '65448'), + ], + }) + } + const baselineRepairArgs = [ + 'scripts/plan-prisma-baseline-repair.js', + '--preflight', + path.join(outputDir, 'foundation-preflight.json'), + ] + if (options.includeMigrationRehearsal) { + baselineRepairArgs.push('--rehearsal', path.join(outputDir, 'migration-rehearsal.json')) + } + if (options.includeStructureRepairRehearsal) { + baselineRepairArgs.push( + '--structure-rehearsal', + path.join(outputDir, 'structure-repair-rehearsal.json'), + ) + } + baselineRepairArgs.push('--output', path.join(outputDir, 'baseline-repair-plan.json')) + steps.push({ + id: 'baseline-repair-plan', + label: 'baseline 与结构修复施工图', + outputFile: path.join(outputDir, 'baseline-repair-plan.json'), + args: baselineRepairArgs, + }) + if (options.includeStructureRepairRehearsal) { + steps.push({ + id: 'authorized-structure-repair-apply-plan', + label: '授权结构修复执行计划', + outputFile: path.join(outputDir, 'authorized-structure-repair-apply-plan.json'), + args: [ + 'scripts/apply-prisma-structure-repair.js', + '--preflight', + path.join(outputDir, 'foundation-preflight.json'), + '--structure-rehearsal', + path.join(outputDir, 'structure-repair-rehearsal.json'), + '--repair-sql', + path.join(outputDir, 'structure-repair.sql'), + '--output', + path.join(outputDir, 'authorized-structure-repair-apply-plan.json'), + ], + }) + } + return steps +} + +function readJsonFile(filePath) { + if (!fs.existsSync(filePath)) return null + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function runAuditStep(step, env = process.env) { + const result = spawnSync(process.execPath, step.args, { + cwd: ROOT_DIR, + encoding: 'utf8', + env: { ...process.env, ...env }, + windowsHide: true, + }) + const outputExists = fs.existsSync(step.outputFile) + let data = null + let jsonError = '' + try { + data = readJsonFile(step.outputFile) + } catch (error) { + jsonError = error instanceof Error ? error.message : String(error) + } + + return { + id: step.id, + label: step.label, + command: [process.execPath, ...step.args].join(' '), + outputFile: step.outputFile, + outputExists, + sensitive: Boolean(step.sensitive), + ok: result.status === 0, + status: result.status, + data, + jsonError, + error: result.error ? String(result.error.message ?? result.error) : '', + stdoutPreview: String(result.stdout ?? '').slice(0, 500), + stderrPreview: String(result.stderr ?? '').slice(0, 500), + } +} + +function runDocker(args, env = process.env) { + return spawnSync('docker', args, { + cwd: ROOT_DIR, + encoding: 'utf8', + env: { ...process.env, ...env }, + windowsHide: true, + }) +} + +function assertDockerCommandOk(result, label) { + if (result.status !== 0) { + const stderr = String(result.stderr ?? '').trim() + const stdout = String(result.stdout ?? '').trim() + const detail = stderr || stdout || result.error?.message || '无输出' + throw new Error(`${label} 失败:${detail}`) + } +} + +function createDockerShadowDatabase(options = {}, env = process.env) { + const port = String(options.dockerShadowPort || '65436') + if (!/^\d+$/.test(port)) { + throw new Error(`Docker shadow 端口必须是数字:${port}`) + } + + const image = options.dockerShadowImage || env.POSTGRES_DOCKER_IMAGE || 'postgres:16' + const containerName = `f3d-foundation-audit-shadow-${Date.now()}-${process.pid}` + const password = `foundation_shadow_${process.pid}` + const databaseName = 'furniture_3d_shadow' + + const dockerVersion = runDocker(['--version'], env) + assertDockerCommandOk(dockerVersion, '检查 Docker') + + const inspect = runDocker(['image', 'inspect', image], env) + assertDockerCommandOk(inspect, `检查 Docker 镜像 ${image}`) + + const cleanup = () => { + runDocker(['rm', '-f', containerName], env) + } + + try { + assertDockerCommandOk( + runDocker([ + 'run', + '-d', + '--name', + containerName, + '-e', + `POSTGRES_PASSWORD=${password}`, + '-p', + `${port}:5432`, + image, + ], env), + '启动 Docker shadow PostgreSQL', + ) + + let ready = false + for (let index = 0; index < 40; index += 1) { + const psqlReady = runDocker([ + 'exec', + '-e', + `PGPASSWORD=${password}`, + containerName, + 'psql', + '-U', + 'postgres', + '-d', + 'postgres', + '-c', + 'SELECT 1;', + ], env) + if (psqlReady.status === 0) { + ready = true + break + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500) + } + if (!ready) { + throw new Error('Docker shadow PostgreSQL 启动超时。') + } + + assertDockerCommandOk( + runDocker([ + 'exec', + '-e', + `PGPASSWORD=${password}`, + containerName, + 'psql', + '-U', + 'postgres', + '-d', + 'postgres', + '-c', + `CREATE DATABASE ${databaseName};`, + ], env), + '创建 Docker shadow 数据库', + ) + + return { + shadowDatabaseUrl: `postgresql://postgres:${password}@localhost:${port}/${databaseName}?schema=public`, + containerName, + cleanup, + } + } catch (error) { + cleanup() + throw error + } +} + +function summarizeBlockerValue(value) { + if (value === null || value === undefined) return true + if (typeof value === 'number' || typeof value === 'boolean') return value + if (Array.isArray(value)) return value.length + return true +} + +function collectBlockers(stepResults) { + const blockers = [] + for (const result of stepResults) { + const blockerValue = (value) => (result.sensitive ? summarizeBlockerValue(value) : value) + const blockerMessage = (message) => + result.sensitive ? `${result.label} 存在敏感审计阻断项,详情仅保留在本地敏感报告。` : message + if (!result.ok) { + blockers.push({ + source: result.id, + key: 'command', + value: result.status, + message: `${result.label} 未通过。`, + }) + } + const data = result.data + if (Array.isArray(data?.blockers)) { + data.blockers.forEach((blocker) => { + blockers.push({ + source: blocker.source ?? result.id, + key: blocker.key ?? 'blocker', + value: blockerValue(blocker.value), + message: blockerMessage(blocker.message ?? `${result.label} 存在阻断项。`), + }) + }) + } + if (Array.isArray(data?.diagnosisBlockers)) { + data.diagnosisBlockers.forEach((blocker) => { + blockers.push({ + source: `${result.id}:diagnosis`, + key: blocker.key ?? 'diagnosisBlocker', + value: blockerValue(blocker.value), + message: blockerMessage(blocker.message ?? `${result.label} 存在诊断阻断项。`), + }) + }) + } + if (Array.isArray(data?.foundationHardReadyGate?.blockers)) { + data.foundationHardReadyGate.blockers.forEach((blocker) => { + blockers.push({ + source: `foundation-hard-ready-gate:${blocker.source ?? 'unknown'}`, + key: blocker.key ?? 'blocker', + value: blockerValue(blocker.value), + message: blockerMessage(blocker.message ?? `${result.label} 存在 hard-ready 阻断项。`), + }) + }) + } + if (!result.outputExists) { + blockers.push({ + source: result.id, + key: 'outputFile', + value: result.outputFile, + message: `${result.label} 没有写出审计 JSON 文件。`, + }) + } else if (result.jsonError) { + blockers.push({ + source: result.id, + key: 'outputJson', + value: result.jsonError, + message: `${result.label} 审计 JSON 无法解析。`, + }) + } + } + return blockers +} + +function uniqueBlockers(blockers) { + return blockers.filter((blocker, index, list) => { + const key = `${blocker.source}:${blocker.key}:${blocker.message}` + return list.findIndex((item) => `${item.source}:${item.key}:${item.message}` === key) === index + }) +} + +function countBlankFillFields(items = []) { + const counts = {} + for (const item of items) { + const fill = item?.fill && typeof item.fill === 'object' ? item.fill : {} + for (const [key, value] of Object.entries(fill)) { + if (value === '' || value == null) { + counts[key] = (counts[key] || 0) + 1 + } + } + } + return counts +} + +function normalizeCleanupFieldKey(field) { + if (typeof field !== 'string' || !field) return '' + const aliases = { + barcode: 'barcode', + development_kind: 'productDevelopmentKind', + model: 'model', + product_category: 'productCategory', + product_development_kind: 'productDevelopmentKind', + product_name: 'productName', + sku_code: 'skuCode', + spec_name: 'specName', + } + if (aliases[field]) return aliases[field] + return field.replace(/_([a-z])/g, (_, char) => char.toUpperCase()) +} + +function cleanupMissingFieldKeys(item) { + const current = item?.current && typeof item.current === 'object' ? item.current : {} + const rawFields = [ + ...(Array.isArray(current.missingRequiredFields) ? current.missingRequiredFields : []), + ...(Array.isArray(current.missingFields) ? current.missingFields : []), + ] + return new Set(rawFields.map(normalizeCleanupFieldKey).filter(Boolean)) +} + +function countBlankMissingFillFields(items = []) { + const counts = {} + for (const item of items) { + const fill = item?.fill && typeof item.fill === 'object' ? item.fill : {} + const missingKeys = cleanupMissingFieldKeys(item) + const candidateKeys = missingKeys.size > 0 + ? Object.keys(fill).filter((key) => missingKeys.has(key)) + : Object.keys(fill) + for (const key of candidateKeys) { + const value = fill[key] + if (value === '' || value == null) { + counts[key] = (counts[key] || 0) + 1 + } + } + } + return counts +} + +function countByField(items = [], fieldName) { + const counts = {} + for (const item of items) { + const value = typeof item?.[fieldName] === 'string' ? item[fieldName] : '' + const key = value || 'UNKNOWN' + counts[key] = (counts[key] || 0) + 1 + } + return counts +} + +function countQualityDecisionPlaceholderFields(items = []) { + const counts = {} + for (const item of items) { + const placeholderFields = Array.isArray(item?.current?.placeholderFields) + ? item.current.placeholderFields + : [] + for (const field of placeholderFields) { + const key = typeof field === 'string' && field ? field : 'UNKNOWN' + counts[key] = (counts[key] || 0) + 1 + } + } + return counts +} + +function countReadinessBlockerPairs(blockers = []) { + const counts = {} + for (const blocker of blockers) { + const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN' + const key = typeof blocker?.key === 'string' && blocker.key ? blocker.key : 'UNKNOWN' + const pairKey = `${source}:${key}` + counts[pairKey] = (counts[pairKey] || 0) + 1 + } + return counts +} + +function countObjectValues(values = {}) { + return Object.values(values).reduce((total, value) => { + const number = Number(value) + return Number.isFinite(number) ? total + number : total + }, 0) +} + +function buildCleanupTemplateSummary(cleanupTemplate) { + if (!cleanupTemplate) return null + const skuItems = cleanupTemplate.skuItems || cleanupTemplate.items?.skuItems || [] + const productProfiles = cleanupTemplate.productProfiles || cleanupTemplate.items?.productProfiles || [] + const qualityDecisionRepairs = + cleanupTemplate.qualityDecisionRepairs || + cleanupTemplate.items?.qualityDecisionRepairs || + [] + const preSkuFormalizationMismatches = + cleanupTemplate.preSkuFormalizationMismatches || + cleanupTemplate.items?.preSkuFormalizationMismatches || + [] + const migrationStatus = cleanupTemplate.migrationApplicationStatus || {} + const skuBlankFillFieldCounts = countBlankMissingFillFields(skuItems) + const productProfileBlankFillFieldCounts = countBlankMissingFillFields(productProfiles) + const qualityDecisionRepairPlaceholderFieldCounts = + countQualityDecisionPlaceholderFields(qualityDecisionRepairs) + const blankMissingFillFieldCount = + countObjectValues(skuBlankFillFieldCounts) + + countObjectValues(productProfileBlankFillFieldCounts) + const placeholderDecisionFieldCount = countObjectValues( + qualityDecisionRepairPlaceholderFieldCounts, + ) + const requiresBusinessConfirmation = + blankMissingFillFieldCount > 0 || + placeholderDecisionFieldCount > 0 || + preSkuFormalizationMismatches.length > 0 + return { + mode: cleanupTemplate.mode || '', + readyToHardReady: !requiresBusinessConfirmation, + requiresBusinessConfirmation, + blankMissingFillFieldCount, + skuItemCount: skuItems.length, + skuBlankFillFieldCounts, + productProfileCount: productProfiles.length, + productProfileBlankFillFieldCounts, + qualityDecisionRepairCount: qualityDecisionRepairs.length, + qualityDecisionRepairPlaceholderFieldCounts, + preSkuFormalizationMismatchCount: preSkuFormalizationMismatches.length, + preSkuFormalizationMismatchReasonCounts: countByField( + preSkuFormalizationMismatches, + 'mismatchReason', + ), + migrationApplicationStatus: { + source: 'transactional-dry-run-replay', + appliedMigrationCount: migrationStatus.appliedMigrationCount ?? null, + strictReady: migrationStatus.strictReady === true, + fullyReady: migrationStatus.fullyReady === true, + pendingStructureMigrationCount: migrationStatus.pendingStructureMigrationCount ?? null, + replayableIdempotentMigrationCount: + migrationStatus.replayableIdempotentMigrationCount ?? null, + }, + } +} + +function buildReadinessSummary(readiness) { + if (!readiness) return null + const nextActionPlan = readiness.nextActionPlan || {} + const stages = Array.isArray(nextActionPlan.stages) ? nextActionPlan.stages : [] + const blockers = Array.isArray(readiness.blockers) ? readiness.blockers : [] + return { + status: readiness.status || '', + hardReady: readiness.hardReady === true, + blockerCount: readiness.blockerCount ?? null, + blockerSourceCounts: countByField(blockers, 'source'), + blockerKeyCounts: countByField(blockers, 'key'), + blockerPairCounts: countReadinessBlockerPairs(blockers), + nextActionStageCount: nextActionPlan.stageCount ?? stages.length, + nextActionStages: stages.map((stage) => ({ + id: stage.id || '', + status: stage.status || '', + reason: stage.reason || '', + commands: Array.isArray(stage.commands) ? stage.commands : [], + guard: stage.guard || '', + })), + } +} + +function buildFoundationHardReadyGateSummary(report) { + if (!report) return null + const gate = report.foundationHardReadyGate || {} + const blockers = Array.isArray(gate.blockers) ? gate.blockers : [] + const migrationStatusSource = report.migrationApplicationStatus + ? report.mode === 'current' + ? 'current-database' + : report.mode === 'migration-dry-run' || report.mode === 'idempotent-migration-replay' + ? 'transactional-dry-run-replay' + : 'reported' + : null + return { + hardReady: gate.hardReady === true, + blockerCount: gate.blockerCount ?? blockers.length, + blockerSourceCounts: gate.blockerSourceCounts || countByField(blockers, 'source'), + blockerKeys: blockers.map((blocker) => { + const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN' + const key = typeof blocker?.key === 'string' && blocker.key ? blocker.key : 'UNKNOWN' + return `${source}:${key}` + }), + blockerValues: blockers.reduce((values, blocker) => { + const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN' + const key = typeof blocker?.key === 'string' && blocker.key ? blocker.key : 'UNKNOWN' + values[`${source}:${key}`] = summarizeBlockerValue(blocker?.value) + return values + }, {}), + rolledBack: report.rolledBack === true, + migrationApplicationStatus: report.migrationApplicationStatus + ? { + source: migrationStatusSource, + appliedMigrationCount: report.migrationApplicationStatus.appliedMigrationCount ?? null, + pendingStructureMigrationCount: + report.migrationApplicationStatus.pendingStructureMigrationCount ?? null, + replayableIdempotentMigrationCount: + report.migrationApplicationStatus.replayableIdempotentMigrationCount ?? null, + strictReady: report.migrationApplicationStatus.strictReady === true, + fullyReady: report.migrationApplicationStatus.fullyReady === true, + } + : null, + } +} + +function buildMigrationRehearsalSummary(migrationRehearsal) { + if (!migrationRehearsal) return null + return { + status: migrationRehearsal.status || '', + restoreOk: migrationRehearsal.restore?.ok === true, + migrateDeployOk: migrationRehearsal.migrateDeploy?.ok === true, + p3005Detected: migrationRehearsal.migrateDeploy?.p3005Detected === true, + dockerCleanupStatus: migrationRehearsal.docker?.cleanupStatus || '', + blockerKeys: Array.isArray(migrationRehearsal.blockers) + ? migrationRehearsal.blockers.map((blocker) => blocker.key).filter(Boolean) + : [], + } +} + +function buildBaselineRepairPlanSummary(baselineRepairPlan) { + if (!baselineRepairPlan) return null + return { + status: baselineRepairPlan.status || '', + recommendedAction: baselineRepairPlan.recommendedAction || '', + safeForProductionWrite: baselineRepairPlan.safeForProductionWrite === true, + blockerKeys: Array.isArray(baselineRepairPlan.blockers) + ? baselineRepairPlan.blockers.map((blocker) => blocker.key).filter(Boolean) + : [], + evidence: { + backupUsable: baselineRepairPlan.evidence?.backupUsable === true, + backupVerified: baselineRepairPlan.evidence?.backupVerified ?? null, + migrationSchemaDiffStatus: baselineRepairPlan.evidence?.migrationSchemaDiffStatus || '', + migrationDiffStatus: baselineRepairPlan.evidence?.migrationDiffStatus || '', + currentSchemaDiffHasSql: + baselineRepairPlan.evidence?.currentSchemaDiffHasSql === true, + pendingMigrationCount: baselineRepairPlan.evidence?.pendingMigrationCount ?? null, + directMigrateDeployBlockedByP3005: + baselineRepairPlan.evidence?.directMigrateDeployBlockedByP3005 === true, + }, + } +} + +function buildFoundationRequiredMigrationApplyPlanSummary(applyPlan) { + if (!applyPlan) return null + return { + mode: applyPlan.mode || '', + dryRun: applyPlan.dryRun !== false, + readyToApply: applyPlan.readyToApply === true, + pendingMigrationCount: applyPlan.pendingMigrationCount ?? null, + pendingStructureMigrationCount: applyPlan.pendingStructureMigrationCount ?? null, + pendingIdempotentMigrationCount: applyPlan.pendingIdempotentMigrationCount ?? null, + migrationHistoryReady: + applyPlan.migrationHistory?.tableExists === true && + applyPlan.migrationHistory?.unfinishedMigrationRows === 0 && + applyPlan.migrationHistory?.rolledBackMigrationRows === 0, + blockerKeys: Array.isArray(applyPlan.blockers) + ? applyPlan.blockers.map((blocker) => blocker.key).filter(Boolean) + : [], + } +} + +function buildPreSkuFormalizationRepairPlanSummary(plan) { + if (!plan) return null + const candidates = Array.isArray(plan.candidates) ? plan.candidates : [] + const requiredEvidenceKeys = Array.isArray(plan.evidencePolicy?.requiredCurrentEvidenceKeys) + ? plan.evidencePolicy.requiredCurrentEvidenceKeys + : [] + const decisionOptionCounts = candidates.reduce((counts, candidate) => { + const decisions = Array.isArray(candidate?.allowedDecisions) + ? candidate.allowedDecisions + : [] + decisions.forEach((item) => { + const key = item?.decision || 'UNKNOWN' + counts[key] = (counts[key] || 0) + 1 + }) + return counts + }, {}) + + return { + mode: plan.mode || '', + readOnly: plan.readOnly === true, + applySupported: plan.applySupported === true, + databaseIdentityPresent: Boolean(plan.databaseIdentity), + candidateCount: plan.candidateCount ?? candidates.length, + reasonCounts: plan.reasonCounts || {}, + requiredCurrentEvidenceKeyCount: requiredEvidenceKeys.length, + candidateEvidenceFingerprintCount: candidates.filter((candidate) => + Boolean(candidate?.evidenceFingerprint), + ).length, + decisionOptionCounts, + globalGuardCount: Array.isArray(plan.globalGuards) ? plan.globalGuards.length : 0, + } +} + +function buildPreSkuFormalizationRepairPlanValidationSummary(report) { + if (!report) return null + return { + mode: report.mode || '', + valid: report.valid === true, + committed: report.committed === true, + requiresDatabase: report.requiresDatabase === true, + databaseIdentityMatched: report.databaseIdentityMatched === true, + candidateCount: report.candidateCount ?? null, + matchedCandidateCount: report.matchedCandidateCount ?? null, + staleCandidateCount: report.staleCandidateCount ?? null, + blockerCount: report.blockerCount ?? null, + blockerKeys: Array.isArray(report.blockers) + ? report.blockers.map((blocker) => blocker.key).filter(Boolean) + : [], + } +} + +function buildPreSkuFormalizationRepairExecutionTemplateSummary(template) { + if (!template) return null + const decisions = Array.isArray(template.decisions) ? template.decisions : [] + const allowedDecisionCounts = {} + const selectedDecisionCounts = {} + let currentEvidenceCount = 0 + let evidenceFingerprintCount = 0 + let reasonFilledCount = 0 + let formalSkuIdFilledCount = 0 + + decisions.forEach((decision) => { + if (decision?.currentEvidence && typeof decision.currentEvidence === 'object') { + currentEvidenceCount += 1 + } + if (typeof decision?.evidenceFingerprint === 'string' && decision.evidenceFingerprint) { + evidenceFingerprintCount += 1 + } + const selectedDecision = + typeof decision?.decision === 'string' && decision.decision + ? decision.decision + : 'UNSELECTED' + selectedDecisionCounts[selectedDecision] = (selectedDecisionCounts[selectedDecision] || 0) + 1 + if (typeof decision?.reason === 'string' && decision.reason.trim()) { + reasonFilledCount += 1 + } + if (typeof decision?.formalSkuId === 'string' && decision.formalSkuId.trim()) { + formalSkuIdFilledCount += 1 + } + const allowedDecisions = Array.isArray(decision?.allowedDecisions) + ? decision.allowedDecisions + : [] + allowedDecisions.forEach((item) => { + const key = item?.decision || 'UNKNOWN' + allowedDecisionCounts[key] = (allowedDecisionCounts[key] || 0) + 1 + }) + }) + const confirmationFilled = + typeof template.confirmation === 'string' && template.confirmation.trim() !== '' + const confirmedByFilled = + typeof template.confirmedBy === 'string' && template.confirmedBy.trim() !== '' + const unselectedDecisionCount = selectedDecisionCounts.UNSELECTED || 0 + const requiresBusinessDecision = + decisions.length > 0 && + (!confirmationFilled || + !confirmedByFilled || + unselectedDecisionCount > 0 || + reasonFilledCount < decisions.length) + + return { + mode: template.mode || '', + generated: true, + databaseIdentityPresent: Boolean(template.databaseIdentity), + candidateCount: template.candidateCount ?? decisions.length, + decisionCount: decisions.length, + requiresBusinessDecision, + unselectedDecisionCount, + confirmationFilled, + confirmedByFilled, + currentEvidenceCount, + evidenceFingerprintCount, + selectedDecisionCounts, + allowedDecisionCounts, + reasonFilledCount, + formalSkuIdFilledCount, + templateInstructionCount: Array.isArray(template.templateInstructions) + ? template.templateInstructions.length + : 0, + } +} + +function buildSnapshotReadBoundarySummary(report, kind) { + if (!report) return null + const prefix = kind === 'process' ? 'processSnapshotRead' : 'compatibilitySnapshotRead' + const debtBudget = report[`${prefix}DebtBudget`] || {} + + return { + mode: report.mode || '', + checkedFiles: report.checkedFiles ?? null, + readCount: report[`${prefix}Count`] ?? null, + blockingRiskCount: report[`${prefix}BlockingRiskCount`] ?? null, + unclassifiedReadCount: report[`unclassified${prefix[0].toUpperCase()}${prefix.slice(1)}Count`] ?? null, + unusedClassificationCount: + report[`unused${prefix[0].toUpperCase()}${prefix.slice(1)}ClassificationCount`] ?? null, + debtBudget: { + total: debtBudget.total ?? null, + high: debtBudget.high ?? null, + medium: debtBudget.medium ?? null, + unknown: debtBudget.unknown ?? null, + }, + budgetViolationCount: report[`${prefix}BudgetViolationCount`] ?? null, + } +} + +function buildReferenceEstimatorCostBoundarySummary(report) { + if (!report) return null + return { + mode: report.mode || '', + approvedSourcePathCount: Array.isArray(report.approvedReferenceEstimatorCostSourcePaths) + ? report.approvedReferenceEstimatorCostSourcePaths.length + : 0, + findingCount: report.referenceEstimatorCostBoundaryFindingCount ?? null, + unclassifiedFindingCount: + report.unclassifiedReferenceEstimatorCostBoundaryFindingCount ?? null, + byRuleId: report.referenceEstimatorCostBoundaryByRuleId || {}, + byFileCount: report.referenceEstimatorCostBoundaryByFile + ? Object.keys(report.referenceEstimatorCostBoundaryByFile).length + : 0, + } +} + +function buildFileSystemMasterDataSourceSummary(report) { + if (!report) return null + return { + mode: report.mode || '', + approvedSourcePathCount: Array.isArray(report.approvedFileSystemMasterDataSourcePaths) + ? report.approvedFileSystemMasterDataSourcePaths.length + : 0, + findingCount: report.fileSystemMasterDataSourceFindingCount ?? null, + unclassifiedFindingCount: report.unclassifiedFileSystemMasterDataSourceCount ?? null, + byFileCount: report.fileSystemMasterDataSourceByFile + ? Object.keys(report.fileSystemMasterDataSourceByFile).length + : 0, + } +} + +function buildStructureRepairRehearsalSummary(structureRepairRehearsal) { + if (!structureRepairRehearsal) return null + return { + status: structureRepairRehearsal.status || '', + repairApplyOk: structureRepairRehearsal.repairApply?.ok === true, + currentSchemaDiffAfterStatus: + structureRepairRehearsal.currentSchemaDiffAfter?.status ?? null, + migrationDiffAfterStatus: structureRepairRehearsal.migrationDiffAfter?.status ?? null, + dockerCleanupStatus: structureRepairRehearsal.docker?.cleanupStatus || '', + destructiveSqlBlocked: + structureRepairRehearsal.repairSqlSummary?.hasDestructiveStatements === true, + repairSqlArtifact: structureRepairRehearsal.repairSqlArtifact + ? { + sizeBytes: structureRepairRehearsal.repairSqlArtifact.sizeBytes ?? 0, + sha256: structureRepairRehearsal.repairSqlArtifact.sha256 || '', + } + : null, + baselineResolveCandidate: + structureRepairRehearsal.baselineResolvePlan?.candidate === true, + baselineResolveMigrationCount: + structureRepairRehearsal.baselineResolvePlan?.migrationCount ?? null, + baselineResolveRehearsalOk: + structureRepairRehearsal.baselineResolveRehearsal?.ok === true, + baselineResolveAppliedCount: + structureRepairRehearsal.baselineResolveRehearsal?.appliedCount ?? null, + prismaMigrateStatusAfterResolveStatus: + structureRepairRehearsal.prismaMigrateStatusAfterResolve?.status ?? null, + readinessAfterResolveStatus: + structureRepairRehearsal.readinessAfterResolve?.readinessStatus || '', + readinessAfterResolveHardReady: + structureRepairRehearsal.readinessAfterResolve?.hardReady ?? null, + readinessAfterResolveBlockerCount: + structureRepairRehearsal.readinessAfterResolve?.blockerCount ?? null, + readinessAfterResolveBlockerKeys: + structureRepairRehearsal.readinessAfterResolve?.blockerKeys || [], + requiredMigrationApplyAfterResolveOk: + structureRepairRehearsal.requiredMigrationApplyAfterResolve?.ok ?? null, + requiredMigrationApplyAfterResolveCount: + structureRepairRehearsal.requiredMigrationApplyAfterResolve?.appliedMigrationCount ?? null, + requiredMigrationApplyAfterResolveStructureCount: + structureRepairRehearsal.requiredMigrationApplyAfterResolve?.pendingStructureMigrationCount ?? null, + requiredMigrationApplyAfterResolveIdempotentCount: + structureRepairRehearsal.requiredMigrationApplyAfterResolve?.replayableIdempotentMigrationCount ?? null, + readinessAfterRequiredMigrationsStatus: + structureRepairRehearsal.readinessAfterRequiredMigrations?.readinessStatus || '', + readinessAfterRequiredMigrationsHardReady: + structureRepairRehearsal.readinessAfterRequiredMigrations?.hardReady ?? null, + readinessAfterRequiredMigrationsBlockerCount: + structureRepairRehearsal.readinessAfterRequiredMigrations?.blockerCount ?? null, + readinessAfterRequiredMigrationsBlockerKeys: + structureRepairRehearsal.readinessAfterRequiredMigrations?.blockerKeys || [], + blockerKeys: Array.isArray(structureRepairRehearsal.blockers) + ? structureRepairRehearsal.blockers.map((blocker) => blocker.key).filter(Boolean) + : [], + } +} + +function buildAuthorizedStructureRepairApplyPlanSummary(applyPlan) { + if (!applyPlan) return null + const exactPhaseCommands = applyPlan.exactPhaseCommands || null + const dryRun = applyPlan.dryRun !== false + const readyToApply = applyPlan.readyToApply === true + return { + mode: applyPlan.mode || '', + phase: applyPlan.phase || '', + dryRun, + readyToApply, + requiresExplicitAuthorization: dryRun || !readyToApply, + executionState: readyToApply + ? 'ready-to-apply-with-explicit-authorization' + : 'plan-only-not-applied', + repairSqlArtifact: applyPlan.repairSqlArtifact + ? { + exists: applyPlan.repairSqlArtifact.exists === true, + sizeBytes: applyPlan.repairSqlArtifact.sizeBytes ?? 0, + sha256: applyPlan.repairSqlArtifact.sha256 || '', + } + : null, + repairSqlSafety: applyPlan.repairSqlSafety + ? { + hasDestructiveStatements: + applyPlan.repairSqlSafety.hasDestructiveStatements === true, + } + : null, + evidenceFreshness: applyPlan.evidenceFreshness + ? { + maxAgeMinutes: applyPlan.evidenceFreshness.maxAgeMinutes ?? null, + preflightAgeMinutes: applyPlan.evidenceFreshness.preflightAgeMinutes ?? null, + structureRehearsalAgeMinutes: + applyPlan.evidenceFreshness.structureRehearsalAgeMinutes ?? null, + } + : null, + baselineResolveMigrationCount: applyPlan.baselineResolveMigrationCount ?? null, + exactPhaseCommandAvailability: exactPhaseCommands + ? { + available: exactPhaseCommands.available === true, + missing: Array.isArray(exactPhaseCommands.missing) + ? exactPhaseCommands.missing + : [], + applyStructureDryRun: + exactPhaseCommands.applyStructureDryRun?.name === 'apply-structure-dry-run', + applyStructure: exactPhaseCommands.applyStructure?.name === 'apply-structure', + resolveBaselineTemplate: + exactPhaseCommands.resolveBaselineTemplate?.name === 'resolve-baseline-template', + } + : null, + blockerKeys: Array.isArray(applyPlan.blockers) + ? applyPlan.blockers.map((blocker) => blocker.key).filter(Boolean) + : [], + } +} + +function buildChecklistItem(id, label, passed, evidence = {}, blockedBy = []) { + const missingEvidence = evidence.missing === true + return { + id, + label, + status: missingEvidence ? 'missing' : passed ? 'passed' : 'blocked', + passed: missingEvidence ? false : passed === true, + blockedBy, + evidence, + } +} + +function hasReadinessBlocker(readiness, predicate) { + const blockers = Array.isArray(readiness?.blockers) ? readiness.blockers : [] + return blockers.some(predicate) +} + +function readinessBlockerKeys(readiness, predicate) { + const blockers = Array.isArray(readiness?.blockers) ? readiness.blockers : [] + return blockers.filter(predicate).map((blocker) => blocker.key).filter(Boolean) +} + +function readinessBlockerSourceKeys(readiness, predicate) { + const blockers = Array.isArray(readiness?.blockers) ? readiness.blockers : [] + return Array.from(new Set( + blockers + .filter(predicate) + .map((blocker) => { + const source = typeof blocker.source === 'string' && blocker.source ? blocker.source : 'unknown' + const key = typeof blocker.key === 'string' && blocker.key ? blocker.key : 'unknown' + return `${source}:${key}` + }), + )) +} + +function readinessBlockerSourceValues(readiness, predicate) { + const blockers = Array.isArray(readiness?.blockers) ? readiness.blockers : [] + return blockers.filter(predicate).reduce((values, blocker) => { + const source = typeof blocker.source === 'string' && blocker.source ? blocker.source : 'unknown' + const key = typeof blocker.key === 'string' && blocker.key ? blocker.key : 'unknown' + const sourceKey = `${source}:${key}` + const value = blocker.value + values[sourceKey] = + typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string' + ? value + : true + return values + }, {}) +} + +function buildFoundationCompletionChecklist({ + readiness, + truthSources, + codeBoundaries, + compatibilitySnapshotRead, + processSnapshotRead, + fileSystemMasterDataSource, + preSkuFormalizationRepairPlanValidation, + authorizedStructureRepairApplyPlan, + stepResults, +}) { + const stepOk = (id) => stepResults.find((result) => result.id === id)?.ok === true + const stepMissing = (id) => !stepResults.some((result) => result.id === id) + const schemaBlockerKeys = readinessBlockerSourceKeys(readiness, (blocker) => + [ + 'prisma-migrate-status', + 'current-schema-diff', + 'migration-status', + 'foundation-preflight', + 'foundation-migrate-plan', + 'foundation-required-migration-apply-plan', + ].includes(blocker.source), + ) + const schemaBlockerValues = readinessBlockerSourceValues(readiness, (blocker) => + [ + 'prisma-migrate-status', + 'current-schema-diff', + 'migration-status', + 'foundation-preflight', + 'foundation-migrate-plan', + 'foundation-required-migration-apply-plan', + ].includes(blocker.source), + ) + const historicalQualityBlockerKeys = readinessBlockerKeys(readiness, (blocker) => + blocker.source === 'foundation-dry-run' && + blocker.key !== 'preSkuFormalizationLifecycleMismatchCount', + ) + const historicalQualityBlockerValues = readinessBlockerSourceValues(readiness, (blocker) => + blocker.source === 'foundation-dry-run' && + blocker.key !== 'preSkuFormalizationLifecycleMismatchCount', + ) + const preSkuLifecycleBlocked = hasReadinessBlocker( + readiness, + (blocker) => blocker.key === 'preSkuFormalizationLifecycleMismatchCount', + ) + const preSkuLifecycleBlockerValues = readinessBlockerSourceValues( + readiness, + (blocker) => blocker.key === 'preSkuFormalizationLifecycleMismatchCount', + ) + const compatibilitySummary = buildSnapshotReadBoundarySummary( + compatibilitySnapshotRead, + 'compatibility', + ) + const processSummary = buildSnapshotReadBoundarySummary(processSnapshotRead, 'process') + const fileSystemSummary = buildFileSystemMasterDataSourceSummary(fileSystemMasterDataSource) + const preSkuValidationSummary = buildPreSkuFormalizationRepairPlanValidationSummary( + preSkuFormalizationRepairPlanValidation, + ) + const authorizedRepairSummary = buildAuthorizedStructureRepairApplyPlanSummary( + authorizedStructureRepairApplyPlan, + ) + + const items = [ + buildChecklistItem( + 'schema-and-migration-history', + '数据库结构与 Prisma 迁移历史', + schemaBlockerKeys.length === 0 && + stepOk('foundation-preflight') && + stepOk('foundation-migrate-plan') && + stepOk('foundation-required-migration-apply-plan'), + { + missing: stepMissing('readiness') || stepMissing('foundation-preflight'), + blockerCount: schemaBlockerKeys.length, + blockerValues: schemaBlockerValues, + readinessHardReady: readiness?.hardReady === true, + foundationPreflightOk: stepOk('foundation-preflight'), + foundationMigratePlanOk: stepOk('foundation-migrate-plan'), + foundationRequiredMigrationApplyPlanOk: + stepOk('foundation-required-migration-apply-plan'), + }, + schemaBlockerKeys, + ), + buildChecklistItem( + 'truth-source-matrix', + '核心对象唯一真相源矩阵', + stepOk('truth-sources') && Number(truthSources?.truthSourceCount ?? 0) > 0, + { + missing: stepMissing('truth-sources'), + truthSourceCount: truthSources?.truthSourceCount ?? null, + prismaModelCount: truthSources?.prismaModelCount ?? null, + databaseGuardCount: truthSources?.databaseGuardCount ?? null, + }, + stepOk('truth-sources') ? [] : ['truth-sources.command'], + ), + buildChecklistItem( + 'code-write-boundaries', + '后端写入边界与受控入口', + stepOk('code-boundaries') && + Array.isArray(codeBoundaries?.violations) && + codeBoundaries.violations.length === 0, + { + missing: stepMissing('code-boundaries'), + checkedFiles: codeBoundaries?.checkedFiles ?? null, + checkedScriptFiles: codeBoundaries?.checkedScriptFiles ?? null, + violationCount: Array.isArray(codeBoundaries?.violations) + ? codeBoundaries.violations.length + : null, + }, + stepOk('code-boundaries') ? [] : ['code-boundaries.command'], + ), + buildChecklistItem( + 'json-and-snapshot-boundaries', + 'JSON/快照边界不反向成为主数据', + stepOk('compatibility-snapshot-read') && + stepOk('process-snapshot-read') && + compatibilitySummary?.blockingRiskCount === 0 && + compatibilitySummary?.unclassifiedReadCount === 0 && + compatibilitySummary?.budgetViolationCount === 0 && + processSummary?.blockingRiskCount === 0 && + processSummary?.unclassifiedReadCount === 0 && + processSummary?.budgetViolationCount === 0, + { + missing: + stepMissing('compatibility-snapshot-read') || + stepMissing('process-snapshot-read'), + compatibility: compatibilitySummary, + process: processSummary, + }, + [ + compatibilitySummary?.blockingRiskCount > 0 ? 'compatibility.blockingRisk' : '', + compatibilitySummary?.unclassifiedReadCount > 0 ? 'compatibility.unclassified' : '', + compatibilitySummary?.budgetViolationCount > 0 ? 'compatibility.budgetViolation' : '', + processSummary?.blockingRiskCount > 0 ? 'process.blockingRisk' : '', + processSummary?.unclassifiedReadCount > 0 ? 'process.unclassified' : '', + processSummary?.budgetViolationCount > 0 ? 'process.budgetViolation' : '', + ].filter(Boolean), + ), + buildChecklistItem( + 'filesystem-master-data-boundary', + '文件系统不能反向造主数据', + stepOk('filesystem-master-data-source') && + fileSystemSummary?.findingCount === 0 && + fileSystemSummary?.unclassifiedFindingCount === 0, + { + missing: stepMissing('filesystem-master-data-source'), + findingCount: fileSystemSummary?.findingCount ?? null, + unclassifiedFindingCount: fileSystemSummary?.unclassifiedFindingCount ?? null, + approvedSourcePathCount: fileSystemSummary?.approvedSourcePathCount ?? null, + }, + [ + fileSystemSummary?.findingCount > 0 ? 'filesystem.finding' : '', + fileSystemSummary?.unclassifiedFindingCount > 0 ? 'filesystem.unclassified' : '', + ].filter(Boolean), + ), + buildChecklistItem( + 'historical-master-data-quality', + '历史主数据字段清洗完成', + historicalQualityBlockerKeys.length === 0, + { + missing: stepMissing('readiness'), + blockerCount: historicalQualityBlockerKeys.length, + blockerValues: historicalQualityBlockerValues, + }, + historicalQualityBlockerKeys, + ), + buildChecklistItem( + 'pre-sku-formalization-lifecycle', + 'PreSku 正式化生命周期一致', + !preSkuLifecycleBlocked && + preSkuValidationSummary?.valid === true && + Number(preSkuValidationSummary?.candidateCount ?? 0) === 0, + { + missing: + stepMissing('readiness') || + stepMissing('pre-sku-formalization-repair-plan-validation'), + validationValid: preSkuValidationSummary?.valid ?? null, + candidateCount: preSkuValidationSummary?.candidateCount ?? null, + staleCandidateCount: preSkuValidationSummary?.staleCandidateCount ?? null, + blockerValues: preSkuLifecycleBlockerValues, + }, + preSkuLifecycleBlocked ? ['preSkuFormalizationLifecycleMismatchCount'] : [], + ), + buildChecklistItem( + 'authorized-foundation-runbook', + '授权结构修复与 baseline 施工链', + authorizedRepairSummary?.exactPhaseCommandAvailability?.available === true && + authorizedRepairSummary?.blockerKeys?.length === 0, + { + missing: stepMissing('authorized-structure-repair-apply-plan'), + dryRun: authorizedRepairSummary?.dryRun ?? null, + readyToApply: authorizedRepairSummary?.readyToApply ?? null, + requiresExplicitAuthorization: + authorizedRepairSummary?.requiresExplicitAuthorization ?? null, + executionState: authorizedRepairSummary?.executionState ?? '', + exactPhaseCommandAvailability: + authorizedRepairSummary?.exactPhaseCommandAvailability ?? null, + }, + authorizedRepairSummary?.blockerKeys || [], + ), + ] + + return { + itemCount: items.length, + passedCount: items.filter((item) => item.status === 'passed').length, + blockedCount: items.filter((item) => item.status === 'blocked').length, + missingCount: items.filter((item) => item.status === 'missing').length, + complete: items.every((item) => item.status === 'passed'), + items, + } +} + +function redactStepPreview(result, key) { + const preview = String(result?.[key] ?? '') + if (!result?.sensitive) return preview + return preview ? '[sensitive output redacted]' : '' +} + +function buildAuditManifest({ outputDir, stepResults, checkedAt = new Date().toISOString() }) { + const blockers = uniqueBlockers(collectBlockers(stepResults)) + const readiness = stepResults.find((result) => result.id === 'readiness')?.data ?? null + const foundationHardReadyGateReport = + stepResults.find((result) => result.id === 'foundation-hard-ready-gate')?.data ?? null + const preflight = stepResults.find((result) => result.id === 'foundation-preflight')?.data ?? null + const migrationPlan = stepResults.find((result) => result.id === 'foundation-migrate-plan')?.data ?? null + const foundationRequiredMigrationApplyPlan = + stepResults.find((result) => result.id === 'foundation-required-migration-apply-plan')?.data ?? null + const referenceEstimatorCostBoundary = + stepResults.find((result) => result.id === 'reference-estimator-cost-boundary')?.data ?? null + const fileSystemMasterDataSource = + stepResults.find((result) => result.id === 'filesystem-master-data-source')?.data ?? null + const compatibilitySnapshotRead = + stepResults.find((result) => result.id === 'compatibility-snapshot-read')?.data ?? null + const processSnapshotRead = + stepResults.find((result) => result.id === 'process-snapshot-read')?.data ?? null + const cleanupTemplate = stepResults.find((result) => result.id === 'cleanup-template')?.data ?? null + const preSkuFormalizationRepairPlan = + stepResults.find((result) => result.id === 'pre-sku-formalization-repair-plan')?.data ?? null + const preSkuFormalizationRepairPlanValidation = + stepResults.find((result) => result.id === 'pre-sku-formalization-repair-plan-validation')?.data ?? null + const preSkuFormalizationRepairExecutionTemplate = + stepResults.find((result) => result.id === 'pre-sku-formalization-repair-execution-template')?.data ?? null + const migrationRehearsal = stepResults.find((result) => result.id === 'migration-rehearsal')?.data ?? null + const baselineRepairPlan = stepResults.find((result) => result.id === 'baseline-repair-plan')?.data ?? null + const structureRepairRehearsal = + stepResults.find((result) => result.id === 'structure-repair-rehearsal')?.data ?? null + const authorizedStructureRepairApplyPlan = + stepResults.find((result) => result.id === 'authorized-structure-repair-apply-plan')?.data ?? null + + const foundationHardReady = + foundationHardReadyGateReport?.foundationHardReadyGate?.hardReady === true + + return { + mode: 'master-data-foundation-audit', + status: + readiness?.hardReady === true && foundationHardReady && blockers.length === 0 + ? 'hard-ready' + : 'not-hard-ready', + hardReady: readiness?.hardReady === true && foundationHardReady && blockers.length === 0, + readyForFoundationMigration: preflight?.ready === true && migrationPlan?.readyToRun === true, + containsSensitiveCleanupTemplate: stepResults.some( + (result) => result.id === 'cleanup-template' && result.outputExists, + ), + containsSensitiveFoundationHardReadyGateReport: stepResults.some( + (result) => result.id === 'foundation-hard-ready-gate' && result.outputExists, + ), + containsSensitivePreSkuFormalizationRepairPlan: stepResults.some( + (result) => result.id === 'pre-sku-formalization-repair-plan' && result.outputExists, + ), + containsSensitivePreSkuFormalizationRepairExecutionTemplate: stepResults.some( + (result) => + result.id === 'pre-sku-formalization-repair-execution-template' && result.outputExists, + ), + checkedAt, + outputDir, + stepCount: stepResults.length, + passedStepCount: stepResults.filter((result) => result.ok).length, + blockerCount: blockers.length, + blockers, + readinessSummary: buildReadinessSummary(readiness), + foundationHardReadyGateSummary: + buildFoundationHardReadyGateSummary(foundationHardReadyGateReport), + cleanupTemplateSummary: buildCleanupTemplateSummary(cleanupTemplate), + migrationRehearsalSummary: buildMigrationRehearsalSummary(migrationRehearsal), + baselineRepairPlanSummary: buildBaselineRepairPlanSummary(baselineRepairPlan), + foundationRequiredMigrationApplyPlanSummary: + buildFoundationRequiredMigrationApplyPlanSummary(foundationRequiredMigrationApplyPlan), + preSkuFormalizationRepairPlanSummary: + buildPreSkuFormalizationRepairPlanSummary(preSkuFormalizationRepairPlan), + preSkuFormalizationRepairPlanValidationSummary: + buildPreSkuFormalizationRepairPlanValidationSummary( + preSkuFormalizationRepairPlanValidation, + ), + preSkuFormalizationRepairExecutionTemplateSummary: + buildPreSkuFormalizationRepairExecutionTemplateSummary( + preSkuFormalizationRepairExecutionTemplate, + ), + compatibilitySnapshotReadSummary: + buildSnapshotReadBoundarySummary(compatibilitySnapshotRead, 'compatibility'), + processSnapshotReadSummary: + buildSnapshotReadBoundarySummary(processSnapshotRead, 'process'), + referenceEstimatorCostBoundarySummary: + buildReferenceEstimatorCostBoundarySummary(referenceEstimatorCostBoundary), + fileSystemMasterDataSourceSummary: + buildFileSystemMasterDataSourceSummary(fileSystemMasterDataSource), + structureRepairRehearsalSummary: + buildStructureRepairRehearsalSummary(structureRepairRehearsal), + authorizedStructureRepairApplyPlanSummary: + buildAuthorizedStructureRepairApplyPlanSummary(authorizedStructureRepairApplyPlan), + foundationCompletionChecklist: buildFoundationCompletionChecklist({ + readiness, + truthSources: stepResults.find((result) => result.id === 'truth-sources')?.data ?? null, + codeBoundaries: stepResults.find((result) => result.id === 'code-boundaries')?.data ?? null, + compatibilitySnapshotRead, + processSnapshotRead, + fileSystemMasterDataSource, + preSkuFormalizationRepairPlanValidation, + authorizedStructureRepairApplyPlan, + stepResults, + }), + stepSummaries: stepResults.map((result) => ({ + id: result.id, + label: result.label, + ok: result.ok, + status: result.status, + outputFile: result.outputFile, + outputExists: result.outputExists, + sensitive: Boolean(result.sensitive), + jsonParsed: Boolean(result.data), + jsonError: result.jsonError, + error: result.error, + stdoutPreview: redactStepPreview(result, 'stdoutPreview'), + stderrPreview: redactStepPreview(result, 'stderrPreview'), + })), + } +} + +function writeJsonReport(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + return filePath +} + +function runAudit(options = parseArgs(), env = process.env) { + fs.mkdirSync(options.outputDir, { recursive: true }) + let shadowContext = null + const stepEnv = { ...env } + + if (options.useDockerShadow && !stepEnv.SHADOW_DATABASE_URL) { + shadowContext = createDockerShadowDatabase(options, env) + stepEnv.SHADOW_DATABASE_URL = shadowContext.shadowDatabaseUrl + } + + try { + const stepResults = auditSteps(options.outputDir, options).map((step) => runAuditStep(step, stepEnv)) + const manifest = buildAuditManifest({ + outputDir: options.outputDir, + stepResults, + }) + const manifestWithShadow = { + ...manifest, + shadowDiffEvidence: { + provided: Boolean(stepEnv.SHADOW_DATABASE_URL), + source: shadowContext ? 'docker-shadow' : stepEnv.SHADOW_DATABASE_URL ? 'environment' : 'none', + dockerContainerName: shadowContext?.containerName || '', + }, + } + const manifestFile = writeJsonReport(path.join(options.outputDir, 'manifest.json'), manifestWithShadow) + return { + ...manifestWithShadow, + manifestFile, + } + } finally { + shadowContext?.cleanup() + } +} + +function main() { + const report = runAudit() + console.log(JSON.stringify(report, null, 2)) + if (!report.hardReady) { + 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 = { + auditSteps, + buildAuditManifest, + buildAuthorizedStructureRepairApplyPlanSummary, + buildBaselineRepairPlanSummary, + buildCleanupTemplateSummary, + buildFileSystemMasterDataSourceSummary, + buildFoundationRequiredMigrationApplyPlanSummary, + buildFoundationHardReadyGateSummary, + buildFoundationCompletionChecklist, + buildMigrationRehearsalSummary, + countQualityDecisionPlaceholderFields, + buildPreSkuFormalizationRepairPlanSummary, + buildPreSkuFormalizationRepairPlanValidationSummary, + buildPreSkuFormalizationRepairExecutionTemplateSummary, + buildReadinessSummary, + buildReferenceEstimatorCostBoundarySummary, + buildStructureRepairRehearsalSummary, + buildSnapshotReadBoundarySummary, + collectBlockers, + countBlankFillFields, + countBlankMissingFillFields, + countByField, + createDockerShadowDatabase, + defaultOutputDir, + parseArgs, + redactStepPreview, + runAudit, + runAuditStep, + runDocker, + timestampForDirectory, + uniqueBlockers, + writeJsonReport, +} diff --git a/furniture-3d-system/scripts/seed-open-platform-demo.ts b/furniture-3d-system/scripts/seed-open-platform-demo.ts index 94a6f775..3a3c985f 100644 --- a/furniture-3d-system/scripts/seed-open-platform-demo.ts +++ b/furniture-3d-system/scripts/seed-open-platform-demo.ts @@ -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() - }) diff --git a/furniture-3d-system/scripts/seed-test-products.ts b/furniture-3d-system/scripts/seed-test-products.ts index bc971e0d..fa21c8ab 100644 --- a/furniture-3d-system/scripts/seed-test-products.ts +++ b/furniture-3d-system/scripts/seed-test-products.ts @@ -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() - }) diff --git a/furniture-3d-system/scripts/sql/check-external-readonly-contract.sql b/furniture-3d-system/scripts/sql/check-external-readonly-contract.sql new file mode 100644 index 00000000..41629ab1 --- /dev/null +++ b/furniture-3d-system/scripts/sql/check-external-readonly-contract.sql @@ -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; diff --git a/furniture-3d-system/scripts/sql/check-internal-master-data-foundation.sql b/furniture-3d-system/scripts/sql/check-internal-master-data-foundation.sql new file mode 100644 index 00000000..a0cd61c1 --- /dev/null +++ b/furniture-3d-system/scripts/sql/check-internal-master-data-foundation.sql @@ -0,0 +1,1296 @@ +-- Internal master data foundation health check. +-- Read-only. Reports data quality and truth-source risks inside the system. + +WITH required_index_guards(index_name, table_name, predicate_hint, definition_hint) AS ( + VALUES + ('AssetSeries_code_key', 'AssetSeries', NULL, NULL), + ('AssetFamily_code_key', 'AssetFamily', NULL, NULL), + ('AssetVariant_code_key', 'AssetVariant', NULL, NULL), + ('AssetVariant_assetFamilyId_identityKey_key', 'AssetVariant', NULL, NULL), + ('AssetVariant_barcode_active_key', 'AssetVariant', '"deletedAt" IS NULL', 'upper(btrim('), + ('AssetFamilySeriesCompatibility_assetFamilyId_assetSeriesId_key', 'AssetFamilySeriesCompatibility', NULL, NULL), + ('DraftGroup_code_key', 'DraftGroup', NULL, NULL), + ('PlanningTemplate_code_key', 'PlanningTemplate', NULL, NULL), + ('Substrate_code_key', 'Substrate', NULL, NULL), + ('WarehouseLocation_code_key', 'WarehouseLocation', NULL, NULL), + ('User_email_normalized_key', 'User', NULL, 'lower(btrim('), + ('User_name_normalized_key', 'User', NULL, 'btrim'), + ('Department_name_normalized_key', 'Department', NULL, 'btrim'), + ('Department_externalId_normalized_key', 'Department', 'NULLIF(btrim', 'btrim'), + ('EmployeeDirectory_dingtalkUserId_normalized_key', 'EmployeeDirectory', 'NULLIF(btrim', 'btrim'), + ('EmployeeDirectory_dingtalkUnionId_normalized_key', 'EmployeeDirectory', 'NULLIF(btrim', 'btrim'), + ('EmployeeDirectory_jobNumber_normalized_key', 'EmployeeDirectory', 'NULLIF(btrim', 'btrim'), + ('EmployeeDirectory_mobile_normalized_key', 'EmployeeDirectory', 'NULLIF(btrim', 'btrim'), + ('WarehouseLocation_active_position_key', 'WarehouseLocation', '"isActive" = true', 'lower(btrim('), + ('PackagedItem_code_key', 'PackagedItem', NULL, NULL), + ('PreSku_draftGroupId_sequence_key', 'PreSku', NULL, NULL), + ('Product_modelNumber_key', 'Product', NULL, NULL), + ('ProductSKU_skuCode_key', 'ProductSKU', NULL, NULL), + ('ProductSKU_active_skuCode_normalized_key', 'ProductSKU', '"deletedAt" IS NULL', 'upper(btrim('), + ('ProductSKU_sourcePreSkuId_key', 'ProductSKU', NULL, NULL), + ('IntakeNonIntegratedProduct_spu_brand_key', 'IntakeNonIntegratedProduct', NULL, NULL), + ('IntakeNonIntegratedProduct_spu_brand_normalized_key', 'IntakeNonIntegratedProduct', NULL, 'upper(btrim('), + ('ProductSkuAssetProfile_skuId_key', 'ProductSkuAssetProfile', NULL, NULL), + ('ProductSkuAssetProfile_barcode_active_non_empty_key', 'ProductSkuAssetProfile', '"skuDeletedAt" IS NULL', 'upper(btrim('), + ('PreSkuBasicInfo_preSkuId_key', 'PreSkuBasicInfo', NULL, NULL), + ('PreSkuBasicInfo_formalSkuCode_non_empty_key', 'PreSkuBasicInfo', 'NULLIF(btrim(', 'upper(btrim('), + ('PreSkuBasicInfo_barcode_non_empty_key', 'PreSkuBasicInfo', 'NULLIF(btrim(', 'upper(btrim('), + ('IntakeNonIntegratedProductProfile_productId_key', 'IntakeNonIntegratedProductProfile', NULL, NULL), + ('IntakeNonIntegratedSkuItem_productId_skuItemId_key', 'IntakeNonIntegratedSkuItem', NULL, NULL), + ('IntakeNonIntegratedSkuItem_skuCode_non_empty_key', 'IntakeNonIntegratedSkuItem', 'NULLIF(btrim(', 'upper(btrim('), + ('IntakeNonIntegratedSkuItem_barcode_non_empty_key', 'IntakeNonIntegratedSkuItem', 'NULLIF(btrim(', 'upper(btrim('), + ('MasterDataQualityDecision_open_target_key', 'MasterDataQualityDecision', 'status = ''APPROVED_INCOMPLETE''', NULL), + ('ProductPatentRecord_patentGroupKey_key', 'ProductPatentRecord', NULL, NULL), + ('IntakeReferenceLibraryItem_libraryType_itemKey_key', 'IntakeReferenceLibraryItem', NULL, NULL), + ('IntakeReferenceLibraryItem_libraryType_code_non_empty_key', 'IntakeReferenceLibraryItem', 'NULLIF(btrim(code),', 'upper(btrim('), + ('IntakeReferenceLibraryItem_libraryType_itemKey_normalized_key', 'IntakeReferenceLibraryItem', NULL, 'lower(btrim('), + ('IntakeReferenceLibraryItem_spu_rule_name_non_empty_key', 'IntakeReferenceLibraryItem', '"libraryType" = ''SPU_RULE''', NULL), + ('IntakeReferenceLibraryItem_spu_rule_name_normalized_key', 'IntakeReferenceLibraryItem', '"libraryType" = ''SPU_RULE''', 'lower(btrim('), + ('SpuNumberCounter_productCategory_key', 'SpuNumberCounter', NULL, NULL), + ('SpuNumberCounter_productCategory_normalized_key', 'SpuNumberCounter', NULL, 'lower(btrim('), + ('SpuNumberCounter_prefix_key', 'SpuNumberCounter', NULL, NULL), + ('Material_code_key', 'Material', NULL, NULL), + ('Hardware_code_key', 'Hardware', NULL, NULL), + ('CommonComponent_code_key', 'CommonComponent', NULL, NULL), + ('BrandMaterialBinding_brand_material_normalized_key', 'BrandMaterialBinding', NULL, 'lower(btrim('), + ('Panel_product_sku_code_normalized_key', 'Panel', NULL, 'upper(btrim('), + ('ProductCategory_name_normalized_key', 'ProductCategory', NULL, 'lower(btrim('), + ('MaterialCategory_name_normalized_key', 'MaterialCategory', NULL, 'lower(btrim('), + ('HardwareCategory_name_normalized_key', 'HardwareCategory', NULL, 'lower(btrim('), + ('CommonComponentCategory_name_normalized_key', 'CommonComponentCategory', NULL, 'lower(btrim('), + ('SubstrateCategory_name_normalized_key', 'SubstrateCategory', NULL, 'lower(btrim('), + ('SubstrateEnvironmentGrade_name_normalized_key', 'SubstrateEnvironmentGrade', NULL, 'lower(btrim('), + ('SubstrateSurfaceOption_name_normalized_key', 'SubstrateSurfaceOption', NULL, 'lower(btrim('), + ('SubstrateSystemNameOption_name_normalized_key', 'SubstrateSystemNameOption', NULL, 'lower(btrim('), + ('SubstrateThicknessSpec_name_normalized_key', 'SubstrateThicknessSpec', NULL, 'lower(btrim('), + ('ProviderConfig_active_service_key', 'ProviderConfig', '"isActive" = true', NULL), + ('ThemeConfig_name_normalized_key', 'ThemeConfig', NULL, 'lower(btrim('), + ('ThemeConfig_default_key', 'ThemeConfig', '"isDefault" = true', NULL) +) +SELECT + 'missing_required_index_guard' AS check_name, + guard.index_name, + guard.table_name, + pg_get_expr(pg_idx.indpred, pg_idx.indrelid) AS actual_predicate, + guard.predicate_hint AS expected_predicate_hint, + pg_get_indexdef(idx.oid) AS actual_definition, + guard.definition_hint AS expected_definition_hint +FROM required_index_guards guard +LEFT JOIN pg_class idx ON idx.relname = guard.index_name +LEFT JOIN pg_index pg_idx ON pg_idx.indexrelid = idx.oid +LEFT JOIN pg_class table_cls ON table_cls.oid = pg_idx.indrelid +LEFT JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace +WHERE idx.oid IS NULL + OR ns.nspname <> 'public' + OR table_cls.relname <> guard.table_name + OR pg_idx.indisunique IS DISTINCT FROM true + OR ( + guard.predicate_hint IS NOT NULL + AND COALESCE(pg_get_expr(pg_idx.indpred, pg_idx.indrelid), '') NOT LIKE '%' || guard.predicate_hint || '%' + ) + OR ( + guard.definition_hint IS NOT NULL + AND COALESCE(pg_get_indexdef(idx.oid), '') NOT LIKE '%' || guard.definition_hint || '%' + ) +ORDER BY guard.index_name; + +WITH required_constraint_guards(constraint_name, table_name, expression_hint) AS ( + VALUES + ('IntakeProductProfile_developmentKind_allowed', 'IntakeNonIntegratedProductProfile', '"productDevelopmentKind" = '''''), + ('IntakeNonIntegratedSkuItem_missingRequiredFields_allowed', 'IntakeNonIntegratedSkuItem', 'missingRequiredFields'), + ('IntakeNonIntegratedSkuItem_quality_state_required_fields', 'IntakeNonIntegratedSkuItem', 'dataQualityStatus'), + ('MasterDataQualityDecision_target_not_blank', 'MasterDataQualityDecision', 'targetType'), + ('MasterDataQualityDecision_status_allowed', 'MasterDataQualityDecision', 'APPROVED_INCOMPLETE'), + ('MasterDataQualityDecision_reason_not_blank', 'MasterDataQualityDecision', 'reason'), + ('MasterDataQualityDecision_decidedBy_not_blank', 'MasterDataQualityDecision', 'decidedBy'), + ('MasterDataQualityDecision_reason_not_placeholder', 'MasterDataQualityDecision', '待确认'), + ('MasterDataQualityDecision_decidedBy_not_placeholder', 'MasterDataQualityDecision', '待确认'), + ('MasterDataQualityDecision_resolvedBy_not_placeholder', 'MasterDataQualityDecision', '待确认'), + ('MasterDataQualityDecision_resolved_state_valid', 'MasterDataQualityDecision', 'resolvedAt'), + ('SpuNumberCounter_productCategory_not_blank', 'SpuNumberCounter', 'productCategory'), + ('SpuNumberCounter_productCategory_normalized', 'SpuNumberCounter', 'btrim'), + ('SpuNumberCounter_prefix_not_blank', 'SpuNumberCounter', 'prefix'), + ('SpuNumberCounter_prefix_normalized', 'SpuNumberCounter', 'upper(btrim'), + ('SpuNumberCounter_lastUsedIndex_non_negative', 'SpuNumberCounter', 'lastUsedIndex'), + ('SpuNumberCounter_sourceLibraryItemId_normalized', 'SpuNumberCounter', 'sourceLibraryItemId'), + ('ProductSKU_active_skuCode_not_deleted_marker', 'ProductSKU', '__deleted__'), + ('ProductSKU_price_cost_stock_non_negative', 'ProductSKU', '"costPrice"'), + ('ProductSKU_soft_delete_identity_valid', 'ProductSKU', 'originalSkuCode'), + ('ProductSKU_no_soft_delete_lifecycle_attributes', 'ProductSKU', '_originalSkuCode'), + ('ProductSKU_soft_delete_tombstone_valid', 'ProductSKU', 'deletedCodeAt'), + ('ProductSKU_no_formal_truth_attributes', 'ProductSKU', 'skuName'), + ('AssetSeries_code_normalized', 'AssetSeries', 'upper(btrim'), + ('AssetSeries_identity_valid', 'AssetSeries', 'sortOrder'), + ('AssetSeries_metadata_object', 'AssetSeries', 'jsonb_typeof'), + ('AssetFamily_code_normalized', 'AssetFamily', 'upper(btrim'), + ('AssetFamily_identity_valid', 'AssetFamily', 'connectionRoles'), + ('AssetFamily_metadata_object', 'AssetFamily', 'jsonb_typeof'), + ('AssetFamily_lifecycle_state_valid', 'AssetFamily', 'PENDING_REVIEW'), + ('DraftGroup_code_normalized', 'DraftGroup', 'upper(btrim'), + ('DraftGroup_identity_valid', 'DraftGroup', 'btrim'), + ('DraftGroup_summary_object', 'DraftGroup', 'jsonb_typeof'), + ('DraftGroup_optional_text_not_blank', 'DraftGroup', 'description'), + ('IntakeReferenceLibraryItem_code_normalized', 'IntakeReferenceLibraryItem', 'upper(btrim'), + ('IntakeReferenceLibraryItem_code_required', 'IntakeReferenceLibraryItem', 'NULLIF(btrim'), + ('IntakeReferenceLibraryItem_libraryType_allowed', 'IntakeReferenceLibraryItem', 'PACKAGING_MATERIAL'), + ('IntakeReferenceLibraryItem_itemKey_required', 'IntakeReferenceLibraryItem', 'btrim'), + ('IntakeReferenceLibraryItem_letter_normalized', 'IntakeReferenceLibraryItem', 'upper(btrim'), + ('IntakeReferenceLibraryItem_spu_rule_name_normalized', 'IntakeReferenceLibraryItem', 'SPU_RULE'), + ('PlanningTemplate_code_normalized', 'PlanningTemplate', 'upper(btrim'), + ('PlanningTemplate_name_not_blank', 'PlanningTemplate', 'name'), + ('PlanningTemplate_slots_array', 'PlanningTemplate', 'jsonb_array_length'), + ('PlanningTemplate_rules_object', 'PlanningTemplate', 'rules'), + ('PlanningTemplate_optional_text_not_blank', 'PlanningTemplate', 'namePattern'), + ('PlanningWorkspace_name_not_blank', 'PlanningWorkspace', 'name'), + ('PlanningWorkspace_visibility_allowed', 'PlanningWorkspace', 'DEPARTMENT'), + ('PlanningWorkspace_slotSelections_object', 'PlanningWorkspace', 'slotSelections'), + ('PlanningWorkspace_metadata_object', 'PlanningWorkspace', 'metadata'), + ('PlanningWorkspace_optional_text_not_blank', 'PlanningWorkspace', 'description'), + ('PlanningDraft_identity_not_blank', 'PlanningDraft', 'selectedCandidateKey'), + ('PlanningDraft_selectedAssets_object', 'PlanningDraft', 'selectedAssets'), + ('PlanningDraft_previewNodes_array', 'PlanningDraft', 'previewNodes'), + ('PlanningDraft_summary_array', 'PlanningDraft', 'summary'), + ('PlanningDraft_warnings_array', 'PlanningDraft', 'warnings'), + ('PlanningDraft_sourceSnapshot_object', 'PlanningDraft', 'sourceSnapshot'), + ('PlanningDraft_optional_text_not_blank', 'PlanningDraft', 'notes'), + ('Substrate_code_normalized', 'Substrate', 'upper(btrim'), + ('WarehouseLocation_code_normalized', 'WarehouseLocation', 'upper(btrim'), + ('PackagedItem_code_normalized', 'PackagedItem', 'upper(btrim'), + ('PreSkuPackagingPlan_status_allowed', 'PreSkuPackagingPlan', 'CONFIRMED'), + ('PreSkuPackagingPlan_boxes_array', 'PreSkuPackagingPlan', 'jsonb_typeof'), + ('PreSkuPackagingPlan_costSummary_object', 'PreSkuPackagingPlan', 'costSummary'), + ('PreSkuPackagingPlan_processDraft_object', 'PreSkuPackagingPlan', 'processDraft'), + ('PreSkuPackagingPlan_optional_text_not_blank', 'PreSkuPackagingPlan', 'sourceRefId'), + ('WarehouseLocation_name_not_blank', 'WarehouseLocation', 'name'), + ('WarehouseLocation_optional_text_not_blank', 'WarehouseLocation', 'zone'), + ('BrandMaterialBinding_materialType_allowed', 'BrandMaterialBinding', 'CARTON_PRINT'), + ('BrandMaterialBinding_identity_not_blank', 'BrandMaterialBinding', 'materialType'), + ('BrandMaterialBinding_file_metadata_consistent', 'BrandMaterialBinding', 'fileSize'), + ('PackagedItem_status_allowed', 'PackagedItem', 'ACTIVE'), + ('PackagedItem_name_not_blank', 'PackagedItem', 'name'), + ('PackagedItem_quantities_valid', 'PackagedItem', 'stockQuantity'), + ('PackagedItem_dimensions_valid', 'PackagedItem', 'weight'), + ('PackagedItem_payload_object', 'PackagedItem', 'jsonb_typeof'), + ('Material_code_normalized', 'Material', 'upper(btrim'), + ('Hardware_code_normalized', 'Hardware', 'upper(btrim'), + ('CommonComponent_code_normalized', 'CommonComponent', 'upper(btrim'), + ('IntakeNonIntegratedProduct_spu_brand_normalized', 'IntakeNonIntegratedProduct', 'upper(btrim'), + ('Product_modelNumber_normalized', 'Product', 'upper(btrim'), + ('Product_name_not_blank', 'Product', 'name'), + ('Product_visibility_allowed', 'Product', 'DEPARTMENT'), + ('Product_optional_text_not_blank', 'Product', 'description'), + ('ProductCollaborator_identity_not_blank', 'ProductCollaborator', 'productId'), + ('ProductCollaborator_role_allowed', 'ProductCollaborator', 'viewer'), + ('ProductCollaborator_addedBy_not_blank', 'ProductCollaborator', 'addedBy'), + ('ShareLink_token_not_blank', 'ShareLink', 'token'), + ('ShareLink_viewCount_non_negative', 'ShareLink', 'viewCount'), + ('ShareLink_expiresAt_after_createdAt', 'ShareLink', 'expiresAt'), + ('Panel_identity_dimensions_valid', 'Panel', 'gltfNodeName'), + ('Panel_explosionDirection_allowed', 'Panel', 'explosionDirection'), + ('Panel_assemblySemantic_object', 'Panel', 'assemblySemantic'), + ('Panel_optional_text_not_blank', 'Panel', 'partType'), + ('Hole_geometry_quality', 'Hole', 'normalX'), + ('Hole_type_allowed', 'Hole', 'shelf_pin'), + ('Hole_optional_text_not_blank', 'Hole', 'groupId'), + ('ProductHardware_match_status_allowed', 'ProductHardware', 'unmatched'), + ('ProductHardware_match_confidence_valid', 'ProductHardware', 'matchConfidence'), + ('ProductHardware_match_method_allowed', 'ProductHardware', 'blockName'), + ('ProductHardware_quantities_geometry_valid', 'ProductHardware', 'holeDistance'), + ('ProductHardware_transform_object', 'ProductHardware', 'transform'), + ('ProductHardware_sourceSignature_object', 'ProductHardware', 'sourceSignature'), + ('ProductHardware_optional_text_not_blank', 'ProductHardware', 'sourceBlockName'), + ('AssemblyStep_identity_order_valid', 'AssemblyStep', 'order'), + ('AssemblyStep_type_allowed', 'AssemblyStep', 'hardware'), + ('AssemblyStep_animation_valid', 'AssemblyStep', 'cameraTransition'), + ('AssemblyStep_transform_object', 'AssemblyStep', 'transform'), + ('AssemblyStep_cameraPosition_object', 'AssemblyStep', 'cameraPosition'), + ('AssemblyStep_infoCardData_object', 'AssemblyStep', 'infoCardData'), + ('AssemblyStep_optional_text_not_blank', 'AssemblyStep', 'description'), + ('AssemblyVersion_snapshot_array', 'AssemblyVersion', 'snapshot'), + ('AssemblyVersion_version_positive', 'AssemblyVersion', 'version'), + ('AssemblyVersion_optional_text_not_blank', 'AssemblyVersion', 'createdBy'), + ('ProductSKU_skuCode_normalized', 'ProductSKU', 'upper(btrim'), + ('ProductSKU_active_skuCode_no_deleted_marker_ci', 'ProductSKU', 'lower'), + ('AssetVariant_code_normalized', 'AssetVariant', 'upper(btrim'), + ('AssetVariant_barcode_normalized', 'AssetVariant', 'upper(btrim'), + ('AssetVariant_identity_valid', 'AssetVariant', 'identityKey'), + ('AssetVariant_json_shape_valid', 'AssetVariant', 'mountPoints'), + ('AssetVariant_cost_weight_valid', 'AssetVariant', 'estimatedCostPrice'), + ('AssetVariant_lifecycle_state_valid', 'AssetVariant', 'PENDING_REVIEW'), + ('AssetVariant_inheritance_not_self', 'AssetVariant', 'inheritedFromVariantId'), + ('PreSku_identity_valid', 'PreSku', 'sourceCandidateKey'), + ('PreSku_process_snapshots_shape_valid', 'PreSku', 'familySelection'), + ('PreSku_weight_valid', 'PreSku', 'weightPreview'), + ('PreSku_component_codes_valid', 'PreSku', 'componentCodes'), + ('PreSku_optional_text_not_blank', 'PreSku', 'customName'), + ('PreSkuBasicInfo_formalSkuCode_normalized', 'PreSkuBasicInfo', 'upper(btrim'), + ('PreSkuBasicInfo_barcode_normalized', 'PreSkuBasicInfo', 'upper(btrim'), + ('PreSkuBasicInfo_model_normalized', 'PreSkuBasicInfo', 'upper(btrim'), + ('PreSkuBasicInfo_versionNo_normalized', 'PreSkuBasicInfo', 'upper(btrim'), + ('PreSkuBasicInfo_attributeCode_normalized', 'PreSkuBasicInfo', 'upper(btrim'), + ('PreSkuBasicInfo_drawingId_normalized', 'PreSkuBasicInfo', 'upper(btrim'), + ('PreSkuBasicInfo_source_allowed', 'PreSkuBasicInfo', 'DRAFT_GROUP'), + ('PreSkuBasicInfo_optional_text_not_blank', 'PreSkuBasicInfo', 'formalSkuCode'), + ('AssetSkuSyncEvent_reason_allowed', 'AssetSkuSyncEvent', 'VARIANT_UPDATED'), + ('AssetSkuSyncEvent_counts_non_negative', 'AssetSkuSyncEvent', 'retryCount'), + ('AssetSkuSyncEvent_synced_not_over_affected', 'AssetSkuSyncEvent', 'syncedCount'), + ('AssetSkuSyncEvent_completed_has_processed_at', 'AssetSkuSyncEvent', 'processedAt'), + ('ProductSkuAssetProfile_barcode_normalized', 'ProductSkuAssetProfile', 'upper(btrim'), + ('ProductSkuAssetProfile_model_normalized', 'ProductSkuAssetProfile', 'upper(btrim'), + ('ProductSkuAssetProfile_specCode_normalized', 'ProductSkuAssetProfile', 'upper(btrim'), + ('ProductSkuAssetProfile_primary_source_required', 'ProductSkuAssetProfile', 'primarySourceAssetVariantId'), + ('ProductSkuAssetProfile_source_variant_ids_required', 'ProductSkuAssetProfile', 'sourceAssetVariantIds'), + ('ProductSkuAssetProfile_primary_source_in_source_ids', 'ProductSkuAssetProfile', 'primarySourceAssetVariantId'), + ('ProductSkuAssetProfile_optional_text_not_blank', 'ProductSkuAssetProfile', 'specCode'), + ('ProductPatentRecord_patentGroupKey_normalized', 'ProductPatentRecord', 'upper(btrim'), + ('ProductPatentRecord_productId_required', 'ProductPatentRecord', 'btrim'), + ('ProductPatentRecord_applicationNo_normalized', 'ProductPatentRecord', 'upper(btrim'), + ('ProductPatentRecord_patentNo_normalized', 'ProductPatentRecord', 'upper(btrim'), + ('ProductPatentRecord_spu_normalized', 'ProductPatentRecord', 'upper(btrim'), + ('ProductPatentRecord_patent_identity_required', 'ProductPatentRecord', 'patentName'), + ('IntakeNonIntegratedSkuItem_skuCode_normalized', 'IntakeNonIntegratedSkuItem', 'upper(btrim'), + ('IntakeNonIntegratedSkuItem_barcode_normalized', 'IntakeNonIntegratedSkuItem', 'upper(btrim'), + ('ApprovalWorkflow_approvalNumber_normalized', 'ApprovalWorkflow', 'upper(btrim'), + ('ApprovalWorkflow_title_not_blank', 'ApprovalWorkflow', 'title'), + ('ApprovalWorkflow_productId_not_blank', 'ApprovalWorkflow', 'productId'), + ('ApprovalWorkflow_accessKey_not_blank', 'ApprovalWorkflow', 'accessKey'), + ('ApprovalWorkflow_participant_user_ids_valid', 'ApprovalWorkflow', 'text_array_distinct_non_blank'), + ('ApprovalWorkflow_business_roles_distinct', 'ApprovalWorkflow', 'array_positions'), + ('ApprovalWorkflow_fieldMatrix_array', 'ApprovalWorkflow', 'jsonb_typeof'), + ('ApprovalWorkflow_summary_object', 'ApprovalWorkflow', 'jsonb_typeof'), + ('ApprovalWorkflow_metadata_object', 'ApprovalWorkflow', 'jsonb_typeof'), + ('ApprovalWorkflowFieldChange_fieldKey_not_blank', 'ApprovalWorkflowFieldChange', 'fieldKey'), + ('ApprovalWorkflowFieldChange_source_not_blank', 'ApprovalWorkflowFieldChange', 'source'), + ('ApprovalWorkflowFieldChange_optional_text_not_blank', 'ApprovalWorkflowFieldChange', 'sourcePage'), + ('ApprovalWorkflowFieldChange_metadata_object', 'ApprovalWorkflowFieldChange', 'jsonb_typeof'), + ('User_email_normalized', 'User', 'lower(btrim'), + ('User_name_not_blank', 'User', 'btrim'), + ('User_loginSource_not_blank', 'User', 'loginSource'), + ('User_business_roles_distinct', 'User', 'array_positions'), + ('User_optional_text_not_blank', 'User', 'dingtalkUserId'), + ('Department_identity_not_blank', 'Department', 'fullPath'), + ('Department_optional_text_not_blank', 'Department', 'externalId'), + ('Department_orderIndex_non_negative', 'Department', 'orderIndex'), + ('Team_identity_not_blank', 'Team', 'orderIndex'), + ('EmployeeDirectory_name_not_blank', 'EmployeeDirectory', 'btrim'), + ('EmployeeDirectory_optional_text_not_blank', 'EmployeeDirectory', 'primaryDepartmentExternalId'), + ('EmployeeDirectory_email_normalized', 'EmployeeDirectory', 'lower(btrim'), + ('EmployeeDirectory_departmentExternalIds_array', 'EmployeeDirectory', 'jsonb_typeof'), + ('EmployeeDirectory_rawSnapshot_object', 'EmployeeDirectory', 'jsonb_typeof'), + ('EmployeeLookupCache_identity_not_blank', 'EmployeeLookupCache', 'lookupValue'), + ('EmployeeLookupCache_result_object', 'EmployeeLookupCache', 'jsonb_typeof'), + ('ProductCategory_identity_valid', 'ProductCategory', 'sortOrder'), + ('ProductCategory_optional_text_not_blank', 'ProductCategory', 'description'), + ('MaterialCategory_identity_valid', 'MaterialCategory', 'sortOrder'), + ('MaterialCategory_optional_text_not_blank', 'MaterialCategory', 'icon'), + ('HardwareCategory_identity_valid', 'HardwareCategory', 'sortOrder'), + ('HardwareCategory_optional_text_not_blank', 'HardwareCategory', 'description'), + ('CommonComponentCategory_identity_valid', 'CommonComponentCategory', 'sortOrder'), + ('CommonComponentCategory_optional_text_not_blank', 'CommonComponentCategory', 'description'), + ('SubstrateCategory_identity_valid', 'SubstrateCategory', 'sortOrder'), + ('SubstrateEnvironmentGrade_identity_valid', 'SubstrateEnvironmentGrade', 'sortOrder'), + ('SubstrateSurfaceOption_identity_valid', 'SubstrateSurfaceOption', 'sortOrder'), + ('SubstrateSystemNameOption_identity_valid', 'SubstrateSystemNameOption', 'sortOrder'), + ('SubstrateThicknessSpec_identity_dimensions_valid', 'SubstrateThicknessSpec', 'thicknessMm'), + ('HardwareMapping_match_key_exactly_one', 'HardwareMapping', 'signatureHash'), + ('HardwareMapping_usage_source_valid', 'HardwareMapping', 'usageCount'), + ('SubstrateFinish_relation_ids_not_blank', 'SubstrateFinish', 'substrateId'), + ('PanelMaterial_relation_ids_not_blank', 'PanelMaterial', 'materialId'), + ('AssetVariantLocationBinding_relation_ids_not_blank', 'AssetVariantLocationBinding', 'warehouseLocationId'), + ('PreSkuLocationBinding_relation_ids_not_blank', 'PreSkuLocationBinding', 'warehouseLocationId'), + ('PackagingPlan_identity_counts_valid', 'PackagingPlan', 'boxCount'), + ('PackagingPlan_boxes_array', 'PackagingPlan', 'jsonb_typeof'), + ('PackagingPlan_totals_valid', 'PackagingPlan', 'totalWeight'), + ('PackagingPlan_optional_text_not_blank', 'PackagingPlan', 'description'), + ('SystemConfig_key_valid', 'SystemConfig', 'btrim'), + ('SystemConfig_description_not_blank', 'SystemConfig', 'description'), + ('ProviderConfig_identity_valid', 'ProviderConfig', 'serviceType'), + ('ProviderConfig_optional_fields_valid', 'ProviderConfig', 'jsonb_typeof'), + ('ThemeConfig_identity_valid', 'ThemeConfig', 'createdBy'), + ('ThemeConfig_optional_config_valid', 'ThemeConfig', 'homeBackground'), + ('IntakeNodePermissionOverride_identity_valid', 'IntakeNodePermissionOverride', 'targetKey'), + ('IntakeNodePermissionOverride_audit_text_not_blank', 'IntakeNodePermissionOverride', 'createdBy'), + ('AssistedAssemblyAiRender_identity_valid', 'AssistedAssemblyAiRender', 'prompt'), + ('AssistedAssemblyAiRender_json_shape_valid', 'AssistedAssemblyAiRender', 'workspaceSnapshot'), + ('AssistedAssemblyAiRender_status_duration_valid', 'AssistedAssemblyAiRender', 'durationMs'), + ('AssistedAssemblyAiRender_optional_text_not_blank', 'AssistedAssemblyAiRender', 'negativePrompt'), + ('Feedback_identity_valid', 'Feedback', 'description'), + ('Feedback_images_valid', 'Feedback', 'text_array_all_trimmed_non_blank'), + ('Feedback_comments_array', 'Feedback', 'jsonb_typeof'), + ('Feedback_resolution_state_valid', 'Feedback', 'resolvedAt'), + ('ActivityLog_identity_valid', 'ActivityLog', 'action'), + ('ActivityLog_target_pair_valid', 'ActivityLog', 'targetType'), + ('ActivityLog_json_shape_valid', 'ActivityLog', 'metadata') +) +SELECT + 'missing_required_check_constraint_guard' AS check_name, + guard.constraint_name, + guard.table_name, + pg_get_constraintdef(con.oid) AS actual_definition, + guard.expression_hint AS expected_expression_hint +FROM required_constraint_guards guard +LEFT JOIN pg_constraint con ON con.conname = guard.constraint_name +LEFT JOIN pg_class table_cls ON table_cls.oid = con.conrelid +LEFT JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace +WHERE con.oid IS NULL + OR con.contype <> 'c' + OR ns.nspname <> 'public' + OR table_cls.relname <> guard.table_name + OR ( + guard.expression_hint IS NOT NULL + AND COALESCE(pg_get_constraintdef(con.oid), '') NOT LIKE '%' || guard.expression_hint || '%' + ) +ORDER BY guard.constraint_name; + +SELECT + 'missing_required_trigger_guard' AS check_name, + guard.trigger_name, + guard.table_name +FROM ( + VALUES + ('IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items', 'IntakeNonIntegratedProduct'), + ('ProductSKU_sync_asset_profile_deleted_at', 'ProductSKU') +) AS guard(trigger_name, table_name) +WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.triggers + WHERE trigger_schema = 'public' + AND trigger_name = guard.trigger_name + AND event_object_table = guard.table_name +); + +SELECT + 'asset_series_duplicate_code_risk' AS check_name, + code, + COUNT(*)::integer AS duplicate_count +FROM "AssetSeries" +WHERE NULLIF(btrim(code), '') IS NOT NULL +GROUP BY code +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, code; + +SELECT + 'asset_family_duplicate_code_risk' AS check_name, + code, + COUNT(*)::integer AS duplicate_count +FROM "AssetFamily" +WHERE NULLIF(btrim(code), '') IS NOT NULL +GROUP BY code +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, code; + +SELECT + 'asset_variant_duplicate_code_risk' AS check_name, + upper(btrim(code)) AS code, + COUNT(*)::integer AS duplicate_count +FROM "AssetVariant" +WHERE NULLIF(btrim(code), '') IS NOT NULL +GROUP BY upper(btrim(code)) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, upper(btrim(code)); + +SELECT + 'asset_variant_duplicate_barcode_risk' AS check_name, + upper(btrim(barcode)) AS barcode, + COUNT(*)::integer AS duplicate_count +FROM "AssetVariant" +WHERE NULLIF(btrim(barcode), '') IS NOT NULL +GROUP BY upper(btrim(barcode)) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, upper(btrim(barcode)); + +SELECT + 'product_duplicate_model_number_risk' AS check_name, + upper(btrim("modelNumber")) AS "modelNumber", + COUNT(*)::integer AS duplicate_count +FROM "Product" +WHERE NULLIF(btrim("modelNumber"), '') IS NOT NULL +GROUP BY upper(btrim("modelNumber")) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, upper(btrim("modelNumber")); + +SELECT + 'product_identity_visibility_risk' AS check_name, + COUNT(*)::integer AS row_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); + +SELECT + 'product_collaborator_role_identity_risk' AS check_name, + COUNT(*)::integer AS row_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); + +SELECT + 'product_sku_price_cost_stock_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "ProductSKU" +WHERE "price" < 0 + OR ("costPrice" IS NOT NULL AND "costPrice" < 0) + OR "stock" < 0; + +SELECT + 'share_link_identity_counter_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "ShareLink" +WHERE NULLIF(btrim(token), '') IS NULL + OR token <> btrim(token) + OR "viewCount" < 0 + OR ("expiresAt" IS NOT NULL AND "expiresAt" <= "createdAt"); + +SELECT + 'panel_identity_dimension_risk' AS check_name, + COUNT(*)::integer AS row_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 ("partType" IS NOT NULL AND NULLIF(btrim("partType"), '') IS NULL) + OR ("explosionDirection" IS NOT NULL AND "explosionDirection" NOT IN ('auto', 'up', 'down', 'left', 'right', 'front', 'back')) + OR ("assemblySemantic" IS NOT NULL AND jsonb_typeof("assemblySemantic") NOT IN ('object', 'null')); + +SELECT + 'panel_duplicate_code_scope_risk' AS check_name, + "productId", + COALESCE("skuId", '') AS "skuScope", + upper(btrim(code)) AS code, + COUNT(*)::integer AS duplicate_count +FROM "Panel" +GROUP BY "productId", COALESCE("skuId", ''), upper(btrim(code)) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, "productId", "skuScope", code; + +SELECT + 'hole_geometry_quality_risk' AS check_name, + COUNT(*)::integer AS row_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); + +SELECT + 'product_hardware_quality_risk' AS check_name, + COUNT(*)::integer AS row_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); + +SELECT + 'assembly_step_quality_risk' AS check_name, + COUNT(*)::integer AS row_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 EXISTS (SELECT 1 FROM unnest("hardwareIds") AS item WHERE NULLIF(btrim(item), '') IS NULL) + OR cardinality("hardwareIds") <> (SELECT COUNT(DISTINCT btrim(item))::integer FROM unnest("hardwareIds") AS item) + OR EXISTS (SELECT 1 FROM unnest("partIds") AS item WHERE NULLIF(btrim(item), '') IS NULL) + OR cardinality("partIds") <> (SELECT COUNT(DISTINCT btrim(item))::integer FROM unnest("partIds") AS item); + +SELECT + 'assembly_version_snapshot_risk' AS check_name, + COUNT(*)::integer AS row_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); + +SELECT + 'product_sku_duplicate_sku_code_risk' AS check_name, + upper(btrim("skuCode")) AS "skuCode", + COUNT(*)::integer AS duplicate_count +FROM "ProductSKU" +WHERE "deletedAt" IS NULL + AND NULLIF(btrim("skuCode"), '') IS NOT NULL +GROUP BY upper(btrim("skuCode")) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, upper(btrim("skuCode")); + +SELECT + 'product_sku_missing_name_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "ProductSKU" sku +JOIN "Product" product ON product."id" = sku."productId" +WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND NULLIF(btrim(COALESCE(sku."name", '')), '') IS NULL; + +SELECT + 'product_sku_formal_truth_attribute_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "ProductSKU" +WHERE attributes ?| ARRAY[ + 'skuName', + 'model', + 'specCode', + 'barcode', + 'category', + 'color', + 'material', + 'size' +]; + +SELECT + 'draft_group_duplicate_code_risk' AS check_name, + code, + COUNT(*)::integer AS duplicate_count +FROM "DraftGroup" +WHERE NULLIF(btrim(code), '') IS NOT NULL +GROUP BY code +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, code; + +SELECT + 'draft_group_invalid_process_shape_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "DraftGroup" +WHERE NULLIF(btrim(code), '') IS NULL + OR code <> btrim(code) + OR NULLIF(btrim(name), '') IS NULL + OR name <> btrim(name) + OR jsonb_typeof(summary::jsonb) <> 'object' + OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL) + OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL); + +SELECT + 'pre_sku_duplicate_per_group_risk' AS check_name, + "draftGroupId", + sequence, + COUNT(*)::integer AS duplicate_count +FROM "PreSku" +GROUP BY "draftGroupId", sequence +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, "draftGroupId", sequence; + +SELECT + 'pre_sku_invalid_process_shape_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "PreSku" +WHERE sequence <= 0 + OR NULLIF(btrim("defaultName"), '') IS NULL + OR "defaultName" <> btrim("defaultName") + OR NULLIF(btrim("sourceCandidateKey"), '') IS NULL + OR "sourceCandidateKey" <> btrim("sourceCandidateKey") + OR jsonb_typeof("familySelection"::jsonb) <> 'object' + OR jsonb_typeof("variantSelection"::jsonb) <> 'object' + OR jsonb_typeof("previewNodes"::jsonb) <> 'array' + OR jsonb_typeof("displaySummary"::jsonb) <> 'object' + OR jsonb_typeof("bomPreview"::jsonb) <> 'array' + OR ( + "recommendedHardware" IS NOT NULL + AND jsonb_typeof("recommendedHardware"::jsonb) NOT IN ('array', 'null') + ) + OR ( + "explosionPreview" IS NOT NULL + AND jsonb_typeof("explosionPreview"::jsonb) NOT IN ('array', 'null') + ) + OR jsonb_typeof("installStepPreview"::jsonb) <> 'array' + OR ( + "packagingSnapshot" IS NOT NULL + AND jsonb_typeof("packagingSnapshot"::jsonb) NOT IN ('object', 'null') + ) + OR ("weightPreview" IS NOT NULL AND "weightPreview" < 0) + OR EXISTS ( + SELECT 1 + FROM unnest("componentCodes") AS item + WHERE NULLIF(btrim(item), '') IS NULL + ) + OR ("customName" IS NOT NULL AND NULLIF(btrim("customName"), '') IS NULL) + OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL); + +SELECT + 'pre_sku_formalization_lifecycle_mismatch_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM ( + SELECT pre_sku."id" + FROM "PreSku" pre_sku + LEFT JOIN "ProductSKU" sku ON sku."sourcePreSkuId" = pre_sku."id" + WHERE pre_sku.status = 'FORMALIZED'::"PreSkuStatus" + AND sku."id" IS NULL + + UNION ALL + + SELECT pre_sku."id" + FROM "ProductSKU" sku + JOIN "PreSku" pre_sku ON pre_sku."id" = sku."sourcePreSkuId" + WHERE sku."deletedAt" IS NULL + AND pre_sku.status <> 'FORMALIZED'::"PreSkuStatus" +) mismatches; + +SELECT + 'asset_family_without_series_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "AssetFamily" +WHERE "assetSeriesId" IS NULL; + +SELECT + 'asset_variant_without_model_url_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "AssetVariant" +WHERE NULLIF(btrim("modelUrl"), '') IS NULL; + +SELECT + 'product_sku_without_asset_profile_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "ProductSKU" sku +JOIN "Product" product ON product."id" = sku."productId" +LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" +WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND jsonb_typeof(sku.attributes::jsonb -> 'assetSkuSnapshot') = 'object' + AND profile."id" IS NULL; + +SELECT + 'product_sku_asset_profile_snapshot_sync_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "ProductSKU" sku +JOIN "Product" product ON product."id" = sku."productId" +JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" +WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND jsonb_typeof(sku.attributes::jsonb -> 'assetSkuSnapshot') = 'object' + AND ( + COALESCE(profile."model", '') IS DISTINCT FROM COALESCE(sku.attributes::jsonb -> 'assetSkuSnapshot' ->> 'model', '') + OR COALESCE(profile."specCode", '') IS DISTINCT FROM COALESCE(sku.attributes::jsonb -> 'assetSkuSnapshot' ->> 'specCode', '') + OR COALESCE(profile."barcode", '') IS DISTINCT FROM COALESCE(sku.attributes::jsonb -> 'assetSkuSnapshot' ->> 'barcode', '') + OR COALESCE(profile."imageUrl", '') IS DISTINCT FROM COALESCE(sku.attributes::jsonb -> 'assetSkuSnapshot' ->> 'imageUrl', '') + ); + +SELECT + 'product_sku_profile_duplicate_barcode_risk' AS check_name, + upper(btrim(profile."barcode")) AS barcode, + COUNT(*)::integer AS duplicate_count +FROM "ProductSkuAssetProfile" profile +JOIN "ProductSKU" sku ON sku."id" = profile."skuId" +JOIN "Product" product ON product."id" = sku."productId" +WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND profile."sourceStatus" = 'ACTIVE' + AND NULLIF(btrim(COALESCE(profile."barcode", '')), '') IS NOT NULL +GROUP BY upper(btrim(profile."barcode")) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, barcode; + +SELECT + 'product_sku_asset_profile_source_id_mismatch_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "ProductSKU" sku +JOIN "Product" product ON product."id" = sku."productId" +JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" +WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND NULLIF(btrim(COALESCE(sku."sourceAssetVariantId", '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(profile."primarySourceAssetVariantId", '')), '') IS NOT NULL + AND sku."sourceAssetVariantId" IS DISTINCT FROM profile."primarySourceAssetVariantId"; + +SELECT + 'pre_sku_missing_truth_snapshot_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "PreSku" +WHERE "familySelection" IS NULL + OR "variantSelection" IS NULL + OR "displaySummary" IS NULL + OR "bomPreview" IS NULL; + +SELECT + 'pre_sku_missing_basic_info_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "PreSku" pre_sku +LEFT JOIN "PreSkuBasicInfo" basic_info ON basic_info."preSkuId" = pre_sku."id" +WHERE basic_info."id" IS NULL + AND ( + 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 ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), '') IS NOT NULL + ); + +SELECT + 'pre_sku_basic_info_snapshot_sync_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "PreSku" pre_sku +JOIN "PreSkuBasicInfo" basic_info ON basic_info."preSkuId" = pre_sku."id" +WHERE COALESCE(basic_info."model", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), ''), '') + OR COALESCE(basic_info."formalSkuCode", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), ''), '') + OR COALESCE(basic_info."barcode", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), ''), '') + OR COALESCE(basic_info."specName", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), ''), ''); + +SELECT + 'pre_sku_duplicate_formal_sku_code_risk' AS check_name, + upper(btrim(basic_info."formalSkuCode")) AS "formalSkuCode", + COUNT(*)::integer AS duplicate_count +FROM "PreSkuBasicInfo" basic_info +JOIN "PreSku" pre_sku ON pre_sku."id" = basic_info."preSkuId" +WHERE pre_sku."status" <> 'ARCHIVED' + AND NULLIF(btrim(basic_info."formalSkuCode"), '') IS NOT NULL +GROUP BY upper(btrim(basic_info."formalSkuCode")) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, upper(btrim(basic_info."formalSkuCode")); + +SELECT + 'pre_sku_duplicate_barcode_risk' AS check_name, + upper(btrim(basic_info."barcode")) AS "barcode", + COUNT(*)::integer AS duplicate_count +FROM "PreSkuBasicInfo" basic_info +JOIN "PreSku" pre_sku ON pre_sku."id" = basic_info."preSkuId" +WHERE pre_sku."status" <> 'ARCHIVED' + AND NULLIF(btrim(basic_info."barcode"), '') IS NOT NULL +GROUP BY upper(btrim(basic_info."barcode")) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, upper(btrim(basic_info."barcode")); + +SELECT + 'intake_non_integrated_missing_product_profile_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "IntakeNonIntegratedProduct" product +LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" +WHERE product."deletedAt" IS NULL + AND profile."id" IS NULL; + +SELECT + 'intake_non_integrated_product_profile_quality_risk' AS check_name, + COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productName"), '') IS NULL)::integer AS missing_product_name_count, + COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productCategory"), '') IS NULL)::integer AS missing_product_category_count, + COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL)::integer AS missing_development_kind_count +FROM "IntakeNonIntegratedProduct" product +JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" +WHERE product."deletedAt" IS NULL; + +SELECT + 'intake_non_integrated_product_profile_quality_sample' AS check_name, + product."id", + product."spu", + profile."productName", + profile."productCategory", + profile."productDevelopmentKind", + ARRAY_REMOVE(ARRAY[ + CASE WHEN NULLIF(btrim(profile."productName"), '') IS NULL THEN 'productName' END, + CASE WHEN NULLIF(btrim(profile."productCategory"), '') IS NULL THEN 'productCategory' END, + CASE WHEN NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL THEN 'productDevelopmentKind' END + ], NULL) AS "missingFields" +FROM "IntakeNonIntegratedProduct" product +JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" +WHERE product."deletedAt" IS NULL + AND ( + NULLIF(btrim(profile."productName"), '') IS NULL + OR NULLIF(btrim(profile."productCategory"), '') IS NULL + OR NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL + ) +ORDER BY product."spu", product."id" +LIMIT 20; + +WITH payload_profile AS ( + SELECT + product."id" AS product_id, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productName'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'previewImageName'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'spu'), ''), '') AS product_name, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productImage'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'previewImage'), ''), '') AS product_image_url, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productCategory'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'productCategory'), ''), '') AS product_category, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'salesCategory'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'salesCategory'), ''), '') AS sales_category, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'year'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'year'), ''), '') AS product_year, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'currentStage'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'projectStatus'), ''), '') AS product_status, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productDevelopmentKind'), ''), '') AS product_development_kind, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'goodsType'), ''), '') AS goods_type, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'materialType'), ''), '') AS material_type, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productionType'), ''), '') AS production_type, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'size'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'size'), ''), '') AS size, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'color'), ''), '') AS color, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'tagMaterial'), ''), '') AS tag_material, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'priceBand'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'targetPriceBand'), ''), '') AS price_band, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandFlowNo'), ''), '') AS demand_flow_no, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'processTemplate'), ''), '') AS process_template, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productPlanning'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'planningOwner'), ''), '') AS product_planning, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'brandPlanning'), ''), '') AS brand_planning, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandDepartment'), ''), '') AS demand_department, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'spuOwner'), ''), '') AS spu_owner, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'operationsOwner'), ''), '') AS operations_owner, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'purchaseOwner'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'purchaser'), ''), '') AS purchase_owner, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualDirector'), ''), '') AS visual_director, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualPlanning'), ''), '') AS visual_planning, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'structureDesigner'), ''), '') AS structure_designer + FROM "IntakeNonIntegratedProduct" product + WHERE product."deletedAt" IS NULL +) +SELECT + 'intake_non_integrated_product_profile_payload_sync_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM payload_profile payload +JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = payload.product_id +WHERE COALESCE(profile."productName", '') IS DISTINCT FROM payload.product_name + OR COALESCE(profile."productImageUrl", '') IS DISTINCT FROM payload.product_image_url + OR COALESCE(profile."productCategory", '') IS DISTINCT FROM payload.product_category + OR COALESCE(profile."salesCategory", '') IS DISTINCT FROM payload.sales_category + OR COALESCE(profile."productYear", '') IS DISTINCT FROM payload.product_year + OR COALESCE(profile."productStatus", '') IS DISTINCT FROM payload.product_status + OR COALESCE(profile."productDevelopmentKind", '') IS DISTINCT FROM payload.product_development_kind + OR COALESCE(profile."goodsType", '') IS DISTINCT FROM payload.goods_type + OR COALESCE(profile."materialType", '') IS DISTINCT FROM payload.material_type + OR COALESCE(profile."productionType", '') IS DISTINCT FROM payload.production_type + OR COALESCE(profile."size", '') IS DISTINCT FROM payload.size + OR COALESCE(profile."color", '') IS DISTINCT FROM payload.color + OR COALESCE(profile."tagMaterial", '') IS DISTINCT FROM payload.tag_material + OR COALESCE(profile."priceBand", '') IS DISTINCT FROM payload.price_band + OR COALESCE(profile."demandFlowNo", '') IS DISTINCT FROM payload.demand_flow_no + OR COALESCE(profile."processTemplate", '') IS DISTINCT FROM payload.process_template + OR COALESCE(profile."productPlanning", '') IS DISTINCT FROM payload.product_planning + OR COALESCE(profile."brandPlanning", '') IS DISTINCT FROM payload.brand_planning + OR COALESCE(profile."demandDepartment", '') IS DISTINCT FROM payload.demand_department + OR COALESCE(profile."spuOwner", '') IS DISTINCT FROM payload.spu_owner + OR COALESCE(profile."operationsOwner", '') IS DISTINCT FROM payload.operations_owner + OR COALESCE(profile."purchaseOwner", '') IS DISTINCT FROM payload.purchase_owner + OR COALESCE(profile."visualDirector", '') IS DISTINCT FROM payload.visual_director + OR COALESCE(profile."visualPlanning", '') IS DISTINCT FROM payload.visual_planning + OR COALESCE(profile."structureDesigner", '') IS DISTINCT FROM payload.structure_designer; + +WITH payload_stock_source AS ( + SELECT + product."id" AS product_id, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSpu'), ''), '') AS source_spu, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSkuId'), ''), '') AS source_sku_id, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceCategory'), ''), '') AS source_category, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceItemType'), ''), '') AS source_item_type, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') AS source_sales_status, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceEnabled'), ''), '') AS source_enabled, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceStockDisabled'), ''), '') AS source_stock_disabled, + CASE + WHEN jsonb_typeof(product."payload"::jsonb -> 'skuItems') = 'array' + THEN jsonb_array_length(product."payload"::jsonb -> 'skuItems') + ELSE 0 + END AS stock_sku_count, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') = '销售' AS stock_has_on_sale_sku + FROM "IntakeNonIntegratedProduct" product + WHERE product."deletedAt" IS NULL +) +SELECT + 'intake_non_integrated_stock_source_profile_sync_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM payload_stock_source payload +JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = payload.product_id +WHERE COALESCE(profile."sourceSpu", '') IS DISTINCT FROM payload.source_spu + OR COALESCE(profile."sourceSkuId", '') IS DISTINCT FROM payload.source_sku_id + OR COALESCE(profile."sourceCategory", '') IS DISTINCT FROM payload.source_category + OR COALESCE(profile."sourceItemType", '') IS DISTINCT FROM payload.source_item_type + OR COALESCE(profile."sourceSalesStatus", '') IS DISTINCT FROM payload.source_sales_status + OR COALESCE(profile."sourceEnabled", '') IS DISTINCT FROM payload.source_enabled + OR COALESCE(profile."sourceStockDisabled", '') IS DISTINCT FROM payload.source_stock_disabled + OR COALESCE(profile."stockSkuCount", 0) IS DISTINCT FROM payload.stock_sku_count + OR COALESCE(profile."stockHasOnSaleSku", false) IS DISTINCT FROM payload.stock_has_on_sale_sku; + +SELECT + 'intake_non_integrated_duplicate_sku_code_risk' AS check_name, + upper(btrim(sku."skuCode")) AS "skuCode", + COUNT(*)::integer AS duplicate_count +FROM "IntakeNonIntegratedSkuItem" sku +JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" +WHERE product."deletedAt" IS NULL + AND NULLIF(btrim(sku."skuCode"), '') IS NOT NULL +GROUP BY upper(btrim(sku."skuCode")) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, upper(btrim(sku."skuCode")); + +SELECT + 'intake_non_integrated_duplicate_barcode_risk' AS check_name, + upper(btrim(sku."barcode")) AS "barcode", + COUNT(*)::integer AS duplicate_count +FROM "IntakeNonIntegratedSkuItem" sku +JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" +WHERE product."deletedAt" IS NULL + AND NULLIF(btrim(sku."barcode"), '') IS NOT NULL +GROUP BY upper(btrim(sku."barcode")) +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, upper(btrim(sku."barcode")); + +SELECT + 'intake_non_integrated_incomplete_sku_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "IntakeNonIntegratedSkuItem" sku +JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" +WHERE product."deletedAt" IS NULL + AND sku."dataQualityStatus" = 'INCOMPLETE'; + +SELECT + 'intake_non_integrated_incomplete_sku_sample' AS check_name, + sku."id", + sku."productId", + product."spu", + sku."skuItemId", + sku."skuCode", + sku."specName", + sku."model", + sku."barcode", + sku."missingRequiredFields" +FROM "IntakeNonIntegratedSkuItem" sku +JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" +WHERE product."deletedAt" IS NULL + AND sku."dataQualityStatus" = 'INCOMPLETE' +ORDER BY product."spu", sku."sortOrder", sku."id" +LIMIT 20; + +SELECT + 'approval_workflow_identity_quality_risk' AS check_name, + COUNT(*)::integer AS total_count, + COUNT(*) FILTER ( + WHERE NULLIF(btrim("approvalNumber"), '') IS NULL + OR "approvalNumber" <> upper(btrim("approvalNumber")) + )::integer AS invalid_approval_number_count, + COUNT(*) FILTER (WHERE NULLIF(btrim("title"), '') IS NULL)::integer AS missing_title_count, + COUNT(*) FILTER (WHERE NULLIF(btrim("productId"), '') IS NULL)::integer AS missing_product_id_count, + COUNT(*) FILTER (WHERE NULLIF(btrim("accessKey"), '') IS NULL)::integer AS missing_access_key_count, + COUNT(*) FILTER ( + WHERE EXISTS ( + SELECT 1 + FROM unnest("participantUserIds") AS participant_id + WHERE NULLIF(btrim(participant_id), '') IS NULL + ) + OR cardinality("participantUserIds") <> ( + SELECT COUNT(DISTINCT participant_id)::integer + FROM unnest("participantUserIds") AS participant_id + ) + )::integer AS invalid_participant_user_ids_count +FROM "ApprovalWorkflow"; + +SELECT + 'approval_workflow_json_shape_risk' AS check_name, + COUNT(*) FILTER ( + WHERE "fieldMatrix" IS NOT NULL AND jsonb_typeof("fieldMatrix") NOT IN ('array', 'null') + )::integer AS invalid_field_matrix_count, + COUNT(*) FILTER ( + WHERE "summary" IS NOT NULL AND jsonb_typeof("summary") NOT IN ('object', 'null') + )::integer AS invalid_summary_count, + COUNT(*) FILTER ( + WHERE "metadata" IS NOT NULL AND jsonb_typeof("metadata") NOT IN ('object', 'null') + )::integer AS invalid_metadata_count +FROM "ApprovalWorkflow"; + +SELECT + 'approval_workflow_field_change_quality_risk' AS check_name, + COUNT(*)::integer AS total_count, + COUNT(*) FILTER (WHERE NULLIF(btrim("fieldKey"), '') IS NULL)::integer AS missing_field_key_count, + COUNT(*) FILTER (WHERE NULLIF(btrim("source"), '') IS NULL)::integer AS missing_source_count, + COUNT(*) FILTER ( + WHERE "sourcePage" IS NOT NULL AND NULLIF(btrim("sourcePage"), '') IS NULL + )::integer AS blank_source_page_count, + COUNT(*) FILTER ( + WHERE "metadata" IS NOT NULL AND jsonb_typeof("metadata") NOT IN ('object', 'null') + )::integer AS invalid_metadata_count +FROM "ApprovalWorkflowFieldChange"; + +SELECT + 'product_patent_record_quality_risk' AS check_name, + COUNT(*)::integer AS total_count, + COUNT(*) FILTER (WHERE NULLIF(btrim("productId"), '') IS NULL)::integer AS missing_product_id_count, + COUNT(*) FILTER (WHERE NULLIF(btrim("patentGroupKey"), '') IS NULL)::integer AS missing_group_key_count, + COUNT(*) FILTER ( + WHERE NULLIF(btrim("patentName"), '') IS NULL + AND NULLIF(btrim("patentNo"), '') IS NULL + AND NULLIF(btrim("applicationNo"), '') IS NULL + )::integer AS missing_patent_identity_count +FROM "ProductPatentRecord"; + +WITH payload_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_payload_sku_items AS ( + SELECT + payload_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 payload_sku_items +), +payload_snapshot AS ( + SELECT + product_id, + sku_item_id, + sort_order, + COALESCE(sku_item ->> 'skuCode', '') AS sku_code, + COALESCE(sku_item ->> 'specName', '') AS spec_name, + COALESCE(sku_item ->> 'model', '') AS model, + COALESCE(sku_item ->> 'barcode', '') AS barcode + FROM deduped_payload_sku_items +), +structured_snapshot AS ( + SELECT + sku."productId" AS product_id, + sku."skuItemId" AS sku_item_id, + sku."sortOrder" AS sort_order, + sku."skuCode" AS sku_code, + sku."specName" AS spec_name, + sku."model" AS model, + sku."barcode" AS barcode + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL +) +SELECT + 'intake_non_integrated_sku_payload_sync_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM payload_snapshot payload +FULL JOIN structured_snapshot structured + ON structured.product_id = payload.product_id + AND structured.sku_item_id = payload.sku_item_id +WHERE payload.product_id IS NULL + OR structured.product_id IS NULL + OR payload.sort_order IS DISTINCT FROM structured.sort_order + OR payload.sku_code IS DISTINCT FROM structured.sku_code + OR payload.spec_name IS DISTINCT FROM structured.spec_name + OR payload.model IS DISTINCT FROM structured.model + OR payload.barcode IS DISTINCT FROM structured.barcode; + +WITH reference_option_rows AS ( + SELECT 'ProductCategory' AS table_name, name, "sortOrder", description, icon FROM "ProductCategory" + UNION ALL SELECT 'MaterialCategory', name, "sortOrder", NULL::text AS description, icon FROM "MaterialCategory" + UNION ALL SELECT 'HardwareCategory', name, "sortOrder", description, icon FROM "HardwareCategory" + UNION ALL SELECT 'CommonComponentCategory', name, "sortOrder", description, icon FROM "CommonComponentCategory" + UNION ALL SELECT 'SubstrateCategory', name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateCategory" + UNION ALL SELECT 'SubstrateEnvironmentGrade', name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateEnvironmentGrade" + UNION ALL SELECT 'SubstrateSurfaceOption', name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateSurfaceOption" + UNION ALL SELECT 'SubstrateSystemNameOption', name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateSystemNameOption" +), +duplicate_reference_option_names AS ( + SELECT table_name, lower(btrim(name)) AS normalized_name + FROM reference_option_rows + GROUP BY table_name, lower(btrim(name)) + HAVING COUNT(*) > 1 +) +SELECT + 'reference_option_quality_risk' AS check_name, + COUNT(*)::integer AS identity_risk_count, + (SELECT COUNT(*)::integer FROM duplicate_reference_option_names) AS duplicate_normalized_name_count +FROM reference_option_rows +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); + +SELECT + 'substrate_thickness_spec_quality_risk' AS check_name, + COUNT(*)::integer AS row_count +FROM "SubstrateThicknessSpec" +WHERE NULLIF(btrim(name), '') IS NULL + OR name <> btrim(name) + OR "sortOrder" < 0 + OR "thicknessMm" <= 0 + OR "sheetLengthMm" <= 0 + OR "sheetWidthMm" <= 0; + +SELECT + 'hardware_mapping_quality_risk' AS check_name, + COUNT(*)::integer AS row_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"); + +SELECT + 'location_and_material_binding_quality_risk' AS check_name, + ( + SELECT COUNT(*)::integer + FROM "SubstrateFinish" + WHERE NULLIF(btrim("substrateId"), '') IS NULL + OR NULLIF(btrim("finishId"), '') IS NULL + ) AS substrate_finish_risk_count, + ( + SELECT COUNT(*)::integer + FROM "PanelMaterial" + WHERE NULLIF(btrim("panelId"), '') IS NULL + OR NULLIF(btrim("materialId"), '') IS NULL + ) AS panel_material_risk_count, + ( + SELECT COUNT(*)::integer + FROM "AssetVariantLocationBinding" + WHERE NULLIF(btrim("assetVariantId"), '') IS NULL + OR NULLIF(btrim("warehouseLocationId"), '') IS NULL + ) AS asset_variant_location_risk_count, + ( + SELECT COUNT(*)::integer + FROM "PreSkuLocationBinding" + WHERE NULLIF(btrim("preSkuId"), '') IS NULL + OR NULLIF(btrim("warehouseLocationId"), '') IS NULL + ) AS pre_sku_location_risk_count; + +SELECT + 'legacy_packaging_plan_quality_risk' AS check_name, + COUNT(*)::integer AS row_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); + +SELECT + 'config_audit_permission_quality_risk' AS check_name, + ( + SELECT COUNT(*)::integer + FROM "SystemConfig" + WHERE NULLIF(btrim(key), '') IS NULL + OR key <> btrim(key) + OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL) + ) AS system_config_risk_count, + ( + SELECT COUNT(*)::integer + 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') + ) AS provider_config_risk_count, + ( + SELECT COUNT(*)::integer + FROM ( + SELECT "serviceType" + FROM "ProviderConfig" + WHERE "isActive" = true + GROUP BY "serviceType" + HAVING COUNT(*) > 1 + ) duplicated_active + ) AS provider_active_conflict_count, + ( + SELECT COUNT(*)::integer + 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') + ) AS theme_config_risk_count, + ( + SELECT COUNT(*)::integer + 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) + ) AS permission_override_risk_count, + ( + SELECT COUNT(*)::integer + 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) + ) AS ai_render_risk_count, + ( + SELECT COUNT(*)::integer + 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 array_position(images, '') IS NOT NULL + 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)) + ) AS feedback_risk_count, + ( + SELECT COUNT(*)::integer + 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') + ) AS activity_log_risk_count; diff --git a/furniture-3d-system/scripts/sql/grant-external-readonly.sql b/furniture-3d-system/scripts/sql/grant-external-readonly.sql new file mode 100644 index 00000000..2716baf0 --- /dev/null +++ b/furniture-3d-system/scripts/sql/grant-external-readonly.sql @@ -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; diff --git a/furniture-3d-system/scripts/sql/preflight-intake-non-integrated-sku-items.sql b/furniture-3d-system/scripts/sql/preflight-intake-non-integrated-sku-items.sql new file mode 100644 index 00000000..72281653 --- /dev/null +++ b/furniture-3d-system/scripts/sql/preflight-intake-non-integrated-sku-items.sql @@ -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; diff --git a/furniture-3d-system/scripts/sql/preflight-product-sku-asset-profile.sql b/furniture-3d-system/scripts/sql/preflight-product-sku-asset-profile.sql new file mode 100644 index 00000000..fdb5bc91 --- /dev/null +++ b/furniture-3d-system/scripts/sql/preflight-product-sku-asset-profile.sql @@ -0,0 +1,53 @@ +-- Preflight for structuring ProductSKU.attributes.assetSkuSnapshot. +-- Read-only. Run before applying 20260530103000_add_product_sku_asset_profile. + +SELECT + 'product_sku_asset_snapshot_missing_or_invalid' AS check_name, + COUNT(*)::integer AS row_count +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') IS DISTINCT FROM 'object'; + +SELECT + 'product_sku_asset_snapshot_missing_primary_keys' AS check_name, + COUNT(*)::integer AS row_count +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' + AND ( + NULLIF(btrim(COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'sourceAssetVariantId', '')), '') IS NULL + OR NULLIF(btrim(COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'specCode', '')), '') IS NULL + ); + +SELECT + 'product_sku_asset_snapshot_source_id_mismatch' AS check_name, + COUNT(*)::integer AS row_count +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' + AND NULLIF(btrim(COALESCE(sku."sourceAssetVariantId", '')), '') IS NOT NULL + AND NULLIF(btrim(COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'sourceAssetVariantId', '')), '') IS NOT NULL + AND sku."sourceAssetVariantId" IS DISTINCT FROM sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'sourceAssetVariantId'; + +SELECT + 'product_sku_asset_snapshot_duplicate_barcode' AS check_name, + barcode, + COUNT(*)::integer AS duplicate_count +FROM ( + SELECT NULLIF(btrim(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'barcode'), '') AS barcode + 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' +) source +WHERE barcode IS NOT NULL +GROUP BY barcode +HAVING COUNT(*) > 1 +ORDER BY duplicate_count DESC, barcode; diff --git a/furniture-3d-system/scripts/sync-board-material-library.ts b/furniture-3d-system/scripts/sync-board-material-library.ts index 8c1e4c37..23ff0734 100644 --- a/furniture-3d-system/scripts/sync-board-material-library.ts +++ b/furniture-3d-system/scripts/sync-board-material-library.ts @@ -1,7 +1,9 @@ import "dotenv/config"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import { Prisma } from "@prisma/client"; -import { prisma } from "../src/lib/prisma"; import { BOARD_ALL_PANEL_FINISHES, BOARD_LIBRARY_DEFAULTS, @@ -10,6 +12,75 @@ import { } from "../src/lib/board-substrate-rules"; const BOARD_IMPORT_SOURCE = "BOARD_LIBRARY_DEFAULTS_2026_04_20"; +const CONFIRM_TOKEN = "sync-board-material-library"; +type PrismaSingleton = typeof import("../src/lib/prisma")["prisma"]; + +let prisma: PrismaSingleton; + +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 和当前备份文件不一致,禁止写库。"); + } +} + +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("板材基础资料库同步真实写库必须通过 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 normalizeCoreIdentity(value: string): string { + return value.trim().toUpperCase(); +} const SUBSTRATE_CATEGORY_SORT_ORDER: Record = { 密度板: 10, @@ -329,6 +400,9 @@ async function createCodeGenerator(options: { } async function main() { + assertApplyAllowed(); + ({ prisma } = await import("../src/lib/prisma")); + const summary: SyncSummary = { materialCategoriesCreated: 0, substrateCategoriesCreated: 0, @@ -363,7 +437,7 @@ async function main() { exists: async (code) => Boolean( await prisma.material.findUnique({ - where: { code }, + where: { code: normalizeCoreIdentity(code) }, select: { id: true }, }), ), @@ -382,7 +456,7 @@ async function main() { exists: async (code) => Boolean( await prisma.substrate.findUnique({ - where: { code }, + where: { code: normalizeCoreIdentity(code) }, select: { id: true }, }), ), @@ -572,5 +646,5 @@ main() process.exitCode = 1; }) .finally(async () => { - await prisma.$disconnect(); + await prisma?.$disconnect(); }); diff --git a/furniture-3d-system/scripts/sync-employee-directory.ts b/furniture-3d-system/scripts/sync-employee-directory.ts index bcb985c8..d0131b8b 100644 --- a/furniture-3d-system/scripts/sync-employee-directory.ts +++ b/furniture-3d-system/scripts/sync-employee-directory.ts @@ -6,6 +6,7 @@ import { type CliOptions = { dryRun: boolean + apply: boolean } type QueryResult = { @@ -13,8 +14,14 @@ type QueryResult = { } function parseCliOptions(argv: string[]): CliOptions { + const apply = argv.includes('--apply') + const dryRun = argv.includes('--dry-run') || !apply + if (apply && argv.includes('--dry-run')) { + throw new Error('不能同时传入 --apply 和 --dry-run。') + } return { - dryRun: argv.includes('--dry-run'), + dryRun, + apply, } } diff --git a/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts b/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts index dcb4e085..3efc08eb 100644 --- a/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts +++ b/furniture-3d-system/scripts/sync-jst-sku-stock-products.ts @@ -1,14 +1,17 @@ import 'dotenv/config' -import { prisma } from '../src/lib/prisma' -import { syncJstSkuStockProducts } from '../src/server/services/jst-sku-stock-sync.service' type CliOptions = { apply: boolean batchSize?: number + confirm: string + expectedDatabaseIdentity: string + expectedSourceDatabaseIdentity: string limit?: number help: boolean } +export const CONFIRM_TOKEN = 'sync-jst-sku-stock' + function readNumberArg(value: string | undefined, name: string) { if (!value) return undefined @@ -23,6 +26,9 @@ function readNumberArg(value: string | undefined, name: string) { function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { apply: false, + confirm: '', + expectedDatabaseIdentity: '', + expectedSourceDatabaseIdentity: '', help: false, } @@ -42,6 +48,21 @@ function parseArgs(argv: string[]): CliOptions { return } + if (arg.startsWith('--confirm=')) { + options.confirm = arg.slice('--confirm='.length) + return + } + + if (arg.startsWith('--expected-database-identity=')) { + options.expectedDatabaseIdentity = arg.slice('--expected-database-identity='.length) + return + } + + if (arg.startsWith('--expected-source-database-identity=')) { + options.expectedSourceDatabaseIdentity = arg.slice('--expected-source-database-identity='.length) + return + } + if (arg.startsWith('--batch-size=')) { options.batchSize = readNumberArg(arg.split('=')[1], '--batch-size') return @@ -58,12 +79,78 @@ function parseArgs(argv: string[]): CliOptions { return options } +function databaseIdentityFromUrl(databaseUrl: string) { + const parsed = new URL(databaseUrl) + const protocol = parsed.protocol.replace(/:$/, '') + const database = parsed.pathname.replace(/^\//, '') + const schema = parsed.searchParams.get('schema') ?? '' + return [ + protocol, + decodeURIComponent(parsed.username || ''), + parsed.hostname, + parsed.port || '', + database, + schema, + ].join('|') +} + +function sourceDatabaseIdentityFromEnv(env: NodeJS.ProcessEnv = process.env) { + const sourceDatabaseUrl = env.JST_SKU_DATABASE_URL?.trim() + if (sourceDatabaseUrl) { + return databaseIdentityFromUrl(sourceDatabaseUrl) + } + + const host = env.JST_SKU_DB_HOST?.trim() ?? '' + const database = env.JST_SKU_DB_NAME?.trim() ?? '' + const user = env.JST_SKU_DB_USER?.trim() ?? '' + if (!host || !database || !user) return '' + + return ['mysql', user, host, env.JST_SKU_DB_PORT?.trim() || '3306', database, ''].join('|') +} + +export function assertApplyGuards(options: CliOptions, env: NodeJS.ProcessEnv = process.env) { + if (!options.apply) return + + if (options.confirm !== CONFIRM_TOKEN) { + throw new Error(`聚水潭存量商品同步真实写库必须显式传入 --confirm=${CONFIRM_TOKEN}。`) + } + + const databaseUrl = env.DATABASE_URL?.trim() ?? '' + if (!databaseUrl) { + throw new Error('聚水潭存量商品同步真实写库必须先配置 DATABASE_URL。') + } + + const actualDatabaseIdentity = databaseIdentityFromUrl(databaseUrl) + if (!options.expectedDatabaseIdentity) { + throw new Error( + `聚水潭存量商品同步真实写库必须显式传入 --expected-database-identity=${actualDatabaseIdentity}。`, + ) + } + if (options.expectedDatabaseIdentity !== actualDatabaseIdentity) { + throw new Error('聚水潭存量商品同步目标库身份和当前 DATABASE_URL 不一致,禁止写库。') + } + + const actualSourceDatabaseIdentity = sourceDatabaseIdentityFromEnv(env) + if (!actualSourceDatabaseIdentity) { + throw new Error('聚水潭存量商品同步真实写库必须先配置 JST_SKU_DATABASE_URL 或 JST_SKU_DB_* 来源库信息。') + } + if (!options.expectedSourceDatabaseIdentity) { + throw new Error( + `聚水潭存量商品同步真实写库必须显式传入 --expected-source-database-identity=${actualSourceDatabaseIdentity}。`, + ) + } + if (options.expectedSourceDatabaseIdentity !== actualSourceDatabaseIdentity) { + throw new Error('聚水潭存量商品同步来源库身份和当前 JST_SKU 来源配置不一致,禁止写库。') + } +} + function printHelp() { console.log(` Usage: - npm run sync:jst-sku-stock -- [--dry-run] [--apply] [--limit=100] [--batch-size=500] + npm run sync:jst-sku-stock -- [--dry-run] [--apply] [--confirm=${CONFIRM_TOKEN}] [--expected-database-identity=postgresql|user|host|port|database|schema] [--expected-source-database-identity=mysql|user|host|port|database|] [--limit=100] [--batch-size=500] Default mode is --dry-run. Use --apply only when you want to write changes. +真实写库必须同时确认目标 PostgreSQL 身份和来源 MySQL 身份,避免错库同步。 Required environment variables: DATABASE_URL @@ -89,19 +176,26 @@ async function main() { printHelp() return } + assertApplyGuards(options) - const result = await syncJstSkuStockProducts({ - apply: options.apply, - batchSize: options.batchSize, - limit: options.limit, - }) + const [{ prisma }, { syncJstSkuStockProducts }] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/jst-sku-stock-sync.service'), + ]) + try { + const result = await syncJstSkuStockProducts({ + apply: options.apply, + batchSize: options.batchSize, + limit: options.limit, + }) - console.log(JSON.stringify(result, null, 2)) + console.log(JSON.stringify(result, null, 2)) + } finally { + await prisma.$disconnect() + } } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)) process.exitCode = 1 -}).finally(async () => { - await prisma.$disconnect() }) diff --git a/furniture-3d-system/scripts/sync-standard-finishes.ts b/furniture-3d-system/scripts/sync-standard-finishes.ts index aea2a13d..c063fa92 100644 --- a/furniture-3d-system/scripts/sync-standard-finishes.ts +++ b/furniture-3d-system/scripts/sync-standard-finishes.ts @@ -1,9 +1,16 @@ 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 { STANDARD_FINISH_CATALOG } from '../src/lib/standard-finish-catalog' import { buildStandardFinishMaterialData } from '../src/lib/standard-finish-sync' +const CONFIRM_TOKEN = 'sync-standard-finishes' +type PrismaSingleton = typeof import('../src/lib/prisma')['prisma'] + +let prisma: PrismaSingleton + interface SyncSummary { categoriesCreated: number categoriesUpdated: number @@ -13,7 +20,71 @@ interface SyncSummary { materialsDeactivated: number } +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 和当前备份文件不一致,禁止写库。') + } +} + +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('标准饰面同步真实写库必须通过 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') +} + async function main() { + assertApplyAllowed() + ;({ prisma } = await import('../src/lib/prisma')) + const summary: SyncSummary = { categoriesCreated: 0, categoriesUpdated: 0, @@ -165,5 +236,5 @@ main() process.exitCode = 1 }) .finally(async () => { - await prisma.$disconnect() + await prisma?.$disconnect() }) diff --git a/furniture-3d-system/scripts/update-demo-models.js b/furniture-3d-system/scripts/update-demo-models.js index 0080c460..749cbbc0 100644 --- a/furniture-3d-system/scripts/update-demo-models.js +++ b/furniture-3d-system/scripts/update-demo-models.js @@ -3,10 +3,34 @@ * 更新演示数据脚本 - 添加真实的3D模型 * 使用更好的3D模型替换默认的演示模型 */ +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) }) +} // 优质的免费3D模型资源 const QUALITY_MODELS = [ @@ -37,6 +61,8 @@ const QUALITY_MODELS = [ ] async function updateDemoModels() { + assertLocalDestructiveDemoAllowed() + createPrismaClient() console.log('🔄 开始更新演示模型...') try { @@ -126,7 +152,8 @@ async function updateDemoModels() { console.error('❌ 更新演示模型失败:', error) throw error } finally { - await prisma.$disconnect() + await prisma?.$disconnect() + await pool?.end() } } diff --git a/furniture-3d-system/scripts/update-model-urls.ts b/furniture-3d-system/scripts/update-model-urls.ts index f7f39adc..270419fb 100644 --- a/furniture-3d-system/scripts/update-model-urls.ts +++ b/furniture-3d-system/scripts/update-model-urls.ts @@ -1,6 +1,21 @@ -import { PrismaClient } from '@prisma/client' +const CONFIRM_TOKEN = 'update-local-model-urls' -const prisma = new PrismaClient() +function assertLocalModelUrlUpdateAllowed() { + if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) { + throw new Error(`update-model-urls 只允许显式本地维护:请追加 --confirm=${CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('update-model-urls 禁止在 NODE_ENV=production 下执行。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('update-model-urls 必须先配置 DATABASE_URL。') + } + const host = new URL(databaseUrl).hostname + if (!['localhost', '127.0.0.1', '::1'].includes(host)) { + throw new Error('update-model-urls 只允许连接本机数据库。') + } +} // Three.js 官方示例模型 URL 列表 const THREE_JS_MODELS = { @@ -46,14 +61,17 @@ const THUMBNAILS = { } async function main() { + assertLocalModelUrlUpdateAllowed() + const { prisma } = await import('../src/lib/prisma') console.log('开始更新产品模型 URL...\n') - // 获取所有产品 - const products = await prisma.product.findMany({ - orderBy: { modelNumber: 'asc' } - }) + try { + // 获取所有产品 + const products = await prisma.product.findMany({ + orderBy: { modelNumber: 'asc' } + }) - console.log(`找到 ${products.length} 个产品\n`) + console.log(`找到 ${products.length} 个产品\n`) // 模型分配方案(模拟不同产品使用不同模型) const modelAssignments: Record = { @@ -79,50 +97,52 @@ async function main() { { model: 'Ferrari' as const, thumbnail: 'Ferrari' as const }, ] - let updatedCount = 0 + let updatedCount = 0 - for (let i = 0; i < products.length; i++) { - const product = products[i] + for (let i = 0; i < products.length; i++) { + const product = products[i] - // 查找分配的模型 - let assignment = modelAssignments[product.modelNumber] + // 查找分配的模型 + let assignment = modelAssignments[product.modelNumber] - // 如果没有特定分配,使用默认模型轮换 - if (!assignment) { - assignment = defaultModels[i % defaultModels.length] - } - - const modelUrl = THREE_JS_MODELS[assignment.model] - const thumbnailUrl = assignment.thumbnail ? THUMBNAILS[assignment.thumbnail] : null - - // 更新产品 - await prisma.product.update({ - where: { id: product.id }, - data: { - modelUrl, - ...(thumbnailUrl && { thumbnailUrl }) + // 如果没有特定分配,使用默认模型轮换 + if (!assignment) { + assignment = defaultModels[i % defaultModels.length] } - }) - console.log(`✓ ${product.modelNumber} (${product.name})`) - console.log(` 模型: ${assignment.model}`) - if (thumbnailUrl) { - console.log(` 预览图: ${assignment.thumbnail}`) + const modelUrl = THREE_JS_MODELS[assignment.model] + const thumbnailUrl = assignment.thumbnail ? THUMBNAILS[assignment.thumbnail] : null + + // 更新产品 + await prisma.product.update({ + where: { id: product.id }, + data: { + modelUrl, + ...(thumbnailUrl && { thumbnailUrl }) + } + }) + + console.log(`✓ ${product.modelNumber} (${product.name})`) + console.log(` 模型: ${assignment.model}`) + if (thumbnailUrl) { + console.log(` 预览图: ${assignment.thumbnail}`) + } + console.log() + + updatedCount++ } - console.log() - updatedCount++ - } + console.log(`\n完成!已更新 ${updatedCount} 个产品的模型 URL`) - console.log(`\n完成!已更新 ${updatedCount} 个产品的模型 URL`) - - // 显示模型 URL 汇总 - console.log('\n=== 可用的 Three.js 示例模型 ===') - for (const [name, url] of Object.entries(THREE_JS_MODELS)) { - console.log(`${name}: ${url}`) + // 显示模型 URL 汇总 + console.log('\n=== 可用的 Three.js 示例模型 ===') + for (const [name, url] of Object.entries(THREE_JS_MODELS)) { + console.log(`${name}: ${url}`) + } + } finally { + await prisma.$disconnect() } } main() .catch(console.error) - .finally(() => prisma.$disconnect()) diff --git a/furniture-3d-system/scripts/update-test-data.ts b/furniture-3d-system/scripts/update-test-data.ts index 3259b35e..21366e9b 100644 --- a/furniture-3d-system/scripts/update-test-data.ts +++ b/furniture-3d-system/scripts/update-test-data.ts @@ -2,27 +2,55 @@ * 更新测试数据脚本 * 修复数据库中的测试产品数据,使其可以正常显示 */ +const CONFIRM_TOKEN = 'update-local-test-data' -import { PrismaClient } from '@prisma/client' +function assertLocalScriptAllowed() { + if (!process.argv.includes(`--confirm=${CONFIRM_TOKEN}`)) { + throw new Error(`update-test-data 只允许显式本地补数:请追加 --confirm=${CONFIRM_TOKEN}。`) + } + if (process.env.NODE_ENV === 'production') { + throw new Error('update-test-data 禁止在 NODE_ENV=production 下执行。') + } + const databaseUrl = process.env.DATABASE_URL || '' + if (!databaseUrl) { + throw new Error('update-test-data 必须先配置 DATABASE_URL。') + } + const host = new URL(databaseUrl).hostname + if (!['localhost', '127.0.0.1', '::1'].includes(host)) { + throw new Error('update-test-data 只允许连接本机数据库。') + } +} -const prisma = new PrismaClient() +function buildDefaultAssetProfile() { + return { + size: { label: '标准尺寸' }, + color: '原木色', + material: '实木', + syncedAt: new Date(), + lockedReasons: {}, + sourceVariants: [], + } +} async function main() { + assertLocalScriptAllowed() + const { prisma } = await import('../src/lib/prisma') console.log('开始更新测试数据...') - // 获取所有产品 - const products = await prisma.product.findMany({ - include: { - skus: true, - assemblySteps: true, - shareLinks: true, - } - }) + try { + // 获取所有产品 + const products = await prisma.product.findMany({ + include: { + skus: true, + assemblySteps: true, + shareLinks: true, + } + }) - console.log(`找到 ${products.length} 个产品`) + console.log(`找到 ${products.length} 个产品`) - // 更新每个产品 - for (const product of products) { + // 更新每个产品 + for (const product of products) { console.log(`\n更新产品: ${product.modelNumber} - ${product.name}`) // 确保产品有基本信息 @@ -52,17 +80,16 @@ async function main() { data: { productId: product.id, skuCode: `${product.modelNumber}-DEFAULT`, - attributes: { - color: '原木色', - size: '标准尺寸', - material: '实木' - }, + attributes: {}, price: 2999.00, costPrice: 1500.00, stock: 100, images: ['/placeholder-furniture.jpg'], isActive: true, ownerId: skuOwner.id, + assetProfile: { + create: buildDefaultAssetProfile(), + }, } }) } else { @@ -133,9 +160,12 @@ async function main() { }) } } - } + } - console.log('\n✅ 测试数据更新完成!') + console.log('\n✅ 测试数据更新完成!') + } finally { + await prisma.$disconnect() + } } main() @@ -143,6 +173,3 @@ main() console.error('❌ 更新失败:', e) process.exit(1) }) - .finally(async () => { - await prisma.$disconnect() - }) diff --git a/furniture-3d-system/scripts/verify-master-data-code-boundaries.js b/furniture-3d-system/scripts/verify-master-data-code-boundaries.js new file mode 100644 index 00000000..f649cd47 --- /dev/null +++ b/furniture-3d-system/scripts/verify-master-data-code-boundaries.js @@ -0,0 +1,3724 @@ +const fs = require('node:fs') +const path = require('node:path') + +function parseArgs(argv) { + const args = { + mode: '', + outputPath: '', + root: path.resolve(__dirname, '..'), + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--mode') { + args.mode = argv[index + 1] ?? '' + if (!args.mode || args.mode.startsWith('--')) { + throw new Error('缺少 --mode 。') + } + index += 1 + continue + } + if (arg === '--root') { + args.root = path.resolve(process.cwd(), argv[index + 1] ?? '') + if (!argv[index + 1] || argv[index + 1].startsWith('--')) { + throw new Error('缺少 --root 。') + } + index += 1 + continue + } + if (arg === '--output') { + args.outputPath = argv[index + 1] ?? '' + if (!args.outputPath || args.outputPath.startsWith('--')) { + throw new Error('缺少 --output 。') + } + index += 1 + continue + } + if (arg.startsWith('--output=')) { + args.outputPath = arg.slice('--output='.length) + if (!args.outputPath) { + throw new Error('缺少 --output 。') + } + continue + } + throw new Error(`未知参数:${arg}`) + } + + return args +} + +const args = + require.main === module + ? parseArgs(process.argv.slice(2)) + : { + mode: '', + outputPath: '', + root: path.resolve(__dirname, '..'), + } +const ROOT_DIR = args.root +const SCAN_DIRS = ['src'] +const SCRIPT_SCAN_DIRS = ['scripts', 'prisma'] +const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']) +const PRISMA_SCHEMA_PATH = path.join(ROOT_DIR, 'prisma/schema.prisma') +const COMPATIBILITY_SNAPSHOT_READ_PRIORITIES = ['high', 'medium', 'low', 'unknown'] +const PRODUCT_SKU_ATTRIBUTE_WRITE_PRIORITIES = ['high', 'medium', 'low', 'unknown'] +const PROCESS_SNAPSHOT_READ_PRIORITIES = ['high', 'medium', 'low', 'unknown'] +const SNAPSHOT_READ_DEBT_BUDGETS = { + compatibilitySnapshotRead: { + total: 48, + high: 0, + medium: 38, + unknown: 0, + }, + processSnapshotRead: { + total: 37, + high: 0, + medium: 33, + unknown: 0, + }, +} +const approvedReferenceEstimatorCostSourcePaths = new Map([ + [ + 'src/app/api/intake/reference-image-estimate/route.ts', + '参考图识别接口只允许使用内置五金样本帮助识别,不保存正式 BOM 或成本。', + ], + [ + 'src/app/intake/RawMaterialLibraryPanel.tsx', + '参考图预估成本页面只允许把临时单价用于前端估算展示,不能写入正式产品、BOM 或包装方案。', + ], + [ + 'src/lib/reference-image-cost-catalog.ts', + '参考图预估成本的小样本目录只服务图片估算识别,不是五金、包装或正式成本主数据源。', + ], +]) + +const approvedFileSystemMasterDataSourcePaths = new Map([ + [ + 'src/server/services/product-hardware-import.service.ts', + '旧产品五金 ZIP 导入入口允许从上传包读取 hardware.json / glb,并写入旧 CAD 兼容链 ProductHardware;不得扩展为扫描 public/uploads 目录反向补主数据。', + ], + [ + 'src/server/services/sku-upload.service.ts', + '旧 SKU CAD 上传入口允许读取本次请求的临时文件并生成旧 CAD 兼容结构;正式基础资料仍必须走结构化字段和校验。', + ], +]) + +function toPosixPath(filePath) { + return filePath.split(path.sep).join('/') +} + +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 emitReport(report) { + writeJsonReport(args.outputPath, report) + console.log(JSON.stringify(report, null, 2)) +} + +function listSourceFiles(dir) { + if (!fs.existsSync(dir)) return [] + + const entries = fs.readdirSync(dir, { withFileTypes: true }) + const files = [] + + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === '.next') continue + + const absolutePath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...listSourceFiles(absolutePath)) + continue + } + + if (entry.isFile() && CODE_EXTENSIONS.has(path.extname(entry.name))) { + files.push(absolutePath) + } + } + + return files +} + +function lineNumberAt(content, index) { + let line = 1 + for (let i = 0; i < index; i += 1) { + if (content.charCodeAt(i) === 10) line += 1 + } + return line +} + +function isTestFile(relativePath) { + return ( + relativePath.includes('/__tests__/') || + relativePath.endsWith('.test.ts') || + relativePath.endsWith('.test.tsx') || + relativePath.endsWith('.spec.ts') || + relativePath.endsWith('.spec.tsx') + ) +} + +const schemaJsonFieldClassifications = new Map( + [ + ['ProductSKU.attributes', ['compatibility-snapshot', '正式 SKU 兼容属性;主读必须走结构化字段、assetProfile 或 legacyAttributes 输出。']], + ['ProductSKU.transferHistory', ['audit-log', 'SKU 转移历史。']], + ['ProductSkuAssetProfile.size', ['structured-json', '正式 SKU 资产资料结构化尺寸对象。']], + ['ProductSkuAssetProfile.lockedReasons', ['structured-json', '正式 SKU 资产资料人工锁定原因。']], + ['ProductSkuAssetProfile.sourceVariants', ['structured-json', '正式 SKU 资产资料来源规格款列表。']], + ['IntakeNonIntegratedProduct.payload', ['compatibility-snapshot', '非集成开品历史过程快照;主读应走 profile 和 skuItems 结构化表。']], + ['ProductPatentRecord.annualFees', ['structured-json', '专利年费明细。']], + ['ProductPatentRecord.annualFeeDates', ['structured-json', '专利年费日期明细。']], + ['IntakeReferenceLibraryItem.payload', ['structured-json', '资料库条目扩展字段;条目身份和编码由结构化列约束。']], + ['AssemblyStep.transform', ['geometry-json', '旧 CAD 装配步骤几何变换。']], + ['AssemblyStep.cameraPosition', ['geometry-json', '旧 CAD 装配步骤相机位置。']], + ['AssemblyStep.infoCardData', ['process-snapshot', '旧 CAD 装配步骤展示信息。']], + ['AssemblyVersion.snapshot', ['process-snapshot', '旧 CAD 装配版本完整步骤快照。']], + ['Panel.assemblySemantic', ['geometry-json', '旧 CAD 板件装配语义。']], + ['PackagingPlan.boxes', ['structured-json', '包装方案箱规列表。']], + ['PlanningTemplate.slots', ['process-template', '企划模板槽位配置。']], + ['PlanningTemplate.rules', ['process-template', '企划模板规则配置。']], + ['PlanningWorkspace.slotSelections', ['process-snapshot', '企划工作区槽位选择状态。']], + ['PlanningWorkspace.metadata', ['process-snapshot', '企划工作区扩展元数据。']], + ['PlanningDraft.selectedAssets', ['process-snapshot', '企划草稿已选资产快照。']], + ['PlanningDraft.previewNodes', ['process-snapshot', '企划草稿预览节点快照。']], + ['PlanningDraft.summary', ['process-snapshot', '企划草稿摘要。']], + ['PlanningDraft.warnings', ['process-snapshot', '企划草稿警告。']], + ['PlanningDraft.sourceSnapshot', ['process-snapshot', '企划草稿来源快照。']], + ['Substrate.metadata', ['structured-json', '板材基材扩展元数据。']], + ['AssetFamily.metadata', ['structured-json', '资产族扩展元数据。']], + ['AssetSeries.metadata', ['structured-json', '系列扩展元数据。']], + ['AssetVariant.dimensions', ['structured-json', '资产规格款尺寸对象。']], + ['AssetVariant.mountPoints', ['geometry-json', '资产规格款连接点。']], + ['AssetVariant.assemblyBasis', ['geometry-json', '资产规格款装配依据。']], + ['AssetVariant.compatibilityRules', ['structured-json', '资产规格款兼容规则。']], + ['AssetVariant.namingFields', ['structured-json', '资产规格款命名字段。']], + [ + 'AssetVariant.businessData', + [ + 'structured-json', + '资产规格款业务扩展资料;cadData 只允许恢复装配几何 assemblyBasis,不能反推正式 SKU 身份、成本、条码或基础资料;编码、条码、成本、重量等真相必须走 AssetVariant 结构化列和 ProductSkuAssetProfile。', + ], + ], + ['AssetSkuSyncEvent.payload', ['sync-event', '资产 SKU 同步事件载荷。']], + ['AssetSkuSyncEvent.progress', ['sync-event', '资产 SKU 同步进度。']], + ['DraftGroup.summary', ['process-snapshot', '草稿组摘要快照。']], + ['AssistedAssemblyAiRender.params', ['process-snapshot', '辅助装配 AI 生图参数。']], + ['AssistedAssemblyAiRender.resultImages', ['process-snapshot', '辅助装配 AI 生图结果。']], + ['AssistedAssemblyAiRender.workspaceSnapshot', ['process-snapshot', '辅助装配工作区快照。']], + ['AssistedAssemblyAiRender.assetSnapshot', ['process-snapshot', '辅助装配资产快照。']], + ['AssistedAssemblyAiRender.attempts', ['process-snapshot', '辅助装配 AI 生图尝试记录。']], + ['PreSku.familySelection', ['compatibility-snapshot', '准 SKU 资产族选择快照;正式基础资料主读必须走 PreSkuBasicInfo。']], + ['PreSku.variantSelection', ['process-snapshot', '准 SKU 规格款组合快照。']], + ['PreSku.recommendedHardware', ['process-snapshot', '准 SKU 推荐五金快照。']], + ['PreSku.previewNodes', ['geometry-json', '准 SKU 预览节点快照。']], + ['PreSku.displaySummary', ['compatibility-snapshot', '准 SKU 展示摘要;正式字段主读必须走 PreSkuBasicInfo。']], + ['PreSku.bomPreview', ['process-snapshot', '准 SKU 入库前 BOM 预览。']], + ['PreSku.explosionPreview', ['geometry-json', '准 SKU 爆炸图预览。']], + ['PreSku.installStepPreview', ['process-snapshot', '准 SKU 安装步骤预览。']], + ['PreSku.packagingSnapshot', ['process-snapshot', '准 SKU 包装快照。']], + ['PreSkuPackagingPlan.boxes', ['structured-json', '准 SKU 包装箱规。']], + [ + 'PreSkuPackagingPlan.costSummary', + [ + 'structured-json', + '准 SKU 包装成本结构化摘要;只作为包装方案内部成本快照,不是正式 SKU 成本或财务成本真相;正式产品/SKU 成本必须由正式 BOM/核价结构化链路承接。', + ], + ], + [ + 'PreSkuPackagingPlan.processDraft', + [ + 'process-snapshot', + '准 SKU 包装过程草稿;只保存入库前包装编辑过程,不得承接正式 SKU 基础资料、库存、成本结论或权限真相。', + ], + ], + [ + 'PackagedItem.payload', + [ + 'process-snapshot', + '轻仓库包装项过程扩展载荷;只允许保存包装执行上下文和展示补充,不得承接成本、库存、SKU 身份或基础资料真相;包装数量、位置、状态、尺寸和正式成本应走 PackagedItem/PackagingPlan/PreSkuPackagingPlan 结构化字段。', + ], + ], + ['ProductSkuQualityInspection.metrics', ['process-snapshot', '正式 SKU 质检指标过程证据;质检结论和数量仍以结构化列为准。']], + ['ProductSkuQualityInspection.attachments', ['process-snapshot', '正式 SKU 质检附件过程证据;附件不能承接正式 SKU 基础资料真相。']], + [ + 'EmployeeLookupCache.result', + [ + 'integration-cache', + '员工外部匹配短期缓存结果;只用于花名册/钉钉匹配加速,不是人员、部门、权限或业务类型真相;真相必须写入 User/Department/EmployeeDirectory/Team 结构化表。', + ], + ], + ['Department.rawSnapshot', ['integration-cache', '组织部门外部原始快照。']], + ['ApprovalWorkflow.fieldMatrix', ['process-snapshot', '审批流字段矩阵。']], + ['ApprovalWorkflow.summary', ['process-snapshot', '审批流摘要。']], + ['ApprovalWorkflow.metadata', ['process-snapshot', '审批流扩展元数据。']], + ['ApprovalWorkflowFieldChange.oldValue', ['audit-log', '审批字段变更旧值。']], + ['ApprovalWorkflowFieldChange.newValue', ['audit-log', '审批字段变更新值。']], + ['ApprovalWorkflowFieldChange.metadata', ['audit-log', '审批字段变更元数据。']], + ['EmployeeDirectory.departmentExternalIds', ['integration-cache', '员工目录外部部门 ID 列表。']], + ['EmployeeDirectory.rawSnapshot', ['integration-cache', '员工目录外部原始快照。']], + ['Feedback.comments', ['audit-log', '反馈评论列表。']], + ['ActivityLog.changes', ['audit-log', '操作日志变更内容。']], + ['ActivityLog.metadata', ['audit-log', '操作日志元数据。']], + ['ThemeConfig.homeBackground', ['ui-config', '主题首页背景配置。']], + ['ThemeConfig.loginPanel', ['ui-config', '主题登录面板配置。']], + ['Hardware.specifications', ['structured-json', '五金规格。']], + ['Hardware.dimensions', ['structured-json', '五金尺寸对象。']], + ['Hardware.accessories', ['structured-json', '五金配件列表。']], + ['Hardware.mountPoints', ['geometry-json', '五金连接点。']], + ['ProductHardware.transform', ['geometry-json', '产品五金几何变换。']], + ['ProductHardware.sourceSignature', ['geometry-json', '产品五金来源几何签名。']], + ['CommonComponent.dimensions', ['structured-json', '通用组件尺寸对象。']], + ['CommonComponent.specifications', ['structured-json', '通用组件规格。']], + ['CommonComponent.mountPoints', ['geometry-json', '通用组件连接点。']], + [ + 'SystemConfig.value', + [ + 'configuration', + '系统配置 JSON 值;必须按 key 由服务层 schema 校验,不得保存用户、权限、产品、SKU、资产等业务主数据;数据库只保存配置真相,敏感密钥不得写入共享文档或前端。', + ], + ], + [ + 'ProviderConfig.extra', + [ + 'configuration', + '供应商/AI 提供方配置扩展;只保存该 provider/serviceType 的连接附加参数,不得承接业务主数据或权限真相;结构和密钥处理必须由 ProviderConfig 服务层按类型校验。', + ], + ], + ].map(([field, [category, targetState]]) => [ + field, + { category, targetState }, + ]), +) + +const schemaJsonFieldBoundaryRequirements = new Map([ + [ + 'AssetVariant.businessData', + { + reason: 'AssetVariant.businessData 名称太宽,必须明确 CAD 快照和正式 SKU/成本/条码真相边界。', + requiredPatterns: [ + /cadData/, + /assemblyBasis/, + /不能反推正式 SKU 身份、成本、条码或基础资料/, + /AssetVariant 结构化列/, + /ProductSkuAssetProfile/, + ], + }, + ], + [ + 'PreSkuPackagingPlan.costSummary', + { + reason: '包装成本摘要容易被误当正式 SKU 成本或财务成本,必须明确只是包装方案内部快照。', + requiredPatterns: [ + /包装成本/, + /不是正式 SKU 成本或财务成本真相/, + /正式 BOM\/核价结构化链路/, + ], + }, + ], + [ + 'PreSkuPackagingPlan.processDraft', + { + reason: '包装过程草稿必须明确不能承接正式 SKU 基础资料、库存、成本结论或权限真相。', + requiredPatterns: [ + /过程草稿/, + /不得承接正式 SKU 基础资料/, + /库存、成本结论或权限真相/, + ], + }, + ], + [ + 'PackagedItem.payload', + { + reason: '轻仓库包装 payload 名称太宽,必须明确不能承接成本、库存、SKU 身份或基础资料真相。', + requiredPatterns: [ + /包装项过程扩展载荷/, + /不得承接成本、库存、SKU 身份或基础资料真相/, + /PackagedItem\/PackagingPlan\/PreSkuPackagingPlan 结构化字段/, + ], + }, + ], + [ + 'EmployeeLookupCache.result', + { + reason: '员工匹配缓存不能变成人员、部门、权限或业务类型真相。', + requiredPatterns: [ + /短期缓存/, + /不是人员、部门、权限或业务类型真相/, + /User\/Department\/EmployeeDirectory\/Team 结构化表/, + ], + }, + ], + [ + 'SystemConfig.value', + { + reason: '通用系统配置 JSON 必须按 key 校验,不能成为业务主数据万能口袋。', + requiredPatterns: [ + /按 key/, + /服务层 schema 校验/, + /不得保存用户、权限、产品、SKU、资产等业务主数据/, + ], + }, + ], + [ + 'ProviderConfig.extra', + { + reason: '供应商扩展配置必须绑定 provider/serviceType,不能承接业务主数据或权限真相。', + requiredPatterns: [ + /provider\/serviceType/, + /不得承接业务主数据或权限真相/, + /ProviderConfig 服务层按类型校验/, + ], + }, + ], +]) + +function findSchemaJsonFieldBoundaryIssues( + classifications = schemaJsonFieldClassifications, + requirements = schemaJsonFieldBoundaryRequirements, +) { + const issues = [] + + for (const [id, requirement] of requirements.entries()) { + const classification = classifications.get(id) + if (!classification) { + issues.push({ + ruleId: 'schema-json-field-boundary-missing', + id, + reason: requirement.reason, + missingRequiredPatterns: requirement.requiredPatterns.map(String), + }) + continue + } + + const missingRequiredPatterns = requirement.requiredPatterns.filter((pattern) => { + pattern.lastIndex = 0 + return !pattern.test(classification.targetState) + }) + + if (missingRequiredPatterns.length === 0) continue + + issues.push({ + ruleId: 'schema-json-field-boundary-too-vague', + id, + category: classification.category, + targetState: classification.targetState, + reason: requirement.reason, + missingRequiredPatterns: missingRequiredPatterns.map(String), + }) + } + + return issues +} + +function getSchemaJsonFieldInventory() { + const content = fs.readFileSync(PRISMA_SCHEMA_PATH, 'utf8') + const fields = [] + let currentModel = '' + + content.split(/\r?\n/).forEach((line, index) => { + const modelMatch = line.match(/^model\s+(\w+)\s*\{/) + if (modelMatch) { + currentModel = modelMatch[1] + return + } + if (currentModel && /^}/.test(line)) { + currentModel = '' + return + } + if (!currentModel || !/\bJson\b/.test(line)) return + + const fieldName = line.trim().split(/\s+/)[0] + const id = `${currentModel}.${fieldName}` + const classification = schemaJsonFieldClassifications.get(id) + fields.push({ + id, + model: currentModel, + field: fieldName, + line: index + 1, + category: classification?.category ?? 'unclassified', + targetState: classification?.targetState ?? '', + }) + }) + + return fields +} + +function findStaleSchemaJsonFieldClassifications(schemaJsonFields) { + const schemaJsonFieldIds = new Set(schemaJsonFields.map((field) => field.id)) + return Array.from(schemaJsonFieldClassifications.entries()) + .filter(([id]) => !schemaJsonFieldIds.has(id)) + .map(([id, classification]) => ({ + id, + category: classification.category, + targetState: classification.targetState, + })) +} + +function getSchemaJsonFieldClassifications() { + return new Map(schemaJsonFieldClassifications) +} + +function findStaleFileClassifications(groups) { + const findings = [] + + for (const group of groups) { + for (const file of group.files) { + if (fs.existsSync(path.join(ROOT_DIR, file))) continue + findings.push({ + group: group.name, + file, + reason: group.reason, + }) + } + } + + return findings +} + +function buildSnapshotReadBudgetViolations(summary, budget, label) { + const actual = { + total: summary.total ?? 0, + high: summary.high ?? 0, + medium: summary.medium ?? 0, + unknown: summary.unknown ?? 0, + } + + return Object.entries(budget) + .filter(([, expectedMax]) => typeof expectedMax === 'number') + .filter(([key, expectedMax]) => (actual[key] ?? 0) > expectedMax) + .map(([key, expectedMax]) => ({ + label, + key, + actual: actual[key] ?? 0, + expectedMax, + reason: + 'JSON/快照读点债务预算被突破;新增读点必须结构化、降级为明确兼容入口,或同步更新预算和治理说明。', + })) +} + +function buildSnapshotReadBudgetReport(compatibilitySummary, processSummary) { + const compatibilityBudgetViolations = buildSnapshotReadBudgetViolations( + compatibilitySummary.compatibilitySnapshotReadSummary, + SNAPSHOT_READ_DEBT_BUDGETS.compatibilitySnapshotRead, + 'compatibilitySnapshotRead', + ) + const processBudgetViolations = buildSnapshotReadBudgetViolations( + processSummary.processSnapshotReadSummary, + SNAPSHOT_READ_DEBT_BUDGETS.processSnapshotRead, + 'processSnapshotRead', + ) + + return { + budgets: SNAPSHOT_READ_DEBT_BUDGETS, + compatibilitySnapshotReadBudgetViolationCount: compatibilityBudgetViolations.length, + compatibilitySnapshotReadBudgetViolations: compatibilityBudgetViolations, + processSnapshotReadBudgetViolationCount: processBudgetViolations.length, + processSnapshotReadBudgetViolations: processBudgetViolations, + } +} + +const formalPreSkuFields = [ + 'skuCode', + 'draftSkuCode', + 'skuSequenceCode', + 'formalSkuCode', + 'barcode', + 'specName', + 'versionNo', + 'model', +] + +const rules = [ + { + id: 'pre-sku-legacy-text-reader-for-formal-fields', + severity: 'error', + description: + '准 SKU 正式字段不能再通过 readPreSkuTextField 从 displaySummary/familySelection 读取。', + pattern: new RegExp( + `(? isTestFile(relativePath), + }, + { + id: 'pre-sku-display-summary-formal-dot-read', + severity: 'error', + description: + '准 SKU 正式字段主读必须走 PreSkuBasicInfo,不能点读 displaySummary/familySelection。', + pattern: new RegExp( + `\\b(displaySummary|familySelection)\\??\\.\\s*(${formalPreSkuFields.join('|')})\\b`, + 'g', + ), + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'pre-sku-display-summary-formal-bracket-read', + severity: 'error', + description: + '准 SKU 正式字段主读必须走 PreSkuBasicInfo,不能 bracket 读取 displaySummary/familySelection。', + pattern: new RegExp( + `\\b(displaySummary|familySelection)\\s*\\[\\s*['"\`](${formalPreSkuFields.join('|')})['"\`]\\s*\\]`, + 'g', + ), + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'pre-sku-display-summary-repository-call', + severity: 'error', + description: + '准 SKU 冲突检测必须查 PreSkuBasicInfo;禁止恢复 findByDisplaySummaryField(s) 这类 JSON path 查询入口。', + pattern: /\bfindByDisplaySummaryFields?\s*\(/g, + isAllowed: () => false, + }, + { + id: 'product-sku-attributes-sku-name-read', + severity: 'error', + description: + '正式 SKU 名称必须使用 ProductSKU.name;attributes.skuName 只能作为 legacyAttributes 展示或测试样本,不能参与正式写入。', + pattern: /\battributes\??\.\s*skuName\b/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'product-sku-upload-attributes-sku-name-write', + severity: 'error', + description: + '上传链 skuAttributes.skuName 只能作为兼容属性保存,不能反写 ProductSKU.name。', + pattern: /\bskuAttributes\w*\??\.\s*skuName\b/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'product-sku-legacy-selection-fallback', + severity: 'error', + description: + '正式入库相似度判断必须读取 sourcePreSku;variantSelectionSummary/bomPreview 旧快照不能参与正式阻断判断。', + pattern: /\battributes\??\.\s*(variantSelectionSummary|bomPreview)\b/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'product-sku-asset-snapshot-direct-read', + severity: 'error', + description: + 'assetSkuSnapshot 是兼容快照;业务判断必须读取 ProductSkuAssetProfile,不能再直接读取旧快照。', + pattern: /\battributes\??\.\s*assetSkuSnapshot\b|path:\s*\[\s*['"]assetSkuSnapshot/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'asset-variant-business-data-cad-data-direct-read', + severity: 'error', + description: + 'AssetVariant.businessData.cadData 只能作为资产中心 CAD 几何兼容快照恢复 assemblyBasis;不能反推正式 SKU 身份、成本、条码或基础资料。', + pattern: /\bbusinessData\??\.\s*cadData\b/g, + isAllowed: ({ relativePath }) => + isTestFile(relativePath) || + relativePath === 'src/app/asset-center/asset-center-utils.ts' || + relativePath === 'src/lib/open-product/asset-similarity.ts' || + relativePath === 'src/server/services/asset-center.service.ts', + }, + { + id: 'approval-workflow-field-matrix-permission-fallback', + severity: 'error', + description: + '审批流程访问权限必须读取结构化 businessRoles / participantUserIds;fieldMatrix 只能表达字段责任矩阵,不能反推流程可见范围。', + pattern: /\bgetWorkflowBusinessRoles\(\s*this\.getFieldMatrix\(\s*workflow\.fieldMatrix/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'reference-library-payload-identity-read', + severity: 'error', + description: + '资料库 payload 只能保存扩展展示字段;id/itemKey/code/name/category 等身份字段必须读取 IntakeReferenceLibraryItem 结构化列。', + pattern: + /\b(?:readText|readPayloadText)\(\s*(?:payload|nestedItem|source|rulePayload)\.(?:id|itemKey|code|name|letter|category|productCategory|categoryCode|prefix)\s*\)/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'formal-product-date-now-identity-fallback', + severity: 'error', + description: + '正式产品型号是主数据身份,不能用 Date.now() 生成临时 SPU;新建正式产品必须显式传入并校验 modelNumber。', + pattern: /SPU-\$\{\s*Date\.now\(\)\s*\}/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'draft-group-date-now-code-fallback', + severity: 'error', + description: + '草稿组编号是辅助装配承接单身份,不能只用 Date.now() 生成;必须使用可读前缀并带足够熵,数据库唯一索引只做兜底。', + pattern: /DG-\$\{\s*Date\.now\(\)\s*\}/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'formal-sku-code-fallback-after-structured-basic-info', + severity: 'error', + description: + '正式入库 ProductSKU.skuCode 只能来自 PreSkuBasicInfo.formalSkuCode;不能回退到接口临时 skuCode 或 modelNumber 序号。', + pattern: /readPreSkuBasicInfo\s*\(\s*preSku\s*,\s*['"`]formalSkuCode['"`]\s*\)\s*\|\|/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'pre-sku-formalized-status-write-outside-intake', + severity: 'error', + description: + '准 SKU 标记 FORMALIZED 只能发生在正式入库事务内;生命周期修复不能绕过专用证据计划直接改 PreSku.status。', + pattern: + /\b(?:prisma|tx)\.preSku\.(?:update|updateMany)\s*\([\s\S]{0,600}?data:\s*\{[\s\S]{0,240}?status:\s*['"`]FORMALIZED['"`]/g, + isAllowed: ({ relativePath }) => + relativePath === 'src/server/services/intake.service.ts' || isTestFile(relativePath), + }, + { + id: 'product-sku-source-pre-sku-write-outside-intake', + severity: 'error', + description: + 'ProductSKU.sourcePreSkuId 是准 SKU 正式化关系,生产写入只能由正式入库事务创建;历史修复必须走带 currentEvidence 的专用修复入口。', + pattern: + /\b(?:prisma|tx)\.productSKU\.(?:create|update|updateMany|upsert)\s*\([\s\S]{0,2200}?sourcePreSkuId\s*:/g, + isAllowed: ({ relativePath }) => + relativePath === 'src/server/services/intake.service.ts' || isTestFile(relativePath), + }, + { + id: 'sku-code-conflict-auto-suffix', + severity: 'error', + description: + 'SKU 编码冲突不能自动追加 -1/-2 后缀静默改码;必须阻断并回到业务资料层处理。', + pattern: /\b(?:skuCode|candidate)\s*=\s*`\$\{baseSkuCode\}-\$\{suffix\}`/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'core-identity-shorthand-query-without-normalization', + severity: 'error', + description: + '核心编码/条码查询不能用 where: { code } 这类缩写依赖调用方已规范化;必须显式 trim + uppercase 或调用 normalizeCoreIdentity。', + pattern: /where:\s*\{\s*(?:code|skuCode|modelNumber|barcode)\s*\}/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'core-identity-direct-query-without-normalization', + severity: 'error', + description: + '核心编码/条码查询不能把同名变量直接作为查询值;仓储和服务入口必须显式 normalizeCoreIdentity 或同口径规范化变量。', + pattern: + /where:\s*\{[\s\S]{0,180}\b(code|skuCode|modelNumber|barcode):\s*\1\b[\s\S]{0,180}\}/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'non-integrated-payload-filter-without-profile-guard', + severity: 'error', + description: + '非集成产品列表/筛选/待办查询必须以 IntakeNonIntegratedProductProfile 为主读;payload JSON 查询只能包在 payloadJsonWhenProfileMissing(...) 里作为历史兜底。', + pattern: + /(? + relativePath !== 'src/server/services/intake-non-integrated-product.service.ts' || + isTestFile(relativePath), + }, + { + id: 'non-integrated-raw-payload-update-entrypoint', + severity: 'error', + description: + '非集成产品 payload 是历史兼容快照;禁止恢复 updatePayloadByUser 这种通用写入口,附件/预览等兼容写入必须使用明确命名入口。', + pattern: /\bupdatePayloadByUser\s*\(/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'app-route-critical-foundation-direct-write', + severity: 'error', + description: + 'Route/Page 层不能直接写配置、权限、反馈、审计和 AI 生图地基表;必须走 service/repository 的规范化写入口。', + pattern: + /\b(?:prisma|tx)\.(systemConfig|providerConfig|themeConfig|intakeNodePermissionOverride|assistedAssemblyAiRender|feedback|activityLog)\.(create|update|upsert|delete|deleteMany|updateMany)\s*\(/g, + skip: ({ relativePath }) => !relativePath.startsWith('src/app/') || isTestFile(relativePath), + isAllowed: () => false, + }, + { + id: 'app-route-core-master-data-direct-write', + severity: 'error', + description: + 'Route/Page 层不能直接写产品资料、资产中心、草稿组和非集成主数据表;必须走 service/repository,保证编码、快照同步和质量状态护栏统一生效。', + pattern: + /\b(?:prisma|tx)\.(product|productSKU|productSkuAssetProfile|draftGroup|preSku|preSkuBasicInfo|assetFamily|assetVariant|intakeNonIntegratedProduct|intakeNonIntegratedProductProfile|intakeNonIntegratedSkuItem|masterDataQualityDecision|productPatentRecord|spuNumberCounter|intakeReferenceLibraryItem)\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g, + skip: ({ relativePath }) => !relativePath.startsWith('src/app/') || isTestFile(relativePath), + isAllowed: () => false, + }, + { + id: 'spu-number-standalone-reservation', + severity: 'error', + description: + '正式 SPU 号段不能单独占号;必须在非集成产品保存事务里调用 reserveInTransaction,确保号段游标和产品资料同事务提交或回滚。', + pattern: /\bspuNumberReservationService\.reserve\s*\(/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'filesystem-json-master-data-source', + severity: 'error', + description: + '应用层不能把本地 JSON 文件读写或导入当作主数据真相;正式资料必须进入数据库或明确的服务端契约,文件 JSON 只能作为导入载荷、迁移计划、备份或测试夹具。', + pattern: + /\b(?:fs\.)?(?:promises\.)?(?:readFileSync|writeFileSync|readFile|writeFile)\s*\([\s\S]{0,220}\.json['"`][\s\S]{0,180}\)|\bimport\s+[^;\n]+?\s+from\s+['"`][^'"`]+\.json['"`]|\brequire\s*\(\s*['"`][^'"`]+\.json['"`]\s*\)|\bimport\s*\(\s*['"`][^'"`]+\.json['"`]\s*\)/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'reference-library-item-key-id-fallback', + severity: 'error', + description: + '资料库条目的业务身份只能来自显式 itemKey 或稳定 code;不能用数据库 id / 前端临时 id 兜底 itemKey。', + pattern: /\bitemKey\s*=\s*[^;\n]*\|\|\s*id\s*\|\|/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'reference-library-item-key-direct-item-id', + severity: 'error', + description: + '资料库保存载荷不能直接把 item.id 当 itemKey;有业务编码的资料必须用 code,缺编码的资料要先定义稳定身份规则。', + pattern: /\bitemKey:\s*item\.id\b/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'reference-library-item-key-item-id-fallback', + severity: 'error', + description: + '资料库保存载荷不能用 code || item.id 这类兜底;有业务编码的资料缺 code 时应阻断,缺编码资料必须使用内容规格派生稳定 key。', + pattern: /\bitemKey:\s*[^,\n]*\|\|\s*item\.id\b/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'reference-library-item-prefixed-ui-id', + severity: 'error', + description: + '资料库保存载荷不能用 `item:${item.id}` / `category:${category.id}` 这类 UI 行 id 派生身份;必须使用业务 code 或内容规格派生的稳定 key。', + pattern: + /(?:\b(?:const|let)\s+itemKey\s*=\s*|\b(?:itemKey|code):\s*)`[\w-]+:\$\{[A-Za-z_$][\w$]*\.id\}`/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, + { + id: 'jst-stock-payload-source-identity-read', + severity: 'error', + description: + '聚水潭存量商品来源身份必须以 IntakeNonIntegratedProductProfile.source* 或 jst-stock-* 业务 ID 为准;payload.metadata.source* 只能作为兼容快照,不能参与同步查找、更新或排除判断。', + pattern: + /\bisJstSkuPayload\s*\(|payload:\s*\{\s*path:\s*\[\s*['"]metadata['"]\s*,\s*['"]sourceSpu['"]/g, + isAllowed: ({ relativePath }) => isTestFile(relativePath), + }, +] + +const advisoryStoragePaths = new Map([]) + +const approvedScriptMasterDataWritePaths = new Map([ + ['prisma/seed.ts', '开发种子数据脚本,只允许用于初始化本地/测试数据,不作为生产主数据修复入口。'], + ['scripts/add-more-skus.ts', '历史演示 SKU 补数脚本,保留为本地测试工具,不作为生产主数据修复入口。'], + [ + 'scripts/cleanup-legacy-product-structure.ts', + '旧 CAD 结构清理脚本;默认 dry-run,--apply 前必须人工确认目标环境、候选清单和备份。', + ], + ['scripts/create-demo-data.js', '演示数据重建脚本,保留为本地测试工具,不作为生产主数据修复入口。'], + ['scripts/seed-test-products.ts', '测试产品种子脚本,保留为测试工具,不作为生产主数据修复入口。'], + [ + 'scripts/sync-board-material-library.ts', + '板材/花色资料库同步维护脚本;只允许受控同步基础资料,不作为临时补数入口。', + ], + [ + 'scripts/sync-jst-sku-stock-products.ts', + '聚水潭存量商品同步脚本;只允许从已确认来源库同步到已确认目标库,不作为临时补数入口。', + ], + [ + 'scripts/sync-standard-finishes.ts', + '标准花色同步维护脚本;只允许受控同步基础资料,不作为临时补数入口。', + ], + [ + 'scripts/apply-pre-sku-formalization-repair.js', + '准 SKU 生命周期专项修复入口;只允许处理 currentEvidence/evidenceFingerprint 匹配的历史错位,不作为正式入库替代入口。', + ], + ['scripts/update-demo-models.js', '演示模型数据脚本,保留为本地测试工具,不作为生产主数据修复入口。'], + ['scripts/update-model-urls.ts', '旧模型 URL 维护脚本,执行前必须人工确认目标环境和备份。'], + ['scripts/update-test-data.ts', '测试数据维护脚本,保留为测试工具,不作为生产主数据修复入口。'], +]) + +const approvedScriptSafetyGuardRequirements = new Map([ + [ + 'prisma/seed.ts', + { + reason: '种子脚本会清核心表,必须同时具备显式确认、本机库限制和 production 拦截。', + requiredTexts: [ + 'reset-local-seed', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + "await import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/create-demo-data.js', + { + reason: '演示数据脚本会清核心表,必须同时具备显式确认、本机库限制和 production 拦截。', + requiredTexts: [ + 'reset-local-demo', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + 'assertLocalDestructiveDemoAllowed()\n createPrismaClient()', + 'PrismaPg', + ], + }, + ], + [ + 'scripts/update-demo-models.js', + { + reason: '演示模型脚本会清核心表,必须同时具备显式确认、本机库限制和 production 拦截。', + requiredTexts: [ + 'reset-local-demo', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + 'assertLocalDestructiveDemoAllowed()\n createPrismaClient()', + 'PrismaPg', + ], + }, + ], + [ + 'scripts/seed-open-platform-demo.ts', + { + reason: '开放平台演示种子会写产品、资产、草稿、仓库和用户资料,必须同时具备显式确认、本机库限制和 production 拦截。', + requiredTexts: [ + 'seed-local-open-platform-demo', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + "await Promise.all([", + "import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/add-more-skus.ts', + { + reason: '演示 SKU 补数脚本会写正式 SKU,必须同时具备显式确认、本机库限制和 production 拦截。', + requiredTexts: [ + 'add-local-demo-skus', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + "await import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/seed-test-products.ts', + { + reason: '测试产品种子脚本会写正式产品和 SKU,必须同时具备显式确认、本机库限制和 production 拦截。', + requiredTexts: [ + 'seed-local-test-products', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + "await import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/update-test-data.ts', + { + reason: '测试数据维护脚本会补写正式产品、SKU、分享和装配步骤,必须同时具备显式确认、本机库限制和 production 拦截。', + requiredTexts: [ + 'update-local-test-data', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + "await import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/cleanup-legacy-product-structure.ts', + { + reason: '旧 CAD 结构清理脚本 apply 会删除旧结构数据,必须绑定确认词、目标库身份和备份 SHA。', + requiredTexts: [ + 'cleanup-legacy-product-structure', + '--expected-database-identity', + '--expected-backup-sha256', + 'DATABASE_BACKUP_FILE', + "await import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/backfill-sku-structural-data.ts', + { + reason: 'SKU 结构数据回填会通过服务补写板件/孔位/五金结构,真实执行必须绑定确认词、目标库身份和备份 SHA。', + requiredTexts: [ + '--apply', + 'backfill-sku-structural-data', + '--expected-database-identity', + '--expected-backup-sha256', + 'DATABASE_BACKUP_FILE', + 'await Promise.all([', + "import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/sync-board-material-library.ts', + { + reason: '板材基础资料同步会写基础资料,默认必须拒绝写库,真实执行必须绑定确认词、目标库身份和备份 SHA。', + requiredTexts: [ + '--apply', + '默认禁止写库', + 'sync-board-material-library', + '--expected-database-identity', + '--expected-backup-sha256', + 'DATABASE_BACKUP_FILE', + 'await import("../src/lib/prisma")', + ], + }, + ], + [ + 'scripts/sync-jst-sku-stock-products.ts', + { + reason: '聚水潭存量商品同步会写入产品资料,真实执行必须绑定确认词、目标库身份和来源库身份。', + requiredTexts: [ + 'sync-jst-sku-stock', + '--expected-database-identity', + '--expected-source-database-identity', + 'DATABASE_URL', + 'JST_SKU_DATABASE_URL', + "await Promise.all([", + "import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/sync-standard-finishes.ts', + { + reason: '标准饰面同步会写基础资料,默认必须拒绝写库,真实执行必须绑定确认词、目标库身份和备份 SHA。', + requiredTexts: [ + '--apply', + '默认禁止写库', + 'sync-standard-finishes', + '--expected-database-identity', + '--expected-backup-sha256', + 'DATABASE_BACKUP_FILE', + "await import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/backfill-asset-variant-thumbnails.ts', + { + reason: '缩略图回填会写资产资料和生成文件,默认必须拒绝写库,真实执行必须绑定确认词、目标库身份和备份 SHA,并且在门禁后加载服务。', + requiredTexts: [ + '--apply', + '默认禁止写库/生成文件', + 'backfill-asset-variant-thumbnails', + '--expected-database-identity', + '--expected-backup-sha256', + 'DATABASE_BACKUP_FILE', + "import('../src/lib/prisma')", + "import('../src/server/services/asset-center.service')", + ], + }, + ], + [ + 'scripts/apply-pre-sku-formalization-repair.js', + { + reason: '准 SKU 生命周期修复会写 PreSku / ProductSKU / DraftGroup,真实执行必须绑定确认词、当前证据、目标库身份和备份 SHA。', + requiredTexts: [ + '--apply', + 'I_UNDERSTAND_THIS_UPDATES_PRE_SKU_LIFECYCLE', + '--expected-database-identity', + '--expected-backup-sha256', + 'DATABASE_BACKUP_FILE', + 'currentEvidence', + 'evidenceFingerprint', + ], + }, + ], + [ + 'scripts/sync-employee-directory.ts', + { + reason: '花名册同步默认只能 dry-run,真实推送到系统内部接口必须显式 --apply。', + requiredTexts: ['--apply', '--dry-run', '|| !apply'], + }, + ], + [ + 'scripts/update-model-urls.ts', + { + reason: '旧模型 URL 维护脚本会批量改正式产品模型地址,必须同时具备显式确认、本机库限制和 production 拦截。', + requiredTexts: [ + 'update-local-model-urls', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + "await import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/performance-test.ts', + { + reason: '性能测试虽然只读,但会对数据库制造查询压力;必须限制为显式本机读库测试。', + requiredTexts: [ + 'run-local-performance-test', + 'NODE_ENV', + 'production', + 'localhost', + '127.0.0.1', + "await import('../src/lib/prisma')", + ], + }, + ], + [ + 'scripts/install-employee-directory-sync-timer.sh', + { + reason: '生产花名册定时器必须显式传入 --apply,避免脚本默认 dry-run 后假同步。', + requiredTexts: ['sync-employee-directory.ts --apply'], + }, + ], +]) + +const approvedRawSqlMasterDataWritePaths = new Map([ + [ + 'scripts/master-data-required-migrations.js', + '治理迁移清单只允许登记幂等 repairSql,用于补齐迁移副作用里的触发器、约束和历史字段清洗;实际执行仍由 foundation dry-run / rehearsal 包裹事务并受 --apply 边界控制。', + ], + [ + 'scripts/apply-master-data-cleanup-plan.js', + '显式历史清洗计划使用原生 SQL 更新结构化主数据和兼容快照;默认 dry-run,--apply 必须带业务确认。', + ], + [ + 'scripts/apply-pre-sku-formalization-repair.js', + '准 SKU 生命周期专项修复计划使用原生 SQL 更新 PreSku 状态、ProductSKU.sourcePreSkuId 和 DraftGroup 状态;默认 dry-run,--apply 必须带业务确认、当前证据、库身份和备份 SHA。', + ], + [ + 'src/server/services/spu-number-reservation.service.ts', + 'SPU 号段服务使用原生 SQL 在同一事务内初始化和推进 SpuNumberCounter,并依赖行锁保证并发安全。', + ], +]) + +const approvedRawSqlMasterDataWriteTableScopes = new Map( + [ + [ + 'scripts/master-data-required-migrations.js', + [ + 'IntakeNonIntegratedSkuItem', + 'IntakeNonIntegratedProductProfile', + 'PreSkuBasicInfo', + 'ProductSkuAssetProfile', + 'IntakeNonIntegratedProduct', + 'ProductSKU', + 'SpuNumberCounter', + ], + ], + [ + 'scripts/apply-master-data-cleanup-plan.js', + [ + 'IntakeNonIntegratedProduct', + 'IntakeNonIntegratedProductProfile', + 'IntakeNonIntegratedSkuItem', + 'MasterDataQualityDecision', + ], + ], + [ + 'scripts/apply-pre-sku-formalization-repair.js', + [ + 'DraftGroup', + 'PreSku', + 'ProductSKU', + ], + ], + ['src/server/services/spu-number-reservation.service.ts', ['SpuNumberCounter']], + ].map(([file, tableNames]) => [file, new Set(tableNames)]), +) + +const approvedRawSqlMasterDataReadPaths = new Map([ + [ + 'scripts/master-data-required-migrations.js', + '治理迁移清单中的 repairSql 允许读取同一目标表的兼容快照字段,用于把旧 JSON 身份迁移到结构化列;不得作为业务读取入口。', + ], + [ + 'scripts/apply-master-data-cleanup-plan.js', + '显式历史清洗计划允许读取待清洗结构化主数据和兼容快照,用于 dry-run、模板输出和 apply 前后对账。', + ], + [ + 'scripts/apply-pre-sku-formalization-repair.js', + '准 SKU 生命周期专项修复入口允许读取 PreSku、ProductSKU、DraftGroup、包装和库位关系,用于 currentEvidence 校验、dry-run 和 apply 前事务内复核。', + ], + [ + 'scripts/verify-master-data-foundation.js', + '内部主数据巡检脚本允许只读扫描核心表、索引、约束、触发器和外部视图,用于生成地基质量报告。', + ], + [ + 'src/server/services/intake-node-permission-settings.service.ts', + '产品资料节点权限服务允许只读检查权限覆盖表结构和读取已保存覆盖规则;结构创建必须来自迁移。', + ], + [ + 'src/server/repositories/intake-product-pricing-query.repository.ts', + '产品核价查询仓储允许只读检索历史非集成 payload 核价状态;这是待结构化迁移的兼容入口,不能扩展为正式产品资料主读。', + ], + [ + 'src/server/services/spu-number-reservation.service.ts', + 'SPU 号段服务允许在同一事务内读取计数器、SPU 规则和现有非集成产品号段,并用行锁保证并发安全。', + ], +]) + +const approvedRawSqlMasterDataReadTableScopes = new Map([ + [ + 'scripts/master-data-required-migrations.js', + new Set([ + 'IntakeNonIntegratedProduct', + 'IntakeNonIntegratedProductProfile', + 'IntakeNonIntegratedSkuItem', + 'IntakeReferenceLibraryItem', + 'PreSku', + 'PreSkuBasicInfo', + 'Product', + 'ProductSKU', + 'ProductSkuAssetProfile', + 'SpuNumberCounter', + ]), + ], + [ + 'scripts/apply-master-data-cleanup-plan.js', + new Set([ + 'IntakeNonIntegratedProduct', + 'IntakeNonIntegratedProductProfile', + 'IntakeNonIntegratedSkuItem', + 'MasterDataQualityDecision', + ]), + ], + [ + 'scripts/apply-pre-sku-formalization-repair.js', + new Set([ + 'DraftGroup', + 'PackagedItem', + 'PreSku', + 'PreSkuLocationBinding', + 'PreSkuPackagingPlan', + 'Product', + 'ProductSKU', + ]), + ], + ['scripts/verify-master-data-foundation.js', '*'], + [ + 'src/server/services/intake-node-permission-settings.service.ts', + new Set(['IntakeNodePermissionOverride']), + ], + [ + 'src/server/repositories/intake-product-pricing-query.repository.ts', + new Set(['IntakeNonIntegratedProduct']), + ], + [ + 'src/server/services/spu-number-reservation.service.ts', + new Set([ + 'IntakeNonIntegratedProduct', + 'IntakeReferenceLibraryItem', + 'SpuNumberCounter', + ]), + ], +]) + +const approvedRuntimeDatabaseDdlPaths = new Map([ + [ + 'scripts/master-data-required-migrations.js', + '治理迁移清单允许登记受控 repairSql 的 trigger/function/check constraint DDL;执行入口必须仍然通过迁移演练或地基巡检事务。', + ], +]) + +const approvedClientStorageUsages = [ + { + file: 'src/app/asset-center/AssetCenterClient.tsx', + category: 'ui-state', + targetState: 'server-owned-asset-series', + reason: '资产中心当前选中系列 UI 状态缓存,不作为主数据真相。', + patterns: [/ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY/], + }, + { + file: 'src/app/first-login/page.tsx', + category: 'auth-display-cache', + targetState: 'server-owned-user', + reason: '首次登录后写入的客户端登录态缓存,用户主数据仍以后端数据库为准。', + patterns: [/localStorage\.setItem\(\s*['"]user['"]/], + }, + { + file: 'src/app/HomeClient.workspace.tsx', + category: 'ui-state', + targetState: 'workspace-tab-session-only', + reason: '首页工作台 tab 会话缓存,不作为主数据真相。', + patterns: [/HOME_WORKSPACE_TAB_STORAGE_KEY/], + }, + { + file: 'src/app/home/hooks/usePersistedExpandedProduct.ts', + category: 'ui-state', + targetState: 'home-expanded-product-session-only', + reason: '首页产品展开和滚动位置会话缓存,不作为主数据真相。', + patterns: [ + /RESTORE_EXPANDED_PRODUCT_STORAGE_KEY/, + /EXPANDED_PRODUCT_STORAGE_KEY/, + /HOME_SCROLL_Y_STORAGE_KEY/, + ], + }, + { + file: 'src/app/draft-groups/DraftGroupsFormalClient.tsx', + category: 'ui-state', + targetState: 'draft-group-formal-pane-width-cache-only', + reason: '草稿组正式化左右面板宽度缓存,只影响本机 UI 布局,不作为草稿组、SKU 或产品资料真相。', + patterns: [ + /FORMAL_LEFT_PANE_WIDTH_STORAGE_KEY/, + /FORMAL_RIGHT_PANE_WIDTH_STORAGE_KEY/, + /function\s+readStoredPaneWidth\(/, + ], + }, + { + file: 'src/app/draft-groups/DraftGroupsFormalClient.tsx', + category: 'process-draft-cache', + targetState: 'database-owned-formal-package-component-details', + reason: '草稿组正式化包内组件分配缓存只作为未推送前页面草稿;已推送 SKU 会同步到正式产品 skuItems.componentDetails。', + patterns: [/FORMAL_PACKAGE_COMPONENT_ASSIGNMENTS_STORAGE_KEY/], + requiredEvidence: [ + /async function\s+syncPushedPackageAssignments\s*\(/, + /saveSharedNonIntegratedProduct\s*\(/, + /skuItems:\s*nextSkuItems/, + /componentDetails/, + /NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY/, + ], + }, + { + file: 'src/app/intake/IntakeClient.tsx', + category: 'ui-state', + targetState: 'product-intake-list-filter-or-workspace-session', + reason: '产品资料页列表筛选和工作区 tab 状态缓存,不作为主数据真相。', + patterns: [ + /readPersistedNonIntegratedListState[\s\S]{0,900}localStorage\.getItem\(\s*key/, + /writePersistedNonIntegratedListState[\s\S]{0,900}localStorage\.setItem\(\s*key/, + /getIntakeWorkspaceStateStorageKey\(/, + ], + }, + { + file: 'src/app/intake/non-integrated/NonIntegratedDetailClient.tsx', + category: 'process-draft-cache', + targetState: 'database-owned-non-integrated-product-and-bom', + reason: '非集成详情页 BOM 会话草稿缓存,只做未保存输入兜底,不作为产品/BOM 主数据真相。', + patterns: [/buildNonIntegratedBomDraftStorageKey\(/], + }, + { + file: 'src/lib/3d/playback-progress-manager.ts', + category: 'ui-state', + targetState: 'playback-progress-cache-only', + reason: '3D 播放进度缓存,不作为主数据真相。', + patterns: [/getStorageKey\(\)|storageKeyPrefix/], + }, + { + file: 'src/lib/preferences.tsx', + category: 'ui-preference', + targetState: 'browser-local-display-preference', + reason: '用户显示偏好缓存,不作为主数据真相。', + patterns: [/['"]apple-ui-font-size['"]/], + }, + { + file: 'src/hooks/useAuth.ts', + category: 'auth-display-cache', + targetState: 'server-owned-user-and-permissions', + reason: '客户端登录态展示缓存,不作为人员或权限主数据真相。', + patterns: [/USER_STORAGE_KEY/], + requiredEvidence: [ + /fetch\(\s*['"]\/api\/auth\/me['"]/, + /credentials:\s*['"]include['"]/, + /applyCachedDisplayState/, + /displayUser:\s*cachedUser/, + /user:\s*null/, + /isAuthenticated:\s*false/, + ], + forbiddenEvidence: [ + /isAuthenticated:\s*Boolean\(\s*cachedUser\s*\)/, + /applyAuthenticatedState\(\s*cachedUser\s*\)/, + ], + }, +] + +function getApprovedClientStorageFiles() { + return new Set(approvedClientStorageUsages.map((usage) => usage.file)) +} + +function getMatchWindow(content, index, radius = 900) { + return content.slice(Math.max(0, index - radius), Math.min(content.length, index + radius)) +} + +function resolveApprovedClientStorageUsage({ relativePath, content, match }) { + const usage = approvedClientStorageUsages.find((candidate) => { + if (candidate.file !== relativePath) return false + const windowText = getMatchWindow(content, match.index ?? 0) + return candidate.patterns.some((pattern) => pattern.test(windowText)) + }) + + return usage + ? { + category: usage.category, + targetState: usage.targetState, + reason: usage.reason, + } + : null +} + +function findApprovedClientStorageEvidenceIssues(rootDir = ROOT_DIR) { + const issues = [] + + for (const usage of approvedClientStorageUsages) { + if (!usage.requiredEvidence?.length) continue + + const absolutePath = path.join(rootDir, usage.file) + if (!fs.existsSync(absolutePath)) { + issues.push({ + ruleId: 'approved-client-storage-evidence-missing', + file: usage.file, + category: usage.category, + targetState: usage.targetState, + reason: '已批准的客户端缓存声明了数据库承接目标,但对应文件不存在,无法验证写回证据。', + missingEvidence: usage.requiredEvidence.map(String), + }) + continue + } + + const content = fs.readFileSync(absolutePath, 'utf8') + const missingEvidence = usage.requiredEvidence.filter((pattern) => { + pattern.lastIndex = 0 + return !pattern.test(content) + }) + const matchedForbiddenEvidence = (usage.forbiddenEvidence ?? []).filter((pattern) => { + pattern.lastIndex = 0 + return pattern.test(content) + }) + + if (missingEvidence.length === 0 && matchedForbiddenEvidence.length === 0) continue + + issues.push({ + ruleId: 'approved-client-storage-evidence-missing', + file: usage.file, + category: usage.category, + targetState: usage.targetState, + reason: '已批准的客户端缓存缺少同文件权威承接证据,或仍保留会让客户端缓存变成第二数据真相的代码模式。', + missingEvidence: missingEvidence.map(String), + matchedForbiddenEvidence: matchedForbiddenEvidence.map(String), + }) + } + + return issues +} + +const approvedServerMasterDataWritePaths = new Map([ + ['src/server/repositories/asset-center.repository.ts', '资产中心仓储层写入口,承接系列/资产族/规格款结构化主数据。'], + ['src/server/repositories/assembly.repository.ts', '旧 CAD 装配步骤和装配版本仓储层写入口。'], + ['src/server/repositories/auth.repository.ts', '认证仓储层写入口,承接用户和登录身份维护。'], + ['src/server/repositories/common-component.repository.ts', '通用组件仓储层写入口。'], + ['src/server/repositories/department.repository.ts', '组织部门仓储层写入口。'], + ['src/server/repositories/draft-group.repository.ts', '草稿组仓储层写入口,承接 DraftGroup / PreSku / PreSkuBasicInfo。'], + ['src/server/repositories/employee-directory.repository.ts', '花名册员工目录仓储层写入口。'], + ['src/server/repositories/feedback.repository.ts', '反馈与反馈流程仓储层写入口。'], + ['src/server/repositories/handover.repository.ts', '交接协作仓储层写入口,只承接协作过程记录。'], + ['src/server/repositories/hardware.repository.ts', '五金基础资料仓储层写入口。'], + ['src/server/repositories/hole.repository.ts', '旧 CAD 孔位兼容链仓储层写入口。'], + ['src/server/repositories/intake-non-integrated-product.repository.ts', '非集成产品资料仓储层写入口,承接 profile / skuItems 结构化主数据。'], + ['src/server/repositories/log.repository.ts', '操作日志仓储层写入口,只承接受控审计日志。'], + ['src/server/repositories/material.repository.ts', '板材/材质基础资料仓储层写入口。'], + ['src/server/repositories/packaging.repository.ts', '旧包装方案仓储层写入口。'], + ['src/server/repositories/panel.repository.ts', '旧 CAD 板件兼容链仓储层写入口。'], + ['src/server/repositories/planning.repository.ts', '规划域仓储层写入口,只承接过程模板和草稿。'], + ['src/server/repositories/product-hardware.repository.ts', '旧 CAD 产品五金兼容链仓储层写入口。'], + ['src/server/repositories/product-upload.repository.ts', '产品上传文件元数据仓储层写入口。'], + ['src/server/repositories/product.repository.ts', '正式产品和正式 SKU 仓储层写入口。'], + ['src/server/repositories/share.repository.ts', '分享链接仓储层写入口,只承接访问入口和统计。'], + ['src/server/repositories/sku.repository.ts', '正式 SKU 仓储层写入口。'], + ['src/server/repositories/sku-quality-inspection.repository.ts', '仓库质检归档仓储层写入口,承接首批质检自动正式 SKU 建档和质检记录维护。'], + ['src/server/repositories/theme.repository.ts', '主题配置仓储层写入口。'], + ['src/server/repositories/team.repository.ts', '团队仓储层写入口。'], + ['src/server/repositories/user.repository.ts', '用户仓储层写入口。'], + ['src/server/repositories/warehouse.repository.ts', '轻仓库和库位绑定仓储层写入口。'], + ['src/server/services/approval-workflow.service.ts', '审批流程服务事务写入口,只承接过程审批和字段变更审计。'], + ['src/server/services/asset-center.service.ts', '资产中心服务层事务写入口,负责编码、系列归属和规格款校验。'], + ['src/server/services/asset-sku-sync.service.ts', '资产到正式 SKU 同步服务写入口,负责 ProductSkuAssetProfile 和兼容副本同步。'], + ['src/server/services/assisted-assembly-ai-render.service.ts', '辅助装配 AI 生图服务写入口,负责生图过程记录。'], + ['src/server/services/department.service.ts', '组织部门服务写入口,负责部门规范化和树关系维护。'], + ['src/server/services/dingtalk-auth.service.ts', '钉钉认证服务写入口,只负责登录用户映射维护。'], + ['src/server/services/external-employee-directory.service.ts', '外部花名册服务写入口,负责员工目录同步导入。'], + ['src/server/services/feedback-workflow.helper.ts', '反馈流程 helper 事务写入口,承接反馈流转过程数据。'], + ['src/server/services/feedback-workflow.service.ts', '反馈流程服务写入口,承接反馈流转过程数据。'], + ['src/server/services/hardware-matcher.runtime.ts', '五金匹配运行时写入口,只维护受控匹配记忆。'], + ['src/server/services/hardware-write-ops.ts', '五金写操作服务入口,负责五金基础资料规范化。'], + ['src/server/services/intake-non-integrated-product.service.ts', '非集成产品资料服务写入口,负责 payload 到 profile / skuItems 的结构化承接。'], + ['src/server/services/intake-node-permission-settings.service.ts', '产品资料节点权限覆盖服务写入口。'], + ['src/server/services/intake-reference-library.service.ts', '产品资料侧资料库服务写入口,负责 itemKey/code 唯一池。'], + ['src/server/services/intake.service.ts', '正式入库服务事务写入口,负责草稿到 Product/ProductSKU 的唯一正式落库链路。'], + ['src/server/services/jst-sku-stock-sync.service.ts', '聚水潭存量商品同步服务写入口,负责外部来源和结构化 profile/skuItems 双写。'], + ['src/server/services/logger.service.ts', '操作日志服务写入口,只承接审计日志。'], + ['src/server/services/open-platform-demo-seed.service.ts', '开放平台演示数据服务,仅用于 demo/test 种子,不作为生产主数据修复入口。'], + ['src/server/services/pre-sku-packaging.service.ts', '准 SKU 包装方案服务写入口,负责 PreSkuPackagingPlan 的受控保存。'], + ['src/server/services/product-collaborator.service.ts', '正式产品协作者服务事务写入口,负责 ProductCollaborator 关系和审计日志。'], + ['src/server/services/product-patent.service.ts', '专利台账服务写入口,负责专利分组和身份规范化。'], + ['src/server/services/provider-config.service.ts', 'AI/供应商配置服务写入口,负责配置 key 和激活状态约束。'], + ['src/server/services/sku-cad-import.helper.ts', 'SKU CAD 导入 helper 写入口,负责旧兼容链结构数据和正式 SKU 边界校验。'], + ['src/server/services/sku-command.service.ts', '正式 SKU 命令服务写入口,负责编辑、软删除、恢复和兼容属性维护。'], + ['src/server/services/spu-number-reservation.service.ts', 'SPU 号段服务写入口,只允许事务内 reserveInTransaction 更新号段。'], + ['src/server/services/system-config.service.ts', '系统配置服务写入口,负责通用配置 key/value。'], + ['src/server/services/system-init.service.ts', '系统初始化服务写入口,仅用于受控初始化默认配置。'], + ['src/server/services/team.service.ts', '团队服务写入口,负责组织团队结构维护。'], +]) + +const approvedServerMasterDataWriteModelScopes = new Map( + [ + [ + 'src/server/repositories/asset-center.repository.ts', + ['assetFamily', 'assetFamilySeriesCompatibility', 'assetSeries', 'assetVariant'], + ], + ['src/server/repositories/assembly.repository.ts', ['assemblyStep', 'assemblyVersion']], + ['src/server/repositories/auth.repository.ts', ['activityLog', 'user']], + ['src/server/repositories/common-component.repository.ts', ['commonComponent', 'commonComponentCategory']], + ['src/server/repositories/department.repository.ts', ['department']], + ['src/server/repositories/draft-group.repository.ts', ['draftGroup', 'preSku', 'preSkuBasicInfo', 'preSkuPackagingPlan']], + ['src/server/repositories/employee-directory.repository.ts', ['department', 'employeeDirectory', 'employeeLookupCache', 'user']], + ['src/server/repositories/feedback.repository.ts', ['feedback']], + ['src/server/repositories/handover.repository.ts', ['activityLog', 'productCollaborator', 'productSKU']], + ['src/server/repositories/hardware.repository.ts', ['hardware', 'hardwareCategory', 'hardwareMapping']], + ['src/server/repositories/hole.repository.ts', ['hole']], + [ + 'src/server/repositories/intake-non-integrated-product.repository.ts', + ['intakeNonIntegratedProduct', 'intakeNonIntegratedProductProfile', 'intakeNonIntegratedSkuItem'], + ], + ['src/server/repositories/log.repository.ts', ['activityLog']], + [ + 'src/server/repositories/material.repository.ts', + [ + 'material', + 'materialCategory', + 'productCategory', + 'substrate', + 'substrateCategory', + 'substrateEnvironmentGrade', + 'substrateFinish', + 'substrateSurfaceOption', + 'substrateSystemNameOption', + 'substrateThicknessSpec', + ], + ], + ['src/server/repositories/packaging.repository.ts', ['packagingPlan']], + ['src/server/repositories/panel.repository.ts', ['panel', 'panelMaterial']], + ['src/server/repositories/planning.repository.ts', ['planningDraft', 'planningTemplate', 'planningWorkspace']], + ['src/server/repositories/product-hardware.repository.ts', ['productHardware']], + ['src/server/repositories/product-upload.repository.ts', ['panel', 'product', 'productSKU']], + [ + 'src/server/repositories/product.repository.ts', + ['hole', 'panel', 'product', 'productCollaborator', 'productSKU', 'productSkuAssetProfile'], + ], + ['src/server/repositories/share.repository.ts', ['shareLink']], + ['src/server/repositories/sku.repository.ts', ['activityLog', 'productSKU']], + [ + 'src/server/repositories/sku-quality-inspection.repository.ts', + ['product', 'productSKU', 'productSkuAssetProfile'], + ], + ['src/server/repositories/theme.repository.ts', ['themeConfig']], + ['src/server/repositories/team.repository.ts', ['team']], + ['src/server/repositories/user.repository.ts', ['user']], + [ + 'src/server/repositories/warehouse.repository.ts', + [ + 'assetVariantLocationBinding', + 'brandMaterialBinding', + 'packagedItem', + 'preSkuLocationBinding', + 'warehouseLocation', + ], + ], + ['src/server/services/approval-workflow.service.ts', ['activityLog', 'approvalWorkflow', 'approvalWorkflowFieldChange']], + [ + 'src/server/services/asset-center.service.ts', + [ + 'assetFamily', + 'assetVariant', + 'substrate', + 'substrateCategory', + 'substrateEnvironmentGrade', + 'substrateSurfaceOption', + 'substrateSystemNameOption', + 'substrateThicknessSpec', + ], + ], + ['src/server/services/asset-sku-sync.service.ts', ['assetSkuSyncEvent', 'productSKU', 'productSkuAssetProfile']], + ['src/server/services/assisted-assembly-ai-render.service.ts', ['assistedAssemblyAiRender']], + ['src/server/services/department.service.ts', ['activityLog', 'department']], + ['src/server/services/dingtalk-auth.service.ts', ['user']], + ['src/server/services/external-employee-directory.service.ts', ['employeeLookupCache', 'user']], + ['src/server/services/feedback-workflow.helper.ts', ['activityLog', 'feedback']], + ['src/server/services/feedback-workflow.service.ts', ['activityLog', 'feedback']], + ['src/server/services/hardware-matcher.runtime.ts', ['hardwareMapping', 'productHardware']], + ['src/server/services/hardware-write-ops.ts', ['hardware', 'hardwareCategory', 'hardwareMapping']], + [ + 'src/server/services/intake-non-integrated-product.service.ts', + ['intakeNonIntegratedProductProfile', 'intakeNonIntegratedSkuItem'], + ], + ['src/server/services/intake-reference-library.service.ts', ['intakeReferenceLibraryItem']], + ['src/server/services/intake-node-permission-settings.service.ts', ['intakeNodePermissionOverride']], + [ + 'src/server/services/intake.service.ts', + ['draftGroup', 'hole', 'packagedItem', 'packagingPlan', 'panel', 'preSku', 'preSkuBasicInfo', 'product', 'productHardware', 'productSKU'], + ], + [ + 'src/server/services/jst-sku-stock-sync.service.ts', + ['intakeNonIntegratedProduct', 'intakeNonIntegratedProductProfile', 'intakeNonIntegratedSkuItem'], + ], + ['src/server/services/logger.service.ts', ['activityLog']], + [ + 'src/server/services/open-platform-demo-seed.service.ts', + [ + 'draftGroup', + 'material', + 'materialCategory', + 'packagedItem', + 'product', + 'productCategory', + 'substrateCategory', + 'substrateEnvironmentGrade', + 'substrateSurfaceOption', + 'substrateSystemNameOption', + 'substrateThicknessSpec', + 'user', + ], + ], + ['src/server/services/pre-sku-packaging.service.ts', ['preSkuPackagingPlan']], + ['src/server/services/product-collaborator.service.ts', ['activityLog', 'productCollaborator']], + ['src/server/services/product-patent.service.ts', ['productPatentRecord']], + ['src/server/services/provider-config.service.ts', ['providerConfig']], + ['src/server/services/sku-cad-import.helper.ts', ['hole', 'panel', 'productHardware', 'productSKU']], + ['src/server/services/sku-command.service.ts', ['productSKU', 'productSkuAssetProfile']], + ['src/server/services/spu-number-reservation.service.ts', ['intakeReferenceLibraryItem', 'spuNumberCounter']], + ['src/server/services/system-config.service.ts', ['systemConfig']], + ['src/server/services/system-init.service.ts', ['user']], + ['src/server/services/team.service.ts', ['activityLog', 'team']], + ].map(([file, models]) => [file, new Set(models)]), +) + +function getApprovedMasterDataWriteBoundaryFiles() { + return { + script: Array.from(approvedScriptMasterDataWritePaths.keys()), + server: Array.from(approvedServerMasterDataWritePaths.keys()), + } +} + +if (require.main !== module) { + module.exports = { + approvedRawSqlMasterDataWritePaths, + approvedRawSqlMasterDataWriteTableScopes, + approvedRawSqlMasterDataReadPaths, + approvedRawSqlMasterDataReadTableScopes, + approvedRuntimeDatabaseDdlPaths, + approvedScriptMasterDataWritePaths, + approvedServerMasterDataWritePaths, + approvedServerMasterDataWriteModelScopes, + buildSnapshotReadBudgetReport, + buildSnapshotReadBudgetViolations, + getApprovedMasterDataWriteBoundaryFiles, + findSchemaJsonFieldBoundaryIssues, + getSchemaJsonFieldClassifications, + getSchemaJsonFieldInventory, + parseArgs, + SNAPSHOT_READ_DEBT_BUDGETS, + writeJsonReport, + } +} else { +const advisoryRules = [ + { + id: 'client-local-storage-master-data-candidate', + severity: 'advisory', + description: + '资料库型数据不应长期以 localStorage 作为唯一真相;若用于多人协作、正式流转或外部依赖,必须迁入数据库或稳定服务端契约。', + pattern: + /\b(?:window\.)?(?:localStorage|sessionStorage)\s*\.\s*(?:getItem|setItem|removeItem|clear)\s*\(|\bcreateJSONStorage\s*\(\s*\(\s*\)\s*=>\s*(?:window\.)?(?:localStorage|sessionStorage)\s*\)/g, + resolveCandidate: ({ relativePath, content, match }) => + advisoryStoragePaths.get(relativePath) ?? + resolveApprovedClientStorageUsage({ relativePath, content, match }), + }, +] + +const compatibilitySnapshotReadRules = [ + { + id: 'pre-sku-compatibility-snapshot-direct-read', + description: + 'PreSku.displaySummary/familySelection 是兼容或过程快照;正式基础资料主读必须优先检查 basicInfo。', + pattern: /\b(?:preSku|firstPreSku|sourcePreSku|record)\.(displaySummary|familySelection)\b/g, + }, + { + id: 'non-integrated-payload-direct-read', + description: + 'IntakeNonIntegratedProduct.payload 是兼容快照;产品头和 SKU 主数据主读必须优先检查 profile/skuItems。', + pattern: /\b(?:product|record|existing|existingRecord|row)\.payload\b/g, + }, + { + id: 'product-sku-attributes-direct-read', + description: + 'ProductSKU.attributes 是兼容属性;正式 SKU 主数据主读必须优先检查结构化字段和 assetProfile。', + pattern: /\b(?:sku|currentSku|updatedSku|existing|record|s)\.attributes\b/g, + }, +] + +const processSnapshotReadRules = [ + { + id: 'pre-sku-process-snapshot-direct-read', + description: + 'PreSku.variantSelection/bomPreview/installStepPreview/packagingSnapshot 是过程快照;只能用于装配、BOM、包装和正式化物料生成,不能反推正式编码、品类或权限。', + pattern: + /\b(?:preSku|sourcePreSku|record)\.(variantSelection|bomPreview|installStepPreview|packagingSnapshot)\b/g, + }, +] + +const compatibilitySnapshotReadClassifications = new Map([ + [ + 'scripts/apply-master-data-cleanup-plan.js', + { + category: 'explicit-cleanup-plan', + priority: 'low', + targetState: '显式历史清洗计划允许读兼容快照,但只能在 dry-run / apply 计划里使用,不能成为业务主读。', + }, + ], + [ + 'scripts/backfill-sku-structural-data.ts', + { + category: 'one-time-backfill', + priority: 'low', + targetState: '一次性结构化回填脚本允许读取旧 attributes 作为迁移来源。', + }, + ], + [ + 'scripts/cleanup-legacy-product-structure.ts', + { + category: 'legacy-cad-cleanup', + priority: 'low', + targetState: '旧 CAD 结构清理只把 attributes 作为兼容链判断证据。', + }, + ], + [ + 'src/app/assisted-assembly/page.tsx', + { + category: 'process-geometry-snapshot', + priority: 'low', + targetState: '辅助装配恢复工作区只读取 assemblyWorkspace 过程快照,不承接正式产品主数据。', + }, + ], + [ + 'src/app/intake/EdgeBandingLibraryPanel.tsx', + { + category: 'reference-library-compatibility', + priority: 'medium', + targetState: '资料库 UI 可临时读取 payload 兼容展示;新增业务字段必须进入 IntakeReferenceLibraryItem 结构化列或专用表。', + }, + ], + [ + 'src/app/intake/spuRuleLibraryLogic.ts', + { + category: 'reference-library-compatibility', + priority: 'medium', + targetState: 'SPU 规则库 payload 只保留兼容展示;正式号段和品类唯一池以结构化 code/name/counter 为准。', + }, + ], + [ + 'src/app/products/[id]/assembly/page.tsx', + { + category: 'process-geometry-snapshot', + priority: 'low', + targetState: '旧产品装配页读取 sourcePreSku assemblyWorkspace 只服务兼容 3D 恢复。', + }, + ], + [ + 'src/app/products/[id]/page.tsx', + { + category: 'legacy-display-output', + priority: 'medium', + targetState: '旧产品详情只能以 legacyAttributes 名义展示兼容字段;正式 SKU 标签必须优先 assetProfile / 结构化字段。', + }, + ], + [ + 'src/app/products/[id]/skus/[skuId]/assembly/page.tsx', + { + category: 'legacy-cad-compatibility', + priority: 'medium', + targetState: 'SKU 装配页读取 CAD 兼容 attributes 只服务旧链,新增装配主数据应落结构化表。', + }, + ], + [ + 'src/app/products/[id]/skus/[skuId]/page.tsx', + { + category: 'legacy-display-output', + priority: 'medium', + targetState: '旧 SKU 详情只能以 legacyAttributes 名义展示兼容字段;编辑保存必须写结构化字段。', + }, + ], + [ + 'src/lib/accessory-library.ts', + { + category: 'reference-library-compatibility', + priority: 'medium', + targetState: '配件资料库 payload 只作兼容展示,资料身份和成本/BOM 字段必须走服务端结构化契约。', + }, + ], + [ + 'src/lib/edge-banding-library.ts', + { + category: 'reference-library-compatibility', + priority: 'medium', + targetState: '封边资料库 payload 只作兼容展示,资料身份和成本/BOM 字段必须走服务端结构化契约。', + }, + ], + [ + 'src/lib/open-product/platform-view.ts', + { + category: 'shared-ui-compatibility-helper', + priority: 'medium', + targetState: '共享展示 helper 可保留旧草稿兜底,但新增正式字段必须优先 basicInfo。', + }, + ], + [ + 'src/lib/packaging-material-library.ts', + { + category: 'reference-library-compatibility', + priority: 'medium', + targetState: '包装材料资料库 payload 只作兼容展示,资料身份和成本/BOM 字段必须走服务端结构化契约。', + }, + ], + [ + 'src/lib/steel-frame-library.ts', + { + category: 'reference-library-compatibility', + priority: 'medium', + targetState: '钢架资料库 payload 只作兼容展示,资料身份和成本/BOM 字段必须走服务端结构化契约。', + }, + ], + [ + 'src/server/dto/product.dto.ts', + { + category: 'dto-legacy-output', + priority: 'medium', + targetState: 'DTO 只能输出 legacyAttributes 兼容字段;主合同字段必须来自结构化列。', + }, + ], + [ + 'src/server/dto/sku.dto.ts', + { + category: 'dto-legacy-output', + priority: 'medium', + targetState: 'DTO 只能输出 legacyAttributes 兼容字段;主合同字段必须来自结构化列。', + }, + ], + [ + 'src/server/repositories/intake-product-pricing-query.repository.ts', + { + category: 'historical-product-pricing-payload-query', + priority: 'medium', + targetState: + '产品核价查询当前只允许读取历史非集成 payload 中的确认状态、skuItems 和 pricingRows 作为兼容入口;后续必须迁入结构化核价行/文档确认表,不能作为正式产品资料主真相。', + }, + ], + [ + 'src/server/repositories/product.repository.ts', + { + category: 'lifecycle-compatibility', + priority: 'medium', + targetState: '正式 SKU 软删除/恢复的生命周期身份必须读 ProductSKU 结构化列;attributes 只允许清理旧兼容元数据。', + }, + ], + [ + 'src/server/services/approval-workflow.service.ts', + { + category: 'workflow-context-compatibility', + priority: 'medium', + targetState: '审批流上下文可读取 payload 兼容快照,但审批摘要不能成为产品资料第二真相。', + }, + ], + [ + 'src/server/services/asset-sku-sync.service.ts', + { + category: 'compatibility-copy-maintenance', + priority: 'medium', + targetState: '资产同步服务只允许维护 attributes 兼容副本;业务判断必须继续读 ProductSkuAssetProfile。', + }, + ], + [ + 'src/server/services/draft-group.mapper.ts', + { + category: 'dto-compatibility-fallback', + priority: 'medium', + targetState: '草稿组 mapper 可保留 displaySummary/familySelection 兼容输出,但 basicInfo 必须是正式字段主读。', + }, + ], + [ + 'src/server/services/draft-group.service.ts', + { + category: 'pre-sku-basic-info-cleanup', + priority: 'low', + targetState: '草稿组服务只允许读取 displaySummary 用于清理旧基础资料字段;冲突检测、保存和自动建议必须走 PreSkuBasicInfo 与资产中心快照。', + }, + ], + [ + 'src/server/services/intake-non-integrated-product.service.ts', + { + category: 'payload-hydration-compatibility', + priority: 'medium', + targetState: '非集成服务可用 payload 拼装旧记录视图;产品头和 SKU 主数据必须优先 profile / skuItems。', + }, + ], + [ + 'src/server/services/intake-reference-library.service.ts', + { + category: 'reference-library-contract-output', + priority: 'low', + targetState: '资料库服务返回 payload 作为扩展载荷,身份和编码仍由结构化列约束。', + }, + ], + [ + 'src/server/services/intake.service.ts', + { + category: 'process-snapshot-formalization', + priority: 'medium', + targetState: '正式入库可读取装配/BOM 过程快照生成结果,但正式编码和基础资料必须来自 PreSkuBasicInfo。', + }, + ], + [ + 'src/server/services/jst-sku-stock-sync.service.ts', + { + category: 'external-sync-compatibility-copy', + priority: 'medium', + targetState: '聚水潭同步可维护 payload 兼容副本;来源身份和筛选必须落 profile 结构化字段。', + }, + ], + [ + 'src/server/services/share.service.ts', + { + category: 'public-legacy-output', + priority: 'medium', + targetState: '分享页只能输出过滤后的 legacyAttributes;公开主字段必须来自结构化 DTO。', + }, + ], + [ + 'src/server/services/sku-soft-delete.helper.ts', + { + category: 'lifecycle-compatibility', + priority: 'medium', + targetState: '软删除 helper 必须优先使用 ProductSKU.originalSkuCode 等结构化列;attributes 只允许作为旧数据回退和清理来源。', + }, + ], + [ + 'src/server/services/spu-number-reservation.service.ts', + { + category: 'legacy-spu-rule-compatibility', + priority: 'medium', + targetState: 'SPU 号段服务只能读取旧 payload 规则作初始化兼容;正式占号以 SpuNumberCounter 为准。', + }, + ], +]) + +const processSnapshotReadClassifications = new Map([ + [ + 'src/app/assisted-assembly/page.tsx', + { + category: 'assisted-assembly-workspace-restore', + priority: 'low', + targetState: + '辅助装配页面可读取准 SKU 过程快照恢复 3D 工作区和已选物料;正式基础资料不得从这些快照反推。' + }, + ], + [ + 'src/app/draft-groups/draftGroupDisplay.ts', + { + category: 'draft-group-readonly-material-summary', + priority: 'medium', + targetState: + '草稿组列表可按 variantSelection 汇总板材、组件和五金展示;编码、条码和品类必须继续走 basicInfo 或资产中心结构化字段。' + }, + ], + [ + 'src/app/draft-groups/DraftGroupsFormalClient.tsx', + { + category: 'draft-group-formal-readonly-material-summary', + priority: 'medium', + targetState: + '草稿组正式化页面可从 variantSelection 读取板材、组件和五金构成用于只读展示与建议;正式编码、条码、品类和权限必须继续走 PreSkuBasicInfo 或结构化来源。' + }, + ], + [ + 'src/app/draft-groups/[id]/DraftGroupDetailClient.tsx', + { + category: 'draft-group-readonly-process-preview', + priority: 'medium', + targetState: + '草稿组详情可展示 BOM 和安装步骤过程预览;补齐正式资料必须写 PreSkuBasicInfo。' + }, + ], + [ + 'src/app/intake/IntakeClient.tsx', + { + category: 'intake-readonly-pre-sku-material-view', + priority: 'medium', + targetState: + '产品资料前端可读取准 SKU 过程快照展示物料构成;正式入库提交仍以后端 PreSkuBasicInfo 校验为准。' + }, + ], + [ + 'src/app/intake/page.tsx', + { + category: 'intake-server-readonly-material-view', + priority: 'medium', + targetState: + '产品资料服务端页面可读取准 SKU 过程快照生成只读物料摘要;正式产品资料不得回写这些快照字段。' + }, + ], + [ + 'src/app/intake/seriesComponentDetails.ts', + { + category: 'intake-readonly-component-detail', + priority: 'medium', + targetState: + '系列组件详情可读取 BOM 过程预览用于展示组件/物料信息;正式成本和编码以结构化 SKU 与 BOM 结果为准。' + }, + ], + [ + 'src/app/products/[id]/assembly/page.tsx', + { + category: 'legacy-product-assembly-restore', + priority: 'low', + targetState: + '旧产品装配页可读取 sourcePreSku 过程快照恢复 3D 装配视图,不参与正式资料写入。' + }, + ], + [ + 'src/server/services/draft-group.mapper.ts', + { + category: 'draft-group-dto-process-output', + priority: 'medium', + targetState: + '草稿组 DTO 可输出规格款组合、BOM、安装步骤和包装输入等过程结果;正式基础资料仍必须读取 PreSkuBasicInfo。' + }, + ], + [ + 'src/server/services/draft-group.service.ts', + { + category: 'draft-group-process-source-resolution', + priority: 'medium', + targetState: + '草稿组服务可从 variantSelection 解析来源规格款 ID 和板材选项;型号、规格码、条码、品类和权限不得从过程快照反推。' + }, + ], + [ + 'src/server/services/intake.service.ts', + { + category: 'formalization-process-materialization', + priority: 'medium', + targetState: + '正式入库可读取 variantSelection/bomPreview/installStepPreview 生成板件、五金、成本和兼容追溯;正式编码、名称和条码必须来自 PreSkuBasicInfo / ProductSkuAssetProfile。' + }, + ], +]) + +const allowedProductSkuAttributeWriteKeys = new Set([ + 'draftGroupId', + 'preSkuId', + 'componentCodes', + 'bomPreview', + 'installStepPreview', + 'variantSelectionSummary', + 'assetSkuSnapshot', + 'packagingSummary', + '_cadImport', + '_sourceFileName', + '_modelMeta', + '_holesImported', + '_holesCount', + '_holesVersion', +]) + +const reservedProductSkuAttributeWriteKeys = new Set([ + 'barcode', + 'barCode', + 'model', + 'modelNumber', + 'specCode', + 'formalSkuCode', + 'skuCode', + 'productName', + 'skuName', + 'productCategory', + 'category', + 'categoryCode', + 'seriesName', + 'seriesCode', + 'familyCode', + 'assetFamilyCode', + 'variantCode', + 'versionNo', + 'color', + 'material', + 'size', +]) + +const structuredJsonPayloadReadAllowlist = new Map([ + [ + 'src/app/intake/EdgeBandingLibraryPanel.tsx', + new Set(['non-integrated-payload-direct-read']), + ], + [ + 'src/app/intake/spuRuleLibraryLogic.ts', + new Set(['non-integrated-payload-direct-read']), + ], + [ + 'src/lib/accessory-library.ts', + new Set(['non-integrated-payload-direct-read']), + ], + [ + 'src/lib/edge-banding-library.ts', + new Set(['non-integrated-payload-direct-read']), + ], + [ + 'src/lib/packaging-material-library.ts', + new Set(['non-integrated-payload-direct-read']), + ], + [ + 'src/lib/steel-frame-library.ts', + new Set(['non-integrated-payload-direct-read']), + ], + [ + 'src/server/services/intake-reference-library.service.ts', + new Set(['non-integrated-payload-direct-read']), + ], +]) + +function isStructuredJsonPayloadRead(relativePath, ruleId) { + return structuredJsonPayloadReadAllowlist.get(relativePath)?.has(ruleId) ?? false +} + +function classifyCompatibilitySnapshotRead(relativePath) { + return ( + compatibilitySnapshotReadClassifications.get(relativePath) ?? { + category: 'unclassified', + priority: 'unknown', + targetState: '', + } + ) +} + +function collectCompatibilitySnapshotReadFindings(scanFiles) { + const findings = [] + const skippedStructuredPayloadReads = [] + const seen = new Set() + + for (const absolutePath of scanFiles) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (isTestFile(relativePath)) continue + + const content = fs.readFileSync(absolutePath, 'utf8') + for (const rule of compatibilitySnapshotReadRules) { + rule.pattern.lastIndex = 0 + + for (const match of content.matchAll(rule.pattern)) { + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + if (isStructuredJsonPayloadRead(relativePath, rule.id)) { + skippedStructuredPayloadReads.push({ + ruleId: rule.id, + file: relativePath, + line, + text: lineText.trim(), + reason: + '结构化资料库 payload 扩展字段读点已由 IntakeReferenceLibraryItem 结构化身份和服务端契约兜底,不计入兼容快照主读风险。', + }) + continue + } + const classification = classifyCompatibilitySnapshotRead(relativePath) + const record = { + ruleId: rule.id, + severity: classification.category === 'unclassified' ? 'error' : 'inventory', + file: relativePath, + line, + text: lineText.trim(), + description: rule.description, + category: classification.category, + priority: classification.priority, + targetState: classification.targetState, + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}` + if (seen.has(recordKey)) continue + seen.add(recordKey) + findings.push(record) + } + } + } + + return { findings, skippedStructuredPayloadReads } +} + +function countBy(items, key, initialKeys = []) { + return items.reduce( + (result, item) => { + const value = item[key] ?? 'unknown' + result[value] = (result[value] ?? 0) + 1 + return result + }, + Object.fromEntries(initialKeys.map((initialKey) => [initialKey, 0])), + ) +} + +function summarizeCompatibilitySnapshotReads(findings) { + const byPriority = countBy( + findings, + 'priority', + COMPATIBILITY_SNAPSHOT_READ_PRIORITIES, + ) + const unclassifiedFindings = findings.filter( + (finding) => finding.category === 'unclassified', + ) + const blockingRiskFindings = findings.filter( + (finding) => finding.priority === 'high' || finding.category === 'unclassified', + ) + const highPriorityCount = byPriority.high ?? 0 + + return { + compatibilitySnapshotReadCount: findings.length, + compatibilitySnapshotReadByCategory: countBy(findings, 'category'), + compatibilitySnapshotReadByRuleId: countBy(findings, 'ruleId'), + compatibilitySnapshotReadByFile: countBy(findings, 'file'), + compatibilitySnapshotReadByPriority: byPriority, + compatibilitySnapshotReadBlockingRiskCount: + blockingRiskFindings.length, + compatibilitySnapshotReadBlockingRisks: blockingRiskFindings, + compatibilitySnapshotReadSummary: { + total: findings.length, + high: highPriorityCount, + medium: byPriority.medium ?? 0, + low: byPriority.low ?? 0, + unknown: byPriority.unknown ?? 0, + unclassified: unclassifiedFindings.length, + }, + unclassifiedCompatibilitySnapshotReadCount: + unclassifiedFindings.length, + unclassifiedCompatibilitySnapshotReads: unclassifiedFindings, + } +} + +function findUnusedCompatibilitySnapshotReadClassifications(findings, skippedReads) { + const activeFiles = new Set([ + ...findings.map((finding) => finding.file), + ...skippedReads.map((finding) => finding.file), + ]) + + return Array.from(compatibilitySnapshotReadClassifications.entries()) + .filter(([file]) => !activeFiles.has(file)) + .map(([file, classification]) => ({ + file, + category: classification.category, + priority: classification.priority, + targetState: classification.targetState, + })) +} + +function classifyProcessSnapshotRead(relativePath) { + return ( + processSnapshotReadClassifications.get(relativePath) ?? { + category: 'unclassified', + priority: 'unknown', + targetState: '', + } + ) +} + +const forbiddenProcessSnapshotIdentityFields = [ + 'barcode', + 'barCode', + 'skuCode', + 'formalSkuCode', + 'model', + 'modelNumber', + 'productName', + 'productCategory', + 'category', + 'categoryCode', + 'seriesName', + 'seriesCode', + 'ownerId', + 'ownerName', + 'businessType', + 'createdBy', + 'updatedBy', +] + +const processSnapshotFormalIdentityReadPatterns = [ + { + id: 'process-snapshot-formal-field-dot-read', + pattern: new RegExp( + `\\b(?:variantSelection|bomPreview|installStepPreview|packagingSnapshot|selection|primarySelection|item|slot|hardware)\\??\\.\\s*(${forbiddenProcessSnapshotIdentityFields.join('|')})\\b`, + 'g', + ), + }, + { + id: 'process-snapshot-formal-field-bracket-read', + pattern: new RegExp( + `\\b(?:variantSelection|bomPreview|installStepPreview|packagingSnapshot|selection|primarySelection|item|slot|hardware)\\s*\\[\\s*['"\`](${forbiddenProcessSnapshotIdentityFields.join('|')})['"\`]\\s*\\]`, + 'g', + ), + }, + { + id: 'process-snapshot-assembly-basis-formal-field-read', + pattern: new RegExp( + `\\breadBasisText\\s*\\([^)]*,\\s*['"\`](${forbiddenProcessSnapshotIdentityFields.join('|')})['"\`]`, + 'g', + ), + }, +] + +function collectProcessSnapshotReadFindings(scanFiles) { + const findings = [] + const seen = new Set() + const PROCESS_SNAPSHOT_CONTEXT_LINE_WINDOW = 15 + + for (const absolutePath of scanFiles) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (isTestFile(relativePath)) continue + + const content = fs.readFileSync(absolutePath, 'utf8') + let hasProcessSnapshotRead = false + const processSnapshotReadLines = [] + + for (const rule of processSnapshotReadRules) { + rule.pattern.lastIndex = 0 + + for (const match of content.matchAll(rule.pattern)) { + hasProcessSnapshotRead = true + const line = lineNumberAt(content, match.index) + processSnapshotReadLines.push(line) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const classification = classifyProcessSnapshotRead(relativePath) + const record = { + ruleId: rule.id, + severity: classification.category === 'unclassified' ? 'error' : 'inventory', + file: relativePath, + line, + text: lineText.trim(), + field: match[1], + description: rule.description, + category: classification.category, + priority: classification.priority, + targetState: classification.targetState, + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}` + if (seen.has(recordKey)) continue + seen.add(recordKey) + findings.push(record) + } + } + + if (!hasProcessSnapshotRead) continue + const isNearProcessSnapshotRead = (line) => + processSnapshotReadLines.some( + (readLine) => Math.abs(readLine - line) <= PROCESS_SNAPSHOT_CONTEXT_LINE_WINDOW, + ) + + for (const rule of processSnapshotFormalIdentityReadPatterns) { + rule.pattern.lastIndex = 0 + + for (const match of content.matchAll(rule.pattern)) { + const line = lineNumberAt(content, match.index) + if (!isNearProcessSnapshotRead(line)) continue + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const record = { + ruleId: rule.id, + severity: 'error', + file: relativePath, + line, + text: lineText.trim(), + field: match[1], + description: + '过程快照不能读取编码、条码、品类、人员或权限字段作为正式资料;这些字段必须来自结构化真相源。', + category: 'forbidden-process-snapshot-identity-read', + priority: 'high', + targetState: + 'variantSelection/bomPreview/installStepPreview/packagingSnapshot 只允许承载装配、BOM、包装和追溯过程,不允许反推正式主数据或权限。', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.field}:${record.text}` + if (seen.has(recordKey)) continue + seen.add(recordKey) + findings.push(record) + } + } + } + + return findings +} + +function summarizeProcessSnapshotReads(findings) { + const byPriority = countBy( + findings, + 'priority', + PROCESS_SNAPSHOT_READ_PRIORITIES, + ) + const unclassifiedFindings = findings.filter( + (finding) => finding.category === 'unclassified', + ) + const blockingRiskFindings = findings.filter( + (finding) => + finding.priority === 'high' || + finding.category === 'unclassified' || + finding.severity === 'error', + ) + + return { + processSnapshotReadCount: findings.length, + processSnapshotReadByCategory: countBy(findings, 'category'), + processSnapshotReadByRuleId: countBy(findings, 'ruleId'), + processSnapshotReadByFile: countBy(findings, 'file'), + processSnapshotReadByPriority: byPriority, + processSnapshotReadBlockingRiskCount: blockingRiskFindings.length, + processSnapshotReadBlockingRisks: blockingRiskFindings, + processSnapshotReadSummary: { + total: findings.length, + high: byPriority.high ?? 0, + medium: byPriority.medium ?? 0, + low: byPriority.low ?? 0, + unknown: byPriority.unknown ?? 0, + unclassified: unclassifiedFindings.length, + }, + unclassifiedProcessSnapshotReadCount: unclassifiedFindings.length, + unclassifiedProcessSnapshotReads: unclassifiedFindings, + } +} + +function findUnusedProcessSnapshotReadClassifications(findings) { + const activeFiles = new Set(findings.map((finding) => finding.file)) + + return Array.from(processSnapshotReadClassifications.entries()) + .filter(([file]) => !activeFiles.has(file)) + .map(([file, classification]) => ({ + file, + category: classification.category, + priority: classification.priority, + targetState: classification.targetState, + })) +} + +function findMatchingBrace(content, openBraceIndex) { + let depth = 0 + let quote = '' + let escaped = false + + for (let index = openBraceIndex; index < content.length; index += 1) { + const char = content[index] + + if (quote) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === quote) { + quote = '' + } + continue + } + + if (char === '"' || char === "'" || char === '`') { + quote = char + continue + } + if (char === '{') { + depth += 1 + continue + } + if (char === '}') { + depth -= 1 + if (depth === 0) return index + } + } + + return -1 +} + +function extractTopLevelObjectKeys(objectLiteral) { + const keys = [] + let depth = 0 + let quote = '' + let escaped = false + let current = '' + + const flushSegment = () => { + const segment = current.trim() + current = '' + if (!segment || segment.startsWith('...')) return + + const keyMatch = + segment.match(/^['"`]([^'"`]+)['"`]\s*:/) ?? + segment.match(/^([A-Za-z_$][\w$]*)\s*:/) + if (keyMatch?.[1]) { + keys.push(keyMatch[1]) + return + } + + const shorthandMatch = segment.match(/^([A-Za-z_$][\w$]*)$/) + if (shorthandMatch?.[1]) { + keys.push(shorthandMatch[1]) + } + } + + for (let index = 1; index < objectLiteral.length - 1; index += 1) { + const char = objectLiteral[index] + + if (quote) { + current += char + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === quote) { + quote = '' + } + continue + } + + if (char === '"' || char === "'" || char === '`') { + quote = char + current += char + continue + } + if (char === '{' || char === '[' || char === '(') { + depth += 1 + current += char + continue + } + if (char === '}' || char === ']' || char === ')') { + depth -= 1 + current += char + continue + } + if (char === ',' && depth === 0) { + flushSegment() + continue + } + + current += char + } + + flushSegment() + return keys +} + +function collectProductSkuAttributeWriteFindings(scanFiles) { + const findings = [] + const seen = new Set() + const attributesObjectPattern = /\battributes\s*:\s*\{/g + + for (const absolutePath of scanFiles) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (isTestFile(relativePath)) continue + if ( + !relativePath.startsWith('src/server/') && + !relativePath.startsWith('scripts/') && + !relativePath.startsWith('prisma/') + ) { + continue + } + + const content = fs.readFileSync(absolutePath, 'utf8') + attributesObjectPattern.lastIndex = 0 + + for (const match of content.matchAll(attributesObjectPattern)) { + const openBraceIndex = content.indexOf('{', match.index) + const closeBraceIndex = findMatchingBrace(content, openBraceIndex) + if (openBraceIndex < 0 || closeBraceIndex < 0) continue + + const objectLiteral = content.slice(openBraceIndex, closeBraceIndex + 1) + const keys = extractTopLevelObjectKeys(objectLiteral) + if (keys.length === 0) continue + + for (const key of keys) { + const isAllowed = allowedProductSkuAttributeWriteKeys.has(key) + const isReserved = reservedProductSkuAttributeWriteKeys.has(key) + if (isAllowed && !isReserved) continue + + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const record = { + ruleId: isReserved + ? 'product-sku-attributes-reserved-key-write' + : 'product-sku-attributes-unregistered-key-write', + severity: isReserved ? 'error' : 'warning', + file: relativePath, + line, + text: lineText.trim(), + key, + priority: isReserved ? 'high' : 'medium', + description: + 'ProductSKU.attributes 只能写已登记的兼容快照键;编码、条码、型号、规格、品类、颜色、材质、尺寸等正式资料必须写结构化字段或 ProductSkuAssetProfile。', + targetState: + '允许的兼容键需要集中登记;新增正式资料字段不得回写 ProductSKU.attributes,避免 JSON 成为第二真相。', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.key}` + if (seen.has(recordKey)) continue + seen.add(recordKey) + findings.push(record) + } + } + } + + return findings +} + +function summarizeProductSkuAttributeWriteFindings(findings) { + const blockingRisks = findings.filter((finding) => finding.severity === 'error') + + return { + productSkuAttributeWriteRiskCount: findings.length, + productSkuAttributeWriteRiskByRuleId: countBy(findings, 'ruleId'), + productSkuAttributeWriteRiskByFile: countBy(findings, 'file'), + productSkuAttributeWriteRiskByPriority: countBy( + findings, + 'priority', + PRODUCT_SKU_ATTRIBUTE_WRITE_PRIORITIES, + ), + productSkuAttributeWriteBlockingRiskCount: blockingRisks.length, + productSkuAttributeWriteBlockingRisks: blockingRisks, + productSkuAttributeWriteRisks: findings, + } +} + +const referenceEstimatorCostSourceRules = [ + { + id: 'reference-estimator-cost-source-symbol', + pattern: + /\b(?:REFERENCE_HARDWARE_ITEMS|DEFAULT_PACKAGING_PRICES|EDGE_BANDING_UNIT_PRICE|BOARD_PRICE_AREA_SQUARE_METER|REFERENCE_ESTIMATOR_PACKAGING_PRICES|REFERENCE_ESTIMATOR_EDGE_BANDING_UNIT_PRICE|REFERENCE_ESTIMATOR_BOARD_PRICE_AREA_SQUARE_METER)\b/g, + description: + '参考图预估成本内置单价只能服务图片估算展示,不能被正式产品、BOM、包装方案、轻仓库或外部同步链路复用。', + }, + { + id: 'reference-estimator-cost-catalog-import', + pattern: + /\bfrom\s+['"][^'"]*reference-image-cost-catalog['"]|\brequire\(\s*['"][^'"]*reference-image-cost-catalog['"]\s*\)/g, + description: + 'reference-image-cost-catalog 是参考图预估成本专用样本目录,不是正式五金或成本主数据源。', + }, +] + +function collectReferenceEstimatorCostBoundaryFindings(scanFiles) { + const findings = [] + const seen = new Set() + + for (const absolutePath of scanFiles) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (isTestFile(relativePath)) continue + + const content = fs.readFileSync(absolutePath, 'utf8') + const approvalReason = approvedReferenceEstimatorCostSourcePaths.get(relativePath) + + for (const rule of referenceEstimatorCostSourceRules) { + rule.pattern.lastIndex = 0 + + for (const match of content.matchAll(rule.pattern)) { + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + if ( + rule.id === 'reference-estimator-cost-source-symbol' && + /from\s+['"][^'"]*reference-image-cost-catalog['"]/.test(lineText) + ) { + continue + } + const record = { + ruleId: rule.id, + severity: approvalReason ? 'approved-reference-estimator-cost' : 'error', + file: relativePath, + line, + text: lineText.trim(), + description: rule.description, + reason: + approvalReason ?? + '未批准使用参考图预估成本源;正式成本必须来自结构化物料/五金/包装资料库或明确的后端算法。', + priority: approvalReason ? 'low' : 'high', + targetState: + '参考图预估成本只保留为人工判断辅助;正式产品成本、BOM 成本、包装成本和外部只读视图不得读取这些页面估算常量或样本目录。', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}` + if (seen.has(recordKey)) continue + seen.add(recordKey) + findings.push(record) + } + } + } + + return findings +} + +function summarizeReferenceEstimatorCostBoundaryFindings(findings) { + const blockingRisks = findings.filter((finding) => finding.severity === 'error') + + return { + referenceEstimatorCostBoundaryFindingCount: findings.length, + referenceEstimatorCostBoundaryByRuleId: countBy(findings, 'ruleId'), + referenceEstimatorCostBoundaryByFile: countBy(findings, 'file'), + approvedReferenceEstimatorCostBoundaryFindings: findings.filter( + (finding) => finding.severity === 'approved-reference-estimator-cost', + ), + unclassifiedReferenceEstimatorCostBoundaryFindingCount: blockingRisks.length, + unclassifiedReferenceEstimatorCostBoundaryFindings: blockingRisks, + } +} + +const fileSystemMasterDataReadPattern = + /\b(?:fs\.)?(?:promises\.)?(?:readdir|readFile|readFileSync|createReadStream)\s*\(/g +const fileSystemMasterDataPathHintPattern = + /public[\\/]+uploads|uploads[\\/]+models|hardware\.json|tempGlbPath|tempFilePath|uploadedFile|modelFile|zipPath|extractDir|cacheDirectory/g +const serverMasterDataWriteTokenPattern = + /\b(?:prisma|this\.prisma|client|tx)\.(product|productSKU|panel|hole|productHardware|hardware)\.(create|createMany|upsert|update|updateMany|delete|deleteMany)\b/gi + +function collectFileSystemMasterDataSourceFindings(scanFiles) { + const findings = [] + const seen = new Set() + + for (const absolutePath of scanFiles) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (isTestFile(relativePath)) continue + if (!relativePath.startsWith('src/server/') && !relativePath.startsWith('scripts/')) { + continue + } + + const content = fs.readFileSync(absolutePath, 'utf8') + if (!fileSystemMasterDataReadPattern.test(content)) { + fileSystemMasterDataReadPattern.lastIndex = 0 + continue + } + fileSystemMasterDataReadPattern.lastIndex = 0 + if (!fileSystemMasterDataPathHintPattern.test(content)) { + fileSystemMasterDataPathHintPattern.lastIndex = 0 + continue + } + fileSystemMasterDataPathHintPattern.lastIndex = 0 + + const writeMatches = Array.from(content.matchAll(serverMasterDataWriteTokenPattern)) + serverMasterDataWriteTokenPattern.lastIndex = 0 + if (writeMatches.length === 0) continue + + const approvalReason = approvedFileSystemMasterDataSourcePaths.get(relativePath) + for (const match of writeMatches) { + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const record = { + ruleId: 'filesystem-master-data-source-write', + severity: approvalReason ? 'approved-filesystem-master-data-source' : 'error', + file: relativePath, + line, + text: lineText.trim(), + model: match[1], + action: match[2], + reason: + approvalReason ?? + '未登记的文件系统读取 + 主数据写入组合;禁止把 public/uploads、临时文件或本地目录扫描结果反向当作主数据真相。', + priority: approvalReason ? 'low' : 'high', + targetState: + '文件上传可作为明确入口的导入请求来源,但不能通过扫描 public/uploads 或任意本地文件目录反向生成 Product/SKU/BOM 主数据。', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.model}:${record.action}` + if (seen.has(recordKey)) continue + seen.add(recordKey) + findings.push(record) + } + } + + return findings +} + +function summarizeFileSystemMasterDataSourceFindings(findings) { + const blockingRisks = findings.filter((finding) => finding.severity === 'error') + + return { + fileSystemMasterDataSourceFindingCount: findings.length, + fileSystemMasterDataSourceByFile: countBy(findings, 'file'), + approvedFileSystemMasterDataSources: findings.filter( + (finding) => finding.severity === 'approved-filesystem-master-data-source', + ), + unclassifiedFileSystemMasterDataSourceCount: blockingRisks.length, + unclassifiedFileSystemMasterDataSources: blockingRisks, + } +} + +const files = SCAN_DIRS.flatMap((scanDir) => listSourceFiles(path.join(ROOT_DIR, scanDir))) +const scriptFiles = SCRIPT_SCAN_DIRS.flatMap((scanDir) => listSourceFiles(path.join(ROOT_DIR, scanDir))) +const findings = [] +const approvedCompatibility = [] +const advisoryFindings = [] +const unclassifiedStorageFindings = [] +const scriptMasterDataWriteFindings = [] +const unclassifiedScriptMasterDataWriteFindings = [] +const serverMasterDataWriteFindings = [] +const unclassifiedServerMasterDataWriteFindings = [] +const rawSqlMasterDataWriteFindings = [] +const unclassifiedRawSqlMasterDataWriteFindings = [] +const rawSqlMasterDataReadFindings = [] +const unclassifiedRawSqlMasterDataReadFindings = [] +const runtimeDatabaseDdlFindings = [] +const unclassifiedRuntimeDatabaseDdlFindings = [] +const scriptSafetyGuardFindings = [] +const missingScriptSafetyGuardFindings = [] +const seenRecords = new Set() +const schemaJsonFieldInventory = getSchemaJsonFieldInventory() +const unclassifiedSchemaJsonFields = schemaJsonFieldInventory.filter( + (field) => field.category === 'unclassified', +) +const staleSchemaJsonFieldClassifications = + findStaleSchemaJsonFieldClassifications(schemaJsonFieldInventory) +const schemaJsonFieldBoundaryIssues = + findSchemaJsonFieldBoundaryIssues() +const approvedClientStorageEvidenceIssues = + findApprovedClientStorageEvidenceIssues() +const staleFileClassifications = findStaleFileClassifications([ + { + name: 'advisoryStoragePaths', + files: advisoryStoragePaths.keys(), + reason: '客户端本地存储分类表不能残留已删除或改名的文件。', + }, + { + name: 'approvedScriptMasterDataWritePaths', + files: approvedScriptMasterDataWritePaths.keys(), + reason: '脚本层主数据直写批准表不能残留已删除或改名的文件。', + }, + { + name: 'approvedScriptSafetyGuardRequirements', + files: approvedScriptSafetyGuardRequirements.keys(), + reason: '脚本安全门禁清单不能残留已删除或改名的文件。', + }, + { + name: 'approvedRawSqlMasterDataWritePaths', + files: approvedRawSqlMasterDataWritePaths.keys(), + reason: '原生 SQL 主数据写入批准表不能残留已删除或改名的文件。', + }, + { + name: 'approvedRawSqlMasterDataWriteTableScopes', + files: approvedRawSqlMasterDataWriteTableScopes.keys(), + reason: '原生 SQL 主数据写入表作用域不能残留已删除或改名的文件。', + }, + { + name: 'approvedRawSqlMasterDataReadPaths', + files: approvedRawSqlMasterDataReadPaths.keys(), + reason: '原生 SQL 主数据读取批准表不能残留已删除或改名的文件。', + }, + { + name: 'approvedRawSqlMasterDataReadTableScopes', + files: approvedRawSqlMasterDataReadTableScopes.keys(), + reason: '原生 SQL 主数据读取表作用域不能残留已删除或改名的文件。', + }, + { + name: 'approvedRuntimeDatabaseDdlPaths', + files: approvedRuntimeDatabaseDdlPaths.keys(), + reason: '运行时数据库 DDL 批准表不能残留已删除或改名的文件。', + }, + { + name: 'approvedReferenceEstimatorCostSourcePaths', + files: approvedReferenceEstimatorCostSourcePaths.keys(), + reason: '参考图预估成本源批准表不能残留已删除或改名的文件。', + }, + { + name: 'approvedFileSystemMasterDataSourcePaths', + files: approvedFileSystemMasterDataSourcePaths.keys(), + reason: '文件系统导入主数据批准表不能残留已删除或改名的文件。', + }, + { + name: 'approvedClientStorageUsages', + files: getApprovedClientStorageFiles().values(), + reason: '客户端本地存储批准用途不能残留已删除或改名的文件。', + }, + { + name: 'approvedServerMasterDataWritePaths', + files: approvedServerMasterDataWritePaths.keys(), + reason: '服务/仓储主数据写入口批准表不能残留已删除或改名的文件。', + }, + { + name: 'approvedServerMasterDataWriteModelScopes', + files: approvedServerMasterDataWriteModelScopes.keys(), + reason: '服务/仓储主数据写入模型作用域表不能残留已删除或改名的文件。', + }, + { + name: 'compatibilitySnapshotReadClassifications', + files: compatibilitySnapshotReadClassifications.keys(), + reason: '兼容快照读点分类表不能残留已删除或改名的文件。', + }, + { + name: 'processSnapshotReadClassifications', + files: processSnapshotReadClassifications.keys(), + reason: '过程快照读点分类表不能残留已删除或改名的文件。', + }, + { + name: 'structuredJsonPayloadReadAllowlist', + files: structuredJsonPayloadReadAllowlist.keys(), + reason: '结构化 payload 读点跳过表不能残留已删除或改名的文件。', + }, +]) + +for (const absolutePath of files) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + const content = fs.readFileSync(absolutePath, 'utf8') + + for (const rule of rules) { + rule.pattern.lastIndex = 0 + + for (const match of content.matchAll(rule.pattern)) { + if (rule.skip?.({ relativePath, match })) continue + + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const record = { + ruleId: rule.id, + severity: rule.severity, + file: relativePath, + line, + text: lineText.trim(), + description: rule.description, + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}` + if (seenRecords.has(recordKey)) continue + seenRecords.add(recordKey) + + if (rule.isAllowed({ relativePath, lineText, match })) { + approvedCompatibility.push({ ...record, severity: 'approved-compatibility' }) + } else { + findings.push(record) + } + } + } + + for (const rule of advisoryRules) { + rule.pattern.lastIndex = 0 + + for (const match of content.matchAll(rule.pattern)) { + const candidate = rule.resolveCandidate({ relativePath, content, match }) + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + if (!candidate) { + if (!isTestFile(relativePath)) { + const record = { + ruleId: 'client-storage-unclassified', + severity: 'error', + file: relativePath, + line, + text: lineText.trim(), + description: + '非测试代码里的本地存储使用必须被分类:主数据风险、兼容缓存、旧缓存,或明确的非主数据 UI 缓存。', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}` + if (!seenRecords.has(recordKey)) { + seenRecords.add(recordKey) + unclassifiedStorageFindings.push(record) + } + } + continue + } + + const record = { + ruleId: rule.id, + severity: rule.severity, + category: candidate.category, + file: relativePath, + line, + text: lineText.trim(), + description: rule.description, + targetState: candidate.targetState, + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}` + if (seenRecords.has(recordKey)) continue + seenRecords.add(recordKey) + advisoryFindings.push(record) + } + } +} + +const scriptMasterDataWriteModels = + 'product|productSKU|productSkuAssetProfile|draftGroup|preSku|preSkuBasicInfo|preSkuPackagingPlan|assetFamily|assetFamilySeriesCompatibility|assetVariant|assetSeries|material|materialCategory|hardware|hardwareCategory|hardwareMapping|commonComponent|commonComponentCategory|substrate|substrateCategory|substrateEnvironmentGrade|substrateFinish|substrateSurfaceOption|substrateSystemNameOption|substrateThicknessSpec|productCategory|warehouseLocation|packagedItem|packagingPlan|panel|panelMaterial|hole|productHardware|assemblyStep|assemblyVersion|assetVariantLocationBinding|brandMaterialBinding|preSkuLocationBinding|intakeNonIntegratedProduct|intakeNonIntegratedProductProfile|intakeNonIntegratedSkuItem|productPatentRecord|spuNumberCounter|intakeReferenceLibraryItem|user|department|employeeDirectory|employeeLookupCache|team|productCollaborator|shareLink|systemConfig|providerConfig|themeConfig|intakeNodePermissionOverride|assistedAssemblyAiRender|feedback|activityLog|approvalWorkflow|assetSkuSyncEvent' +const serverMasterDataWriteModels = + `${scriptMasterDataWriteModels}|approvalWorkflowFieldChange|planningTemplate|planningWorkspace|planningDraft` +const masterDataWriteActions = 'create|createMany|update|updateMany|upsert|delete|deleteMany' +const rawSqlMasterDataTableNames = new Set([ + 'ActivityLog', + 'ApprovalWorkflow', + 'ApprovalWorkflowFieldChange', + 'AssetFamily', + 'AssetFamilySeriesCompatibility', + 'AssetSeries', + 'AssetSkuSyncEvent', + 'AssetVariant', + 'AssistedAssemblyAiRender', + 'BrandMaterialBinding', + 'CommonComponent', + 'CommonComponentCategory', + 'Department', + 'DraftGroup', + 'EmployeeDirectory', + 'EmployeeLookupCache', + 'Feedback', + 'Hardware', + 'HardwareCategory', + 'HardwareMapping', + 'Hole', + 'IntakeNodePermissionOverride', + 'IntakeNonIntegratedProduct', + 'IntakeNonIntegratedProductProfile', + 'IntakeNonIntegratedSkuItem', + 'IntakeReferenceLibraryItem', + 'Material', + 'MaterialCategory', + 'PackagedItem', + 'PackagingPlan', + 'Panel', + 'PanelMaterial', + 'PlanningDraft', + 'PlanningTemplate', + 'PlanningWorkspace', + 'PreSku', + 'PreSkuBasicInfo', + 'PreSkuPackagingPlan', + 'Product', + 'ProductCategory', + 'ProductCollaborator', + 'ProductHardware', + 'ProductPatentRecord', + 'ProductSKU', + 'ProductSkuAssetProfile', + 'ProviderConfig', + 'ShareLink', + 'SpuNumberCounter', + 'Substrate', + 'SubstrateCategory', + 'SubstrateEnvironmentGrade', + 'SubstrateFinish', + 'SubstrateSurfaceOption', + 'SubstrateSystemNameOption', + 'SubstrateThicknessSpec', + 'SystemConfig', + 'Team', + 'ThemeConfig', + 'User', + 'WarehouseLocation', +]) + +const scriptMasterDataDirectWritePattern = new RegExp( + `\\b(?:prisma|tx)\\.(${scriptMasterDataWriteModels})\\.(${masterDataWriteActions})\\s*\\(`, + 'g', +) +const rawSqlRelationRef = String.raw`(?:"?[A-Za-z_][\w]*"?\s*\.\s*)?"?([A-Z][A-Za-z0-9_]*)"?` +const rawSqlMasterDataWritePattern = new RegExp( + String.raw`\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+${rawSqlRelationRef}`, + 'g', +) +const rawSqlMasterDataReadPattern = new RegExp( + String.raw`\b(FROM|JOIN)\s+${rawSqlRelationRef}`, + 'g', +) +const runtimeDatabaseDdlPattern = + /\b(CREATE\s+(?:TABLE|INDEX|UNIQUE\s+INDEX|OR\s+REPLACE\s+FUNCTION)|ALTER\s+TABLE|DROP\s+(?:TABLE|INDEX|FUNCTION)|TRUNCATE)\b/gi +const unsafeRawSqlPattern = /\.\$executeRawUnsafe\s*\(/g + +function approvedTableScopeIncludes(approvedTables, tableName) { + return approvedTables === '*' || Boolean(approvedTables?.has(tableName)) +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function collectPrismaClientAliases(content) { + const aliases = new Set(['prisma', 'tx', 'transaction', 'this.prisma']) + const aliasPattern = + /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:tx|transaction|prisma|this\.prisma)\s*(?:\?\?\s*(?:tx|transaction|prisma|this\.prisma))?/g + + for (const match of content.matchAll(aliasPattern)) { + aliases.add(match[1]) + } + + return aliases +} + +function buildServerMasterDataDirectWritePattern(content) { + const clientPattern = Array.from(collectPrismaClientAliases(content)) + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join('|') + + return new RegExp( + `\\b(?:${clientPattern})\\.(${serverMasterDataWriteModels})\\.(${masterDataWriteActions})\\s*\\(`, + 'g', + ) +} + +const nestedRelationWriteModels = new Map([ + ['basicInfo', 'preSkuBasicInfo'], + ['profile', 'intakeNonIntegratedProductProfile'], + ['skuItems', 'intakeNonIntegratedSkuItem'], + ['assetProfile', 'productSkuAssetProfile'], +]) + +const nestedRelationWritePattern = new RegExp( + `\\b(${Array.from(nestedRelationWriteModels.keys()).join('|')})\\s*:\\s*\\{[\\s\\S]{0,500}?\\b(${masterDataWriteActions})\\s*:`, + 'g', +) + +for (const absolutePath of scriptFiles) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + const content = fs.readFileSync(absolutePath, 'utf8') + const approvalReason = approvedScriptMasterDataWritePaths.get(relativePath) + + scriptMasterDataDirectWritePattern.lastIndex = 0 + for (const match of content.matchAll(scriptMasterDataDirectWritePattern)) { + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const record = { + ruleId: 'script-core-master-data-direct-write', + severity: approvalReason ? 'approved-script' : 'error', + file: relativePath, + line, + text: lineText.trim(), + description: + '脚本直接写核心主数据表必须被明确分类;生产修复和正式清洗必须走专用服务、迁移或显式清洗计划。', + reason: approvalReason ?? '', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}` + if (seenRecords.has(recordKey)) continue + seenRecords.add(recordKey) + scriptMasterDataWriteFindings.push(record) + if (!approvalReason) { + unclassifiedScriptMasterDataWriteFindings.push(record) + } + } +} + +for (const [relativePath, requirement] of approvedScriptSafetyGuardRequirements.entries()) { + const absolutePath = path.join(ROOT_DIR, relativePath) + if (!fs.existsSync(absolutePath)) continue + const content = fs.readFileSync(absolutePath, 'utf8').replace(/\r\n/g, '\n') + const missingTexts = requirement.requiredTexts.filter((text) => !content.includes(text)) + const record = { + ruleId: 'approved-script-safety-guard', + severity: missingTexts.length === 0 ? 'approved-script-guard' : 'error', + file: relativePath, + line: 1, + text: missingTexts.length === 0 ? 'required guard tokens present' : `missing: ${missingTexts.join(', ')}`, + description: + '已批准的脚本层主数据写入/同步入口必须保留显式门禁,不能只靠分类说明防误写。', + reason: requirement.reason, + missingTexts, + } + scriptSafetyGuardFindings.push(record) + if (missingTexts.length > 0) { + missingScriptSafetyGuardFindings.push(record) + findings.push(record) + } +} + +for (const absolutePath of [...scriptFiles, ...files]) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (isTestFile(relativePath)) continue + + const content = fs.readFileSync(absolutePath, 'utf8') + const approvalReason = approvedRawSqlMasterDataWritePaths.get(relativePath) + const approvedTables = approvedRawSqlMasterDataWriteTableScopes.get(relativePath) + + rawSqlMasterDataWritePattern.lastIndex = 0 + for (const match of content.matchAll(rawSqlMasterDataWritePattern)) { + const tableName = match[2] + if (!rawSqlMasterDataTableNames.has(tableName)) continue + const isApproved = Boolean(approvalReason) && approvedTableScopeIncludes(approvedTables, tableName) + + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const record = { + ruleId: 'raw-sql-master-data-write', + severity: isApproved ? 'approved-raw-sql' : 'error', + file: relativePath, + line, + text: lineText.trim(), + tableName, + operation: match[1].replace(/\s+/g, ' ').toUpperCase(), + description: + '原生 SQL 写核心主数据表必须被明确分类;需要说明事务语义、并发/锁策略或历史清洗计划,不能绕过 Prisma 写入口巡检。', + reason: approvalReason + ? approvedTableScopeIncludes(approvedTables, tableName) + ? approvalReason + : `该文件已分类,但未批准原生 SQL 写入 ${tableName};需要确认是否新增旁路或扩展允许表范围。` + : '', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.tableName}:${record.operation}` + if (seenRecords.has(recordKey)) continue + seenRecords.add(recordKey) + rawSqlMasterDataWriteFindings.push(record) + if (!isApproved) { + unclassifiedRawSqlMasterDataWriteFindings.push(record) + } + } +} + +for (const absolutePath of [...scriptFiles, ...files]) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (isTestFile(relativePath)) continue + + const content = fs.readFileSync(absolutePath, 'utf8') + const approvalReason = approvedRawSqlMasterDataReadPaths.get(relativePath) + const approvedTables = approvedRawSqlMasterDataReadTableScopes.get(relativePath) + + rawSqlMasterDataReadPattern.lastIndex = 0 + for (const match of content.matchAll(rawSqlMasterDataReadPattern)) { + const tableName = match[2] + if (!rawSqlMasterDataTableNames.has(tableName)) continue + const isApproved = Boolean(approvalReason) && approvedTableScopeIncludes(approvedTables, tableName) + + const line = lineNumberAt(content, match.index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const record = { + ruleId: 'raw-sql-master-data-read', + severity: isApproved ? 'approved-raw-sql-read' : 'error', + file: relativePath, + line, + text: lineText.trim(), + tableName, + operation: match[1].toUpperCase(), + description: + '原生 SQL 读取核心主数据表必须被明确分类;业务服务默认应通过 Prisma delegate、Repository 或结构化 helper 读取,避免绕过统一真相源边界。', + reason: approvalReason + ? approvedTableScopeIncludes(approvedTables, tableName) + ? approvalReason + : `该文件已分类,但未批准原生 SQL 读取 ${tableName};需要确认是否新增旁路或扩展允许表范围。` + : '', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.tableName}:${record.operation}` + if (seenRecords.has(recordKey)) continue + seenRecords.add(recordKey) + rawSqlMasterDataReadFindings.push(record) + if (!isApproved) { + unclassifiedRawSqlMasterDataReadFindings.push(record) + } + } +} + +for (const absolutePath of [...scriptFiles, ...files]) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (isTestFile(relativePath)) continue + if (relativePath === 'scripts/verify-master-data-code-boundaries.js') continue + + const content = fs.readFileSync(absolutePath, 'utf8') + if (!/\$executeRaw(?:Unsafe)?|(?:\w+\.)?query\s*\(/.test(content)) continue + const approvalReason = approvedRuntimeDatabaseDdlPaths.get(relativePath) + + const addRuntimeDatabaseDdlFinding = ({ index, ruleId, operation, description }) => { + const line = lineNumberAt(content, index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const record = { + ruleId, + severity: approvalReason ? 'approved-runtime-ddl' : 'error', + file: relativePath, + line, + text: lineText.trim(), + operation, + description, + reason: approvalReason ?? '', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.operation}:${record.text}` + if (seenRecords.has(recordKey)) return + seenRecords.add(recordKey) + runtimeDatabaseDdlFindings.push(record) + if (!approvalReason) { + unclassifiedRuntimeDatabaseDdlFindings.push(record) + } + } + + runtimeDatabaseDdlPattern.lastIndex = 0 + for (const match of content.matchAll(runtimeDatabaseDdlPattern)) { + addRuntimeDatabaseDdlFinding({ + index: match.index, + ruleId: 'runtime-database-ddl', + operation: match[1].replace(/\s+/g, ' ').toUpperCase(), + description: + '应用代码和普通脚本不能在运行时补建表、索引、约束或函数;数据库结构真相必须来自 Prisma migration、治理迁移清单和 strict 巡检。', + }) + } + + unsafeRawSqlPattern.lastIndex = 0 + for (const match of content.matchAll(unsafeRawSqlPattern)) { + addRuntimeDatabaseDdlFinding({ + index: match.index, + ruleId: 'unsafe-raw-sql-runtime-entry', + operation: '$executeRawUnsafe', + description: + '$executeRawUnsafe 会绕开参数化和写入口语义巡检;应用代码必须使用 Prisma delegate、$queryRaw/$executeRaw tagged template,或显式迁移/清洗脚本。', + }) + } +} + +for (const absolutePath of files) { + const relativePath = toPosixPath(path.relative(ROOT_DIR, absolutePath)) + if (!relativePath.startsWith('src/server/') || isTestFile(relativePath)) continue + + const content = fs.readFileSync(absolutePath, 'utf8') + const approvalReason = approvedServerMasterDataWritePaths.get(relativePath) + const approvedModels = approvedServerMasterDataWriteModelScopes.get(relativePath) + + const addServerMasterDataWriteFinding = ({ + index, + model, + action, + ruleId = 'server-core-master-data-direct-write', + description = + '服务/仓储层直接写核心主数据表必须被明确分类;新增写入口需要说明职责,避免绕过统一业务校验、结构化副本同步和审计边界。', + parentModel = null, + nestedRelation = null, + }) => { + const line = lineNumberAt(content, index) + const lineText = content.split(/\r?\n/)[line - 1] ?? '' + const isApproved = + Boolean(approvalReason) && Boolean(approvedModels?.has(model)) + const record = { + ruleId, + severity: isApproved ? 'approved-server-write' : 'error', + file: relativePath, + line, + text: lineText.trim(), + model, + action, + ...(parentModel && { parentModel }), + ...(nestedRelation && { nestedRelation }), + description, + reason: approvalReason + ? approvedModels?.has(model) + ? approvalReason + : `该文件已分类,但未批准写入 ${model};需要确认是否新增旁路或扩展允许模型。` + : '', + } + const recordKey = `${record.ruleId}:${record.file}:${record.line}:${record.text}:${record.model}:${record.action}:${record.nestedRelation ?? ''}` + if (seenRecords.has(recordKey)) return + seenRecords.add(recordKey) + serverMasterDataWriteFindings.push(record) + if (!isApproved) { + unclassifiedServerMasterDataWriteFindings.push(record) + } + } + + const serverMasterDataDirectWritePattern = buildServerMasterDataDirectWritePattern(content) + serverMasterDataDirectWritePattern.lastIndex = 0 + for (const match of content.matchAll(serverMasterDataDirectWritePattern)) { + addServerMasterDataWriteFinding({ + index: match.index, + model: match[1], + action: match[2], + }) + } + + nestedRelationWritePattern.lastIndex = 0 + for (const match of content.matchAll(nestedRelationWritePattern)) { + addServerMasterDataWriteFinding({ + index: match.index, + model: nestedRelationWriteModels.get(match[1]), + action: match[2], + ruleId: 'server-core-master-data-nested-write', + nestedRelation: match[1], + description: + '服务/仓储层的 Prisma relation 嵌套写入也必须被明确分类;不能只批准父模型写入而绕过子模型结构化校验边界。', + }) + } +} + +const compatibilitySnapshotReadInventory = collectCompatibilitySnapshotReadFindings([ + ...files, + ...scriptFiles, +]) +const compatibilitySnapshotReadFindings = compatibilitySnapshotReadInventory.findings +const skippedStructuredPayloadReads = + compatibilitySnapshotReadInventory.skippedStructuredPayloadReads +const compatibilitySnapshotReadSummary = + summarizeCompatibilitySnapshotReads(compatibilitySnapshotReadFindings) +const unclassifiedCompatibilitySnapshotReadFindings = + compatibilitySnapshotReadSummary.unclassifiedCompatibilitySnapshotReads +const unusedCompatibilitySnapshotReadClassifications = + findUnusedCompatibilitySnapshotReadClassifications( + compatibilitySnapshotReadFindings, + skippedStructuredPayloadReads, + ) +const productSkuAttributeWriteFindings = collectProductSkuAttributeWriteFindings([ + ...files, + ...scriptFiles, +]) +const productSkuAttributeWriteSummary = + summarizeProductSkuAttributeWriteFindings(productSkuAttributeWriteFindings) +const referenceEstimatorCostBoundaryFindings = + collectReferenceEstimatorCostBoundaryFindings(files) +const referenceEstimatorCostBoundarySummary = + summarizeReferenceEstimatorCostBoundaryFindings( + referenceEstimatorCostBoundaryFindings, + ) +const fileSystemMasterDataSourceFindings = + collectFileSystemMasterDataSourceFindings(files) +const fileSystemMasterDataSourceSummary = + summarizeFileSystemMasterDataSourceFindings( + fileSystemMasterDataSourceFindings, + ) +const processSnapshotReadFindings = collectProcessSnapshotReadFindings([ + ...files, + ...scriptFiles, +]) +const processSnapshotReadSummary = + summarizeProcessSnapshotReads(processSnapshotReadFindings) +const unclassifiedProcessSnapshotReadFindings = + processSnapshotReadSummary.unclassifiedProcessSnapshotReads +const unusedProcessSnapshotReadClassifications = + findUnusedProcessSnapshotReadClassifications(processSnapshotReadFindings) +const snapshotReadBudgetReport = buildSnapshotReadBudgetReport( + compatibilitySnapshotReadSummary, + processSnapshotReadSummary, +) + +if (args.mode === 'compatibility-snapshot-read') { + emitReport({ + mode: 'compatibility-snapshot-read', + checkedFiles: files.length, + checkedScriptFiles: scriptFiles.length, + ...compatibilitySnapshotReadSummary, + skippedStructuredPayloadReadCount: skippedStructuredPayloadReads.length, + skippedStructuredPayloadReads, + unusedCompatibilitySnapshotReadClassificationCount: + unusedCompatibilitySnapshotReadClassifications.length, + unusedCompatibilitySnapshotReadClassifications, + compatibilitySnapshotReadDebtBudget: + SNAPSHOT_READ_DEBT_BUDGETS.compatibilitySnapshotRead, + compatibilitySnapshotReadBudgetViolationCount: + snapshotReadBudgetReport.compatibilitySnapshotReadBudgetViolationCount, + compatibilitySnapshotReadBudgetViolations: + snapshotReadBudgetReport.compatibilitySnapshotReadBudgetViolations, + compatibilitySnapshotReadFindings, + }) + process.exit( + compatibilitySnapshotReadSummary.compatibilitySnapshotReadBlockingRiskCount > 0 || + unusedCompatibilitySnapshotReadClassifications.length > 0 || + snapshotReadBudgetReport.compatibilitySnapshotReadBudgetViolationCount > 0 + ? 1 + : 0, + ) +} + +if (args.mode === 'server-master-data-write') { + emitReport({ + mode: 'server-master-data-write', + checkedFiles: files.length, + serverMasterDataDirectWriteCount: serverMasterDataWriteFindings.length, + approvedServerMasterDataDirectWrites: serverMasterDataWriteFindings.filter( + (finding) => finding.severity === 'approved-server-write', + ), + unclassifiedServerMasterDataDirectWriteCount: + unclassifiedServerMasterDataWriteFindings.length, + unclassifiedServerMasterDataDirectWrites: + unclassifiedServerMasterDataWriteFindings, + }) + process.exit(unclassifiedServerMasterDataWriteFindings.length > 0 ? 1 : 0) +} + +if (args.mode === 'process-snapshot-read') { + emitReport({ + mode: 'process-snapshot-read', + checkedFiles: files.length, + checkedScriptFiles: scriptFiles.length, + ...processSnapshotReadSummary, + unusedProcessSnapshotReadClassificationCount: + unusedProcessSnapshotReadClassifications.length, + unusedProcessSnapshotReadClassifications, + processSnapshotReadDebtBudget: + SNAPSHOT_READ_DEBT_BUDGETS.processSnapshotRead, + processSnapshotReadBudgetViolationCount: + snapshotReadBudgetReport.processSnapshotReadBudgetViolationCount, + processSnapshotReadBudgetViolations: + snapshotReadBudgetReport.processSnapshotReadBudgetViolations, + processSnapshotReadFindings, + }) + process.exit( + processSnapshotReadSummary.processSnapshotReadBlockingRiskCount > 0 || + unusedProcessSnapshotReadClassifications.length > 0 || + snapshotReadBudgetReport.processSnapshotReadBudgetViolationCount > 0 + ? 1 + : 0, + ) +} + +if (args.mode === 'product-sku-attributes-write') { + emitReport({ + mode: 'product-sku-attributes-write', + checkedFiles: files.length, + checkedScriptFiles: scriptFiles.length, + allowedProductSkuAttributeWriteKeys: Array.from(allowedProductSkuAttributeWriteKeys), + reservedProductSkuAttributeWriteKeys: Array.from(reservedProductSkuAttributeWriteKeys), + ...productSkuAttributeWriteSummary, + }) + process.exit( + productSkuAttributeWriteSummary.productSkuAttributeWriteBlockingRiskCount > 0 + ? 1 + : 0, + ) +} + +if (args.mode === 'reference-estimator-cost-boundary') { + emitReport({ + mode: 'reference-estimator-cost-boundary', + checkedFiles: files.length, + approvedReferenceEstimatorCostSourcePaths: Array.from( + approvedReferenceEstimatorCostSourcePaths.entries(), + ).map(([file, reason]) => ({ file, reason })), + ...referenceEstimatorCostBoundarySummary, + }) + process.exit( + referenceEstimatorCostBoundarySummary + .unclassifiedReferenceEstimatorCostBoundaryFindingCount > 0 + ? 1 + : 0, + ) +} + +if (args.mode === 'filesystem-master-data-source') { + emitReport({ + mode: 'filesystem-master-data-source', + checkedFiles: files.length, + approvedFileSystemMasterDataSourcePaths: Array.from( + approvedFileSystemMasterDataSourcePaths.entries(), + ).map(([file, reason]) => ({ file, reason })), + ...fileSystemMasterDataSourceSummary, + }) + process.exit( + fileSystemMasterDataSourceSummary.unclassifiedFileSystemMasterDataSourceCount > 0 + ? 1 + : 0, + ) +} + +if (args.mode) { + throw new Error(`未知巡检模式:${args.mode}`) +} + +const report = { + checkedFiles: files.length, + checkedScriptFiles: scriptFiles.length, + violations: findings, + approvedCompatibilityCount: approvedCompatibility.length, + approvedCompatibility: approvedCompatibility.slice(0, 50), + advisoryCount: advisoryFindings.length, + advisoryByCategory: advisoryFindings.reduce((result, finding) => { + result[finding.category] = (result[finding.category] ?? 0) + 1 + return result + }, {}), + advisoryFindings: advisoryFindings.slice(0, 50), + unclassifiedStorageCount: unclassifiedStorageFindings.length, + unclassifiedStorageFindings: unclassifiedStorageFindings.slice(0, 50), + scriptMasterDataDirectWriteCount: scriptMasterDataWriteFindings.length, + approvedScriptMasterDataDirectWrites: scriptMasterDataWriteFindings + .filter((finding) => finding.severity === 'approved-script') + .slice(0, 50), + unclassifiedScriptMasterDataDirectWriteCount: + unclassifiedScriptMasterDataWriteFindings.length, + unclassifiedScriptMasterDataDirectWrites: + unclassifiedScriptMasterDataWriteFindings.slice(0, 50), + scriptSafetyGuardCount: scriptSafetyGuardFindings.length, + approvedScriptSafetyGuards: scriptSafetyGuardFindings + .filter((finding) => finding.severity === 'approved-script-guard') + .slice(0, 50), + missingScriptSafetyGuardCount: missingScriptSafetyGuardFindings.length, + missingScriptSafetyGuards: missingScriptSafetyGuardFindings.slice(0, 50), + rawSqlMasterDataWriteCount: rawSqlMasterDataWriteFindings.length, + approvedRawSqlMasterDataWrites: rawSqlMasterDataWriteFindings + .filter((finding) => finding.severity === 'approved-raw-sql') + .slice(0, 50), + unclassifiedRawSqlMasterDataWriteCount: + unclassifiedRawSqlMasterDataWriteFindings.length, + unclassifiedRawSqlMasterDataWrites: + unclassifiedRawSqlMasterDataWriteFindings.slice(0, 50), + rawSqlMasterDataReadCount: rawSqlMasterDataReadFindings.length, + approvedRawSqlMasterDataReads: rawSqlMasterDataReadFindings + .filter((finding) => finding.severity === 'approved-raw-sql-read') + .slice(0, 50), + unclassifiedRawSqlMasterDataReadCount: + unclassifiedRawSqlMasterDataReadFindings.length, + unclassifiedRawSqlMasterDataReads: + unclassifiedRawSqlMasterDataReadFindings.slice(0, 50), + runtimeDatabaseDdlCount: runtimeDatabaseDdlFindings.length, + approvedRuntimeDatabaseDdlFindings: runtimeDatabaseDdlFindings + .filter((finding) => finding.severity === 'approved-runtime-ddl') + .slice(0, 50), + unclassifiedRuntimeDatabaseDdlCount: + unclassifiedRuntimeDatabaseDdlFindings.length, + unclassifiedRuntimeDatabaseDdlFindings: + unclassifiedRuntimeDatabaseDdlFindings.slice(0, 50), + serverMasterDataDirectWriteCount: serverMasterDataWriteFindings.length, + approvedServerMasterDataDirectWrites: serverMasterDataWriteFindings + .filter((finding) => finding.severity === 'approved-server-write') + .slice(0, 50), + unclassifiedServerMasterDataDirectWriteCount: + unclassifiedServerMasterDataWriteFindings.length, + unclassifiedServerMasterDataDirectWrites: + unclassifiedServerMasterDataWriteFindings.slice(0, 50), + compatibilitySnapshotReadCount: compatibilitySnapshotReadFindings.length, + compatibilitySnapshotReadByCategory: + compatibilitySnapshotReadSummary.compatibilitySnapshotReadByCategory, + compatibilitySnapshotReadByRuleId: + compatibilitySnapshotReadSummary.compatibilitySnapshotReadByRuleId, + compatibilitySnapshotReadByFile: + compatibilitySnapshotReadSummary.compatibilitySnapshotReadByFile, + compatibilitySnapshotReadByPriority: + compatibilitySnapshotReadSummary.compatibilitySnapshotReadByPriority, + compatibilitySnapshotReadBlockingRiskCount: + compatibilitySnapshotReadSummary.compatibilitySnapshotReadBlockingRiskCount, + compatibilitySnapshotReadBlockingRisks: + compatibilitySnapshotReadSummary.compatibilitySnapshotReadBlockingRisks.slice(0, 50), + compatibilitySnapshotReadSummary: + compatibilitySnapshotReadSummary.compatibilitySnapshotReadSummary, + unclassifiedCompatibilitySnapshotReadCount: + compatibilitySnapshotReadSummary.unclassifiedCompatibilitySnapshotReadCount, + unclassifiedCompatibilitySnapshotReads: + compatibilitySnapshotReadSummary.unclassifiedCompatibilitySnapshotReads.slice(0, 50), + skippedStructuredPayloadReadCount: skippedStructuredPayloadReads.length, + skippedStructuredPayloadReads: skippedStructuredPayloadReads.slice(0, 50), + unusedCompatibilitySnapshotReadClassificationCount: + unusedCompatibilitySnapshotReadClassifications.length, + unusedCompatibilitySnapshotReadClassifications: + unusedCompatibilitySnapshotReadClassifications.slice(0, 50), + compatibilitySnapshotReadFindings: compatibilitySnapshotReadFindings.slice(0, 50), + productSkuAttributeWriteRiskCount: + productSkuAttributeWriteSummary.productSkuAttributeWriteRiskCount, + productSkuAttributeWriteRiskByRuleId: + productSkuAttributeWriteSummary.productSkuAttributeWriteRiskByRuleId, + productSkuAttributeWriteRiskByFile: + productSkuAttributeWriteSummary.productSkuAttributeWriteRiskByFile, + productSkuAttributeWriteRiskByPriority: + productSkuAttributeWriteSummary.productSkuAttributeWriteRiskByPriority, + productSkuAttributeWriteBlockingRiskCount: + productSkuAttributeWriteSummary.productSkuAttributeWriteBlockingRiskCount, + productSkuAttributeWriteBlockingRisks: + productSkuAttributeWriteSummary.productSkuAttributeWriteBlockingRisks.slice(0, 50), + productSkuAttributeWriteRisks: + productSkuAttributeWriteSummary.productSkuAttributeWriteRisks.slice(0, 50), + referenceEstimatorCostBoundaryFindingCount: + referenceEstimatorCostBoundarySummary.referenceEstimatorCostBoundaryFindingCount, + referenceEstimatorCostBoundaryByRuleId: + referenceEstimatorCostBoundarySummary.referenceEstimatorCostBoundaryByRuleId, + referenceEstimatorCostBoundaryByFile: + referenceEstimatorCostBoundarySummary.referenceEstimatorCostBoundaryByFile, + approvedReferenceEstimatorCostBoundaryFindings: + referenceEstimatorCostBoundarySummary.approvedReferenceEstimatorCostBoundaryFindings.slice( + 0, + 50, + ), + unclassifiedReferenceEstimatorCostBoundaryFindingCount: + referenceEstimatorCostBoundarySummary + .unclassifiedReferenceEstimatorCostBoundaryFindingCount, + unclassifiedReferenceEstimatorCostBoundaryFindings: + referenceEstimatorCostBoundarySummary.unclassifiedReferenceEstimatorCostBoundaryFindings.slice( + 0, + 50, + ), + fileSystemMasterDataSourceFindingCount: + fileSystemMasterDataSourceSummary.fileSystemMasterDataSourceFindingCount, + fileSystemMasterDataSourceByFile: + fileSystemMasterDataSourceSummary.fileSystemMasterDataSourceByFile, + approvedFileSystemMasterDataSources: + fileSystemMasterDataSourceSummary.approvedFileSystemMasterDataSources.slice(0, 50), + unclassifiedFileSystemMasterDataSourceCount: + fileSystemMasterDataSourceSummary.unclassifiedFileSystemMasterDataSourceCount, + unclassifiedFileSystemMasterDataSources: + fileSystemMasterDataSourceSummary.unclassifiedFileSystemMasterDataSources.slice(0, 50), + processSnapshotReadCount: processSnapshotReadFindings.length, + processSnapshotReadByCategory: + processSnapshotReadSummary.processSnapshotReadByCategory, + processSnapshotReadByRuleId: + processSnapshotReadSummary.processSnapshotReadByRuleId, + processSnapshotReadByFile: + processSnapshotReadSummary.processSnapshotReadByFile, + processSnapshotReadByPriority: + processSnapshotReadSummary.processSnapshotReadByPriority, + processSnapshotReadBlockingRiskCount: + processSnapshotReadSummary.processSnapshotReadBlockingRiskCount, + processSnapshotReadBlockingRisks: + processSnapshotReadSummary.processSnapshotReadBlockingRisks.slice(0, 50), + processSnapshotReadSummary: + processSnapshotReadSummary.processSnapshotReadSummary, + unclassifiedProcessSnapshotReadCount: + processSnapshotReadSummary.unclassifiedProcessSnapshotReadCount, + unclassifiedProcessSnapshotReads: + processSnapshotReadSummary.unclassifiedProcessSnapshotReads.slice(0, 50), + unusedProcessSnapshotReadClassificationCount: + unusedProcessSnapshotReadClassifications.length, + unusedProcessSnapshotReadClassifications: + unusedProcessSnapshotReadClassifications.slice(0, 50), + processSnapshotReadFindings: processSnapshotReadFindings.slice(0, 50), + ...snapshotReadBudgetReport, + schemaJsonFieldCount: schemaJsonFieldInventory.length, + schemaJsonFieldByCategory: schemaJsonFieldInventory.reduce((result, field) => { + result[field.category] = (result[field.category] ?? 0) + 1 + return result + }, {}), + unclassifiedSchemaJsonFieldCount: unclassifiedSchemaJsonFields.length, + unclassifiedSchemaJsonFields, + staleSchemaJsonFieldClassificationCount: + staleSchemaJsonFieldClassifications.length, + staleSchemaJsonFieldClassifications, + schemaJsonFieldBoundaryRequirementCount: + schemaJsonFieldBoundaryRequirements.size, + schemaJsonFieldBoundaryIssueCount: + schemaJsonFieldBoundaryIssues.length, + schemaJsonFieldBoundaryIssues: + schemaJsonFieldBoundaryIssues.slice(0, 50), + staleFileClassificationCount: staleFileClassifications.length, + staleFileClassifications, + approvedClientStorageEvidenceIssueCount: + approvedClientStorageEvidenceIssues.length, + approvedClientStorageEvidenceIssues: + approvedClientStorageEvidenceIssues.slice(0, 50), + approvedClientStorageUsages: approvedClientStorageUsages.map((usage) => ({ + file: usage.file, + category: usage.category, + targetState: usage.targetState, + reason: usage.reason, + requiredEvidenceCount: usage.requiredEvidence?.length ?? 0, + forbiddenEvidenceCount: usage.forbiddenEvidence?.length ?? 0, + })), +} + +emitReport(report) + +if ( + findings.length > 0 || + unclassifiedStorageFindings.length > 0 || + unclassifiedScriptMasterDataWriteFindings.length > 0 || + unclassifiedRawSqlMasterDataWriteFindings.length > 0 || + unclassifiedRawSqlMasterDataReadFindings.length > 0 || + unclassifiedRuntimeDatabaseDdlFindings.length > 0 || + unclassifiedServerMasterDataWriteFindings.length > 0 || + unclassifiedCompatibilitySnapshotReadFindings.length > 0 || + compatibilitySnapshotReadSummary.compatibilitySnapshotReadBlockingRiskCount > 0 || + unusedCompatibilitySnapshotReadClassifications.length > 0 || + productSkuAttributeWriteSummary.productSkuAttributeWriteBlockingRiskCount > 0 || + referenceEstimatorCostBoundarySummary + .unclassifiedReferenceEstimatorCostBoundaryFindingCount > 0 || + fileSystemMasterDataSourceSummary.unclassifiedFileSystemMasterDataSourceCount > 0 || + processSnapshotReadSummary.processSnapshotReadBlockingRiskCount > 0 || + unusedProcessSnapshotReadClassifications.length > 0 || + unclassifiedProcessSnapshotReadFindings.length > 0 || + snapshotReadBudgetReport.compatibilitySnapshotReadBudgetViolationCount > 0 || + snapshotReadBudgetReport.processSnapshotReadBudgetViolationCount > 0 || + unclassifiedSchemaJsonFields.length > 0 || + staleSchemaJsonFieldClassifications.length > 0 || + schemaJsonFieldBoundaryIssues.length > 0 || + staleFileClassifications.length > 0 || + approvedClientStorageEvidenceIssues.length > 0 +) { + process.exit(1) +} +} diff --git a/furniture-3d-system/scripts/verify-master-data-foundation.js b/furniture-3d-system/scripts/verify-master-data-foundation.js new file mode 100644 index 00000000..96a87498 --- /dev/null +++ b/furniture-3d-system/scripts/verify-master-data-foundation.js @@ -0,0 +1,4648 @@ +const fs = require('node:fs') +const path = require('node:path') +const { Pool } = require('pg') +const { + REQUIRED_MIGRATIONS, + applyMissingMigrationFiles, + classifyAppliedMigrationPaths, + columnDefaultMatches, + columnExists, + dataCheckPasses, + guardExists, + getMigrationTargetRelations, + getPendingStructureMigrationPaths, + relationColumnExists, + relationExists, + validateRequiredMigrationCatalog, +} = require('./master-data-required-migrations') + +const ROOT_DIR = path.resolve(__dirname, '..') + +function parseArgs(argv) { + const args = { + outputFile: '', + summaryOutputFile: '', + summaryStdout: false, + requireApplied: false, + readOnlyMigrationStatus: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--require-applied') { + args.requireApplied = true + continue + } + if (arg === '--read-only-migration-status') { + args.readOnlyMigrationStatus = true + continue + } + if (arg === '--output') { + const outputFile = argv[index + 1] + if (!outputFile) throw new Error('--output 需要提供文件路径。') + args.outputFile = outputFile + index += 1 + continue + } + if (arg.startsWith('--output=')) { + args.outputFile = arg.slice('--output='.length) + if (!args.outputFile) throw new Error('--output 需要提供文件路径。') + continue + } + if (arg === '--summary-output') { + const summaryOutputFile = argv[index + 1] + if (!summaryOutputFile) throw new Error('--summary-output 需要提供文件路径。') + args.summaryOutputFile = summaryOutputFile + index += 1 + continue + } + if (arg.startsWith('--summary-output=')) { + args.summaryOutputFile = arg.slice('--summary-output='.length) + if (!args.summaryOutputFile) throw new Error('--summary-output 需要提供文件路径。') + continue + } + if (arg === '--summary-stdout') { + args.summaryStdout = true + continue + } + throw new Error(`未知参数:${arg}`) + } + + if (args.requireApplied && args.readOnlyMigrationStatus) { + throw new Error('--require-applied 和 --read-only-migration-status 不能同时使用。') + } + + return args +} + +function writeJsonReport(filePath, payload) { + if (!filePath) return '' + const absolutePath = path.resolve(ROOT_DIR, filePath) + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }) + fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + return absolutePath +} + +function summarizeBlockerValue(value) { + if (value === null || value === undefined) return true + if (typeof value === 'number' || typeof value === 'boolean') return value + if (Array.isArray(value)) return value.length + return true +} + +function buildFoundationHardReadySummary(report) { + const gate = report.foundationHardReadyGate || buildFoundationHardReadyGate(report) + const blockers = Array.isArray(gate.blockers) ? gate.blockers : [] + const migrationStatusSource = report.migrationApplicationStatus + ? report.mode === 'current' + ? 'current-database' + : report.mode === 'migration-dry-run' || report.mode === 'idempotent-migration-replay' + ? 'transactional-dry-run-replay' + : 'reported' + : null + return { + mode: report.mode || 'unknown', + rolledBack: report.rolledBack === true, + hardReady: gate.hardReady === true, + blockerCount: gate.blockerCount ?? blockers.length, + blockerSourceCounts: gate.blockerSourceCounts || blockers.reduce((counts, blocker) => { + const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN' + counts[source] = (counts[source] || 0) + 1 + return counts + }, {}), + blockerKeys: blockers.map((blocker) => { + const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN' + const key = typeof blocker?.key === 'string' && blocker.key ? blocker.key : 'UNKNOWN' + return `${source}:${key}` + }), + blockerValues: blockers.reduce((values, blocker) => { + const source = typeof blocker?.source === 'string' && blocker.source ? blocker.source : 'UNKNOWN' + const key = typeof blocker?.key === 'string' && blocker.key ? blocker.key : 'UNKNOWN' + values[`${source}:${key}`] = summarizeBlockerValue(blocker?.value) + return values + }, {}), + migrationApplicationStatus: report.migrationApplicationStatus + ? { + source: migrationStatusSource, + appliedMigrationCount: report.migrationApplicationStatus.appliedMigrationCount ?? null, + pendingStructureMigrationCount: + report.migrationApplicationStatus.pendingStructureMigrationCount ?? null, + replayableIdempotentMigrationCount: + report.migrationApplicationStatus.replayableIdempotentMigrationCount ?? null, + strictReady: report.migrationApplicationStatus.strictReady === true, + fullyReady: report.migrationApplicationStatus.fullyReady === true, + } + : null, + } +} + +function writeFoundationHardReadyOutputs(args, report) { + writeJsonReport(args.outputFile, report) + writeJsonReport(args.summaryOutputFile, buildFoundationHardReadySummary(report)) +} + +function printFoundationHardReadyReport(args, report) { + const payload = args.summaryStdout ? buildFoundationHardReadySummary(report) : report + console.log(JSON.stringify(payload, null, 2)) +} + +const REQUIRED_DATABASE_GUARDS = [ + { + type: 'unique-index', + name: 'AssetSeries_code_key', + relationName: 'AssetSeries', + purpose: '资产系列编码是资产中心系列真相源,必须数据库级唯一。', + }, + { + type: 'check-constraint', + name: 'AssetSeries_identity_valid', + relationName: 'AssetSeries', + expressionPattern: 'sortOrder', + purpose: '资产系列名称、编码和排序必须是稳定结构化字段,不能保存空名称或负排序。', + }, + { + type: 'check-constraint', + name: 'AssetSeries_metadata_object', + relationName: 'AssetSeries', + expressionPattern: 'jsonb_typeof', + purpose: '资产系列 metadata 只能是对象,避免扩展字段形态漂移。', + }, + { + type: 'unique-index', + name: 'AssetFamily_code_key', + relationName: 'AssetFamily', + purpose: '资产族编码是资产族真相源,必须数据库级唯一。', + }, + { + type: 'check-constraint', + name: 'AssetFamily_identity_valid', + relationName: 'AssetFamily', + expressionPattern: 'connectionRoles', + purpose: '资产族名称、编码、连接角色和可选文本必须保持可用,源头资料不能出现空白身份。', + }, + { + type: 'check-constraint', + name: 'AssetFamily_metadata_object', + relationName: 'AssetFamily', + expressionPattern: 'jsonb_typeof', + purpose: '资产族 metadata 只能是对象,避免结构化源数据扩展字段形态漂移。', + }, + { + type: 'check-constraint', + name: 'AssetFamily_lifecycle_state_valid', + relationName: 'AssetFamily', + expressionPattern: 'PENDING_REVIEW', + purpose: '资产族提交、审核、驳回和软删除状态必须和对应时间/人员字段一致。', + }, + { + type: 'unique-index', + name: 'AssetVariant_code_key', + relationName: 'AssetVariant', + purpose: '规格款编码是资产中心规格款真相源,必须数据库级唯一。', + }, + { + type: 'unique-index', + name: 'AssetVariant_assetFamilyId_identityKey_key', + relationName: 'AssetVariant', + purpose: '同一资产族下规格款身份键必须唯一,防止同款规格被重复建档。', + }, + { + type: 'partial-unique-index', + name: 'AssetVariant_barcode_active_key', + relationName: 'AssetVariant', + predicatePattern: '"deletedAt" IS NULL', + definitionPattern: 'upper(btrim(', + purpose: '未删除规格款的条码必须唯一,正式 SKU 同步副本不能从资产源头继承冲突。', + }, + { + type: 'check-constraint', + name: 'AssetVariant_identity_valid', + relationName: 'AssetVariant', + expressionPattern: 'identityKey', + purpose: '资产规格款名称、编码、身份键、模型 URL 和可选文本必须保持可用。', + }, + { + type: 'check-constraint', + name: 'AssetVariant_json_shape_valid', + relationName: 'AssetVariant', + expressionPattern: 'mountPoints', + purpose: '资产规格款尺寸、挂点、装配依据和业务扩展 JSON 形态必须稳定。', + }, + { + type: 'check-constraint', + name: 'AssetVariant_cost_weight_valid', + relationName: 'AssetVariant', + expressionPattern: 'estimatedCostPrice', + purpose: '资产规格款重量、损耗率、成本和预估成本不能为负。', + }, + { + type: 'check-constraint', + name: 'AssetVariant_lifecycle_state_valid', + relationName: 'AssetVariant', + expressionPattern: 'PENDING_REVIEW', + purpose: '资产规格款提交、审核、驳回和软删除状态必须和对应时间/人员字段一致。', + }, + { + type: 'check-constraint', + name: 'AssetVariant_inheritance_not_self', + relationName: 'AssetVariant', + expressionPattern: 'inheritedFromVariantId', + purpose: '资产规格款继承关系不能指向自身,避免规格派生链出现自环。', + }, + { + type: 'unique-index', + name: 'AssetFamilySeriesCompatibility_assetFamilyId_assetSeriesId_key', + relationName: 'AssetFamilySeriesCompatibility', + purpose: '资产族和系列兼容关系必须唯一,避免同一归属被重复表达。', + }, + { + type: 'unique-index', + name: 'DraftGroup_code_key', + relationName: 'DraftGroup', + purpose: '草稿组编号必须唯一,辅助装配确认结果不能产生重复承接单。', + }, + { + type: 'check-constraint', + name: 'DraftGroup_identity_valid', + relationName: 'DraftGroup', + expressionPattern: 'btrim', + purpose: '草稿组编号和名称必须保持非空且无首尾空格,不能让展示文本变成不稳定承接身份。', + }, + { + type: 'check-constraint', + name: 'DraftGroup_summary_object', + relationName: 'DraftGroup', + expressionPattern: 'jsonb_typeof', + purpose: '草稿组 summary 只能是对象,过程摘要快照不能漂移成数组或自由值。', + }, + { + type: 'check-constraint', + name: 'DraftGroup_optional_text_not_blank', + relationName: 'DraftGroup', + expressionPattern: 'description', + purpose: '草稿组可选长文本只能是 NULL 或有效文本,避免空字符串和 NULL 形成两套缺失状态。', + }, + { + type: 'unique-index', + name: 'PlanningTemplate_code_key', + relationName: 'PlanningTemplate', + purpose: '规划模板编码必须唯一,避免同一模板身份被重复表达。', + }, + { + type: 'unique-index', + name: 'Substrate_code_key', + relationName: 'Substrate', + purpose: '基材编码必须唯一,资产规格款不能引用语义重复的基材。', + }, + { + type: 'unique-index', + name: 'WarehouseLocation_code_key', + relationName: 'WarehouseLocation', + purpose: '库位编码必须唯一,库存和资产位置绑定不能指向重复库位身份。', + }, + { + type: 'unique-index', + name: 'User_email_normalized_key', + relationName: 'User', + definitionPattern: 'lower(btrim', + purpose: '用户邮箱必须按小写去空格后的语义唯一,登录身份不能被大小写绕开。', + }, + { + type: 'unique-index', + name: 'User_name_normalized_key', + relationName: 'User', + definitionPattern: 'btrim', + purpose: '用户花名必须按去空格后的语义唯一,不能出现看似不同的同名账号。', + }, + { + type: 'unique-index', + name: 'Department_name_normalized_key', + relationName: 'Department', + definitionPattern: 'btrim', + purpose: '部门名称必须按去空格后的语义唯一,组织树不能有空格差异重复节点。', + }, + { + type: 'partial-unique-index', + name: 'Department_externalId_normalized_key', + relationName: 'Department', + predicatePattern: 'NULLIF(btrim', + definitionPattern: 'btrim', + purpose: '外部部门 ID 非空时必须语义唯一,花名册同步不能把同一钉钉部门写成两个节点。', + }, + { + type: 'partial-unique-index', + name: 'EmployeeDirectory_dingtalkUserId_normalized_key', + relationName: 'EmployeeDirectory', + predicatePattern: 'NULLIF(btrim', + definitionPattern: 'btrim', + purpose: '员工钉钉 userId 非空时必须语义唯一,账号匹配不能出现多候选。', + }, + { + type: 'partial-unique-index', + name: 'EmployeeDirectory_dingtalkUnionId_normalized_key', + relationName: 'EmployeeDirectory', + predicatePattern: 'NULLIF(btrim', + definitionPattern: 'btrim', + purpose: '员工钉钉 unionId 非空时必须语义唯一,跨应用匹配不能出现多候选。', + }, + { + type: 'partial-unique-index', + name: 'EmployeeDirectory_jobNumber_normalized_key', + relationName: 'EmployeeDirectory', + predicatePattern: 'NULLIF(btrim', + definitionPattern: 'btrim', + purpose: '员工工号非空时必须语义唯一,后台创建账号和手动匹配不能命中不稳定记录。', + }, + { + type: 'partial-unique-index', + name: 'EmployeeDirectory_mobile_normalized_key', + relationName: 'EmployeeDirectory', + predicatePattern: 'NULLIF(btrim', + definitionPattern: 'btrim', + purpose: '员工手机号非空时必须语义唯一,钉钉匹配和花名册匹配不能出现多候选。', + }, + { + type: 'partial-unique-index', + name: 'WarehouseLocation_active_position_key', + relationName: 'WarehouseLocation', + predicatePattern: '"isActive" = true', + definitionPattern: 'lower(btrim', + purpose: '活动库位的区/巷道/货架/箱位组合必须唯一,避免同一物理位置被重复建档。', + }, + { + type: 'unique-index', + name: 'PackagedItem_code_key', + relationName: 'PackagedItem', + purpose: '包装成品编号必须唯一,轻仓库成品不能产生重复身份。', + }, + { + type: 'unique-index', + name: 'PreSku_draftGroupId_sequence_key', + relationName: 'PreSku', + purpose: '同一草稿组内准 SKU 序号必须唯一,避免入库前清单重复行。', + }, + { + type: 'check-constraint', + name: 'PreSku_identity_valid', + relationName: 'PreSku', + expressionPattern: 'sourceCandidateKey', + purpose: '准 SKU 序号、默认名称和来源候选 key 是草稿承接身份,必须稳定非空。', + }, + { + type: 'check-constraint', + name: 'PreSku_process_snapshots_shape_valid', + relationName: 'PreSku', + expressionPattern: 'familySelection', + purpose: '准 SKU 过程快照 JSON 形态必须稳定,避免展示/入库生成链路吞掉坏结构。', + }, + { + type: 'check-constraint', + name: 'PreSku_weight_valid', + relationName: 'PreSku', + expressionPattern: 'weightPreview', + purpose: '准 SKU 预估重量不能为负,草稿承接层不能沉淀异常重量。', + }, + { + type: 'check-constraint', + name: 'PreSku_component_codes_valid', + relationName: 'PreSku', + expressionPattern: 'componentCodes', + purpose: '准 SKU 组件编码数组不能包含空白元素,避免组件身份池出现隐形空值。', + }, + { + type: 'check-constraint', + name: 'PreSku_optional_text_not_blank', + relationName: 'PreSku', + expressionPattern: 'customName', + purpose: '准 SKU 可选文本只能是 NULL 或有效文本,避免空字符串和 NULL 形成两套缺失状态。', + }, + { + type: 'unique-index', + name: 'Product_modelNumber_key', + relationName: 'Product', + purpose: '正式产品型号必须唯一,是正式产品主数据身份。', + }, + { + type: 'unique-index', + name: 'ProductSKU_skuCode_key', + relationName: 'ProductSKU', + purpose: '正式 SKU 编码必须唯一,是正式商品对内对外稳定身份。', + }, + { + type: 'partial-unique-index', + name: 'ProductSKU_active_skuCode_normalized_key', + relationName: 'ProductSKU', + predicatePattern: '"deletedAt" IS NULL', + definitionPattern: 'upper(btrim(', + purpose: '活动正式 SKU 编码必须按大写去空格后的语义唯一,删除态墓碑不参与活动池。', + }, + { + type: 'unique-index', + name: 'ProductSKU_sourcePreSkuId_key', + relationName: 'ProductSKU', + purpose: '一个准 SKU 只能正式入库一次,不能被重复转成多个正式 SKU。', + }, + { + type: 'check-constraint', + name: 'ProductSKU_price_cost_stock_non_negative', + relationName: 'ProductSKU', + expressionPattern: '"costPrice"', + purpose: '正式 SKU 售价、成本价和库存不能为负,正式成本口径必须落在结构化 ProductSKU 字段而不是过程估算。', + }, + { + type: 'unique-index', + name: 'IntakeNonIntegratedProduct_spu_brand_key', + relationName: 'IntakeNonIntegratedProduct', + purpose: '非集成开品 SPU 在同一品牌池内必须唯一,避免产品资料入口重复身份。', + }, + { + type: 'unique-index', + name: 'IntakeNonIntegratedProduct_spu_brand_normalized_key', + relationName: 'IntakeNonIntegratedProduct', + definitionPattern: 'upper(btrim(', + purpose: '非集成开品 SPU 在同一品牌池内必须按大写去空格后的语义唯一。', + }, + { + type: 'unique-index', + name: 'ProductSkuAssetProfile_skuId_key', + relationName: 'ProductSkuAssetProfile', + purpose: '正式 SKU 到资产资料同步副本必须一对一。', + }, + { + type: 'partial-unique-index', + name: 'ProductSkuAssetProfile_barcode_active_non_empty_key', + relationName: 'ProductSkuAssetProfile', + predicatePattern: '"skuDeletedAt" IS NULL', + definitionPattern: 'upper(btrim(', + purpose: '正式 SKU 资产资料同步副本的非空活动条码必须唯一,且软删除 SKU 必须释放条码池。', + }, + { + type: 'unique-index', + name: 'PreSkuBasicInfo_preSkuId_key', + relationName: 'PreSkuBasicInfo', + purpose: '准 SKU 基础资料必须一对一,不能产生第二份真相。', + }, + { + type: 'partial-unique-index', + name: 'PreSkuBasicInfo_formalSkuCode_non_empty_key', + relationName: 'PreSkuBasicInfo', + predicatePattern: 'NULLIF(btrim(', + definitionPattern: 'upper(btrim(', + purpose: '准 SKU 非空正式规格码必须唯一,不能只靠页面冲突检测。', + }, + { + type: 'partial-unique-index', + name: 'PreSkuBasicInfo_barcode_non_empty_key', + relationName: 'PreSkuBasicInfo', + predicatePattern: 'NULLIF(btrim(', + definitionPattern: 'upper(btrim(', + purpose: '准 SKU 非空条码必须唯一,进入正式产品前先锁住冲突池。', + }, + { + type: 'unique-index', + name: 'IntakeNonIntegratedProductProfile_productId_key', + relationName: 'IntakeNonIntegratedProductProfile', + purpose: '非集成产品头结构化 profile 必须一对一。', + }, + { + type: 'unique-index', + name: 'IntakeNonIntegratedSkuItem_productId_skuItemId_key', + relationName: 'IntakeNonIntegratedSkuItem', + purpose: '非集成 SKU 明细必须和 payload 的 skuItemId 稳定对账。', + }, + { + type: 'partial-unique-index', + name: 'IntakeNonIntegratedSkuItem_skuCode_non_empty_key', + relationName: 'IntakeNonIntegratedSkuItem', + predicatePattern: 'NULLIF(btrim(', + definitionPattern: 'upper(btrim(', + purpose: '非空 SKU 编码必须全局唯一,空值只允许作为待补齐状态。', + }, + { + type: 'partial-unique-index', + name: 'IntakeNonIntegratedSkuItem_barcode_non_empty_key', + relationName: 'IntakeNonIntegratedSkuItem', + predicatePattern: 'NULLIF(btrim(', + definitionPattern: 'upper(btrim(', + purpose: '非空条码必须全局唯一,空值只允许作为待补齐状态。', + }, + { + type: 'unique-index', + name: 'ProductPatentRecord_patentGroupKey_key', + relationName: 'ProductPatentRecord', + purpose: '专利台账按产品分组一对一保存,不能让同一分组被多行重复表达。', + }, + { + type: 'trigger', + name: 'IntakeNonIntegratedProduct_soft_delete_cleanup_sku_items', + relationName: 'IntakeNonIntegratedProduct', + purpose: '非集成产品软删除后释放结构化 SKU 编码和条码唯一池。', + }, + { + type: 'unique-index', + name: 'IntakeReferenceLibraryItem_libraryType_itemKey_key', + relationName: 'IntakeReferenceLibraryItem', + purpose: '资料库型条目必须在服务端拥有稳定身份,不能继续只靠浏览器本地 ID。', + }, + { + type: 'partial-unique-index', + name: 'IntakeReferenceLibraryItem_libraryType_code_non_empty_key', + relationName: 'IntakeReferenceLibraryItem', + predicatePattern: 'NULLIF(btrim(code),', + definitionPattern: 'upper(btrim(', + purpose: '同一资料库内非空编码必须唯一,为后续替换 localStorage 资料库提供硬约束。', + }, + { + type: 'unique-index', + name: 'IntakeReferenceLibraryItem_libraryType_itemKey_normalized_key', + relationName: 'IntakeReferenceLibraryItem', + definitionPattern: 'lower(btrim(', + purpose: '同一资料库内 itemKey 必须按大小写和空格归一后唯一,避免本地缓存旧键制造第二份真相。', + }, + { + type: 'partial-unique-index', + name: 'IntakeReferenceLibraryItem_spu_rule_name_non_empty_key', + relationName: 'IntakeReferenceLibraryItem', + predicatePattern: '"libraryType" = \'SPU_RULE\'', + purpose: 'SPU 规则按产品品类一对一,避免正式号段计数器初始化来源不确定。', + }, + { + type: 'partial-unique-index', + name: 'IntakeReferenceLibraryItem_spu_rule_name_normalized_key', + relationName: 'IntakeReferenceLibraryItem', + predicatePattern: '"libraryType" = \'SPU_RULE\'', + definitionPattern: 'lower(btrim(', + purpose: 'SPU 规则产品品类必须按去空格和大小写归一后一对一,和正式 SPU 计数器查找语义保持一致。', + }, + { + type: 'unique-index', + name: 'SpuNumberCounter_productCategory_key', + relationName: 'SpuNumberCounter', + purpose: 'SPU 正式号段计数器必须按产品品类一对一锁定,不能继续依赖前端本地缓存占号。', + }, + { + type: 'unique-index', + name: 'SpuNumberCounter_productCategory_normalized_key', + relationName: 'SpuNumberCounter', + definitionPattern: 'lower(btrim(', + purpose: 'SPU 号段计数器产品品类必须按去空格和大小写归一后唯一,避免同品类双号池。', + }, + { + type: 'unique-index', + name: 'SpuNumberCounter_prefix_key', + relationName: 'SpuNumberCounter', + purpose: 'SPU 正式号段前缀必须唯一,避免不同品类并发占用同一个前缀导致撞号。', + }, + { + type: 'unique-index', + name: 'Material_code_key', + relationName: 'Material', + purpose: '材质花色编码必须唯一,资产和正式 SKU 不能引用重复花色身份。', + }, + { + type: 'unique-index', + name: 'Hardware_code_key', + relationName: 'Hardware', + purpose: '标准五金编码必须唯一,孔位匹配和 BOM 不能引用重复五金身份。', + }, + { + type: 'unique-index', + name: 'CommonComponent_code_key', + relationName: 'CommonComponent', + purpose: '通用组件编码必须唯一,辅助装配组件池不能产生重复组件身份。', + }, + { + type: 'unique-index', + name: 'BrandMaterialBinding_brand_material_normalized_key', + relationName: 'BrandMaterialBinding', + definitionPattern: 'lower(btrim', + purpose: '品牌物料准备项必须按品牌和物料类型的规范化身份唯一,避免同一品牌同一资料重复绑定。', + }, + { + type: 'unique-index', + name: 'Panel_product_sku_code_normalized_key', + relationName: 'Panel', + definitionPattern: 'upper(btrim', + purpose: '同一产品/SKU 范围内板件编号必须按大写去空格后唯一,旧 CAD 板件清单不能重复身份。', + }, + { + type: 'check-constraint', + name: 'IntakeProductProfile_developmentKind_allowed', + relationName: 'IntakeNonIntegratedProductProfile', + expressionPattern: '"productDevelopmentKind" = \'\'', + purpose: '非集成产品开发类型只能为空草稿、product 或 series,避免旁路写入随手文本。', + }, + { + type: 'check-constraint', + name: 'IntakeNonIntegratedSkuItem_missingRequiredFields_allowed', + relationName: 'IntakeNonIntegratedSkuItem', + expressionPattern: 'missingRequiredFields', + purpose: '非集成 SKU 缺失字段枚举只能使用统一质量规则定义的字段名。', + }, + { + type: 'check-constraint', + name: 'IntakeNonIntegratedSkuItem_quality_state_required_fields', + relationName: 'IntakeNonIntegratedSkuItem', + expressionPattern: 'dataQualityStatus', + purpose: '非集成 SKU 的 READY/INCOMPLETE 状态必须和必填字段、缺失字段数组一致。', + }, + { + type: 'partial-unique-index', + name: 'MasterDataQualityDecision_open_target_key', + relationName: 'MasterDataQualityDecision', + predicatePattern: 'status = \'APPROVED_INCOMPLETE\'', + purpose: '同一主数据对象同一时间只能有一条未解决的不完整审批,避免分阶段清洗原因互相覆盖。', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_target_not_blank', + relationName: 'MasterDataQualityDecision', + expressionPattern: 'targetType', + purpose: '主数据质量决策必须绑定明确目标对象,不能留下游离审批记录。', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_status_allowed', + relationName: 'MasterDataQualityDecision', + expressionPattern: 'APPROVED_INCOMPLETE', + purpose: '主数据质量决策状态只能使用受控枚举文本,避免随手状态绕开 readiness 语义。', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_reason_not_blank', + relationName: 'MasterDataQualityDecision', + expressionPattern: 'reason', + purpose: '允许分阶段不完整时必须保留业务确认原因,不能只有一个布尔开关。', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_decidedBy_not_blank', + relationName: 'MasterDataQualityDecision', + expressionPattern: 'decidedBy', + purpose: '主数据质量决策必须记录确认人,后续清洗和问责可追溯。', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_reason_not_placeholder', + relationName: 'MasterDataQualityDecision', + expressionPattern: '待确认', + purpose: '主数据质量决策原因不能是待确认、UNKNOWN 或默认值这类占位文本。', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_decidedBy_not_placeholder', + relationName: 'MasterDataQualityDecision', + expressionPattern: '待确认', + purpose: '主数据质量决策确认人不能是待确认、UNKNOWN 或默认值这类占位文本。', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_resolvedBy_not_placeholder', + relationName: 'MasterDataQualityDecision', + expressionPattern: '待确认', + purpose: '主数据质量决策解决人不能是待确认、UNKNOWN 或默认值这类占位文本。', + }, + { + type: 'check-constraint', + name: 'MasterDataQualityDecision_resolved_state_valid', + relationName: 'MasterDataQualityDecision', + expressionPattern: 'resolvedAt', + purpose: '已解决的质量决策必须记录解决人和时间,未解决记录不能伪装成已关闭。', + }, + { + type: 'check-constraint', + name: 'SpuNumberCounter_productCategory_not_blank', + relationName: 'SpuNumberCounter', + expressionPattern: 'productCategory', + purpose: '正式 SPU 号段计数器必须绑定非空产品品类,不能产生游离号池。', + }, + { + type: 'check-constraint', + name: 'SpuNumberCounter_productCategory_normalized', + relationName: 'SpuNumberCounter', + expressionPattern: 'btrim', + purpose: '正式 SPU 号段计数器产品品类必须无首尾空格,不能绕过服务层制造第二号池。', + }, + { + type: 'check-constraint', + name: 'SpuNumberCounter_prefix_not_blank', + relationName: 'SpuNumberCounter', + expressionPattern: 'prefix', + purpose: '正式 SPU 号段计数器必须有非空前缀,不能生成无业务含义编号。', + }, + { + type: 'check-constraint', + name: 'SpuNumberCounter_prefix_normalized', + relationName: 'SpuNumberCounter', + expressionPattern: 'upper(btrim', + purpose: 'SPU 正式号段前缀必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'SpuNumberCounter_lastUsedIndex_non_negative', + relationName: 'SpuNumberCounter', + expressionPattern: 'lastUsedIndex', + purpose: '正式 SPU 号段计数器不能回到负数,防止重新占用历史编号。', + }, + { + type: 'check-constraint', + name: 'SpuNumberCounter_sourceLibraryItemId_normalized', + relationName: 'SpuNumberCounter', + expressionPattern: 'sourceLibraryItemId', + purpose: 'SPU 号段计数器关联的规则来源 id 非空时必须可追溯且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'ProductSKU_active_skuCode_not_deleted_marker', + relationName: 'ProductSKU', + expressionPattern: '__deleted__', + purpose: '活动正式 SKU 编码不能带软删除墓碑标记,防止旁路写入污染正式编码池。', + }, + { + type: 'check-constraint', + name: 'ProductSKU_soft_delete_identity_valid', + relationName: 'ProductSKU', + expressionPattern: 'originalSkuCode', + purpose: '正式 SKU 软删除生命周期身份必须落结构化字段,活动 SKU 不得残留删除态身份。', + }, + { + type: 'check-constraint', + name: 'ProductSKU_no_soft_delete_lifecycle_attributes', + relationName: 'ProductSKU', + expressionPattern: '_originalSkuCode', + purpose: '正式 SKU attributes 不能再承载软删除生命周期元数据,防止 JSON 兼容字段重新成为第二真相。', + }, + { + type: 'check-constraint', + name: 'ProductSKU_soft_delete_tombstone_valid', + relationName: 'ProductSKU', + expressionPattern: 'deletedCodeAt', + purpose: '正式 SKU 删除态必须保留结构化删除编码时间,随产品删除的归属字段必须成对出现。', + }, + { + type: 'check-constraint', + name: 'ProductSKU_no_formal_truth_attributes', + relationName: 'ProductSKU', + expressionPattern: 'skuName', + purpose: '正式 SKU attributes 不得保存名称、型号、规格码、条码、品类、颜色、材质和尺寸等正式资料字段。', + }, + { + type: 'trigger', + name: 'ProductSKU_sync_asset_profile_deleted_at', + relationName: 'ProductSKU', + purpose: '正式 SKU 软删除生命周期必须自动同步到 ProductSkuAssetProfile.skuDeletedAt,避免条码活动池与 SKU 删除态漂移。', + }, + { + type: 'check-constraint', + name: 'AssetSeries_code_normalized', + relationName: 'AssetSeries', + expressionPattern: 'upper(btrim', + purpose: '资产系列编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'AssetFamily_code_normalized', + relationName: 'AssetFamily', + expressionPattern: 'upper(btrim', + purpose: '资产族编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'DraftGroup_code_normalized', + relationName: 'DraftGroup', + expressionPattern: 'upper(btrim', + purpose: '草稿组编号必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'IntakeReferenceLibraryItem_code_normalized', + relationName: 'IntakeReferenceLibraryItem', + expressionPattern: 'upper(btrim', + purpose: '资料库条目编码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'IntakeReferenceLibraryItem_code_required', + relationName: 'IntakeReferenceLibraryItem', + expressionPattern: 'NULLIF(btrim', + purpose: '资料库条目编码必须非空且大写归一,payload 扩展不能替代结构化 code 身份。', + }, + { + type: 'check-constraint', + name: 'IntakeReferenceLibraryItem_libraryType_allowed', + relationName: 'IntakeReferenceLibraryItem', + expressionPattern: 'PACKAGING_MATERIAL', + purpose: '资料库类型必须固定在后端支持的类型枚举内,不能由手工 SQL 或新入口写出游离类型。', + }, + { + type: 'check-constraint', + name: 'IntakeReferenceLibraryItem_itemKey_required', + relationName: 'IntakeReferenceLibraryItem', + expressionPattern: 'btrim', + purpose: '资料库条目 itemKey 必须非空且无首尾空格,不能用空键或空格键作为主数据身份。', + }, + { + type: 'check-constraint', + name: 'IntakeReferenceLibraryItem_letter_normalized', + relationName: 'IntakeReferenceLibraryItem', + expressionPattern: 'upper(btrim', + purpose: '资料库字母索引非空时必须大写且无首尾空格,避免筛选和排序出现语义重复。', + }, + { + type: 'check-constraint', + name: 'IntakeReferenceLibraryItem_spu_rule_name_normalized', + relationName: 'IntakeReferenceLibraryItem', + expressionPattern: 'SPU_RULE', + purpose: 'SPU 规则产品品类不能为空且不能带首尾空格,避免号段规则和计数器查找漂移。', + }, + { + type: 'check-constraint', + name: 'PlanningTemplate_code_normalized', + relationName: 'PlanningTemplate', + expressionPattern: 'upper(btrim', + purpose: '规划模板编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PlanningTemplate_name_not_blank', + relationName: 'PlanningTemplate', + expressionPattern: 'name', + purpose: '规划模板名称必须非空且无首尾空格,模板编码不能指向无语义名称。', + }, + { + type: 'check-constraint', + name: 'PlanningTemplate_slots_array', + relationName: 'PlanningTemplate', + expressionPattern: 'jsonb_array_length', + purpose: '规划模板槽位定义必须是非空数组,JSON 只承载受控模板结构。', + }, + { + type: 'check-constraint', + name: 'PlanningTemplate_rules_object', + relationName: 'PlanningTemplate', + expressionPattern: 'rules', + purpose: '规划模板规则只能是对象或空值,不能写成第二套游离结构。', + }, + { + type: 'check-constraint', + name: 'PlanningTemplate_optional_text_not_blank', + relationName: 'PlanningTemplate', + expressionPattern: 'namePattern', + purpose: '规划模板可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'PlanningWorkspace_name_not_blank', + relationName: 'PlanningWorkspace', + expressionPattern: 'name', + purpose: '规划工作台名称必须非空且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PlanningWorkspace_visibility_allowed', + relationName: 'PlanningWorkspace', + expressionPattern: 'DEPARTMENT', + purpose: '规划工作台可见范围只能来自固定枚举,不能成为游离权限状态。', + }, + { + type: 'check-constraint', + name: 'PlanningWorkspace_slotSelections_object', + relationName: 'PlanningWorkspace', + expressionPattern: 'slotSelections', + purpose: '规划工作台槽位选择只能是对象,明确作为过程筛选 JSON。', + }, + { + type: 'check-constraint', + name: 'PlanningWorkspace_metadata_object', + relationName: 'PlanningWorkspace', + expressionPattern: 'metadata', + purpose: '规划工作台元数据只能是对象或空值,不能承载结构化主数据真相。', + }, + { + type: 'check-constraint', + name: 'PlanningWorkspace_optional_text_not_blank', + relationName: 'PlanningWorkspace', + expressionPattern: 'description', + purpose: '规划工作台可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'PlanningDraft_identity_not_blank', + relationName: 'PlanningDraft', + expressionPattern: 'selectedCandidateKey', + purpose: '规划草稿名称和候选 key 必须非空且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PlanningDraft_selectedAssets_object', + relationName: 'PlanningDraft', + expressionPattern: 'selectedAssets', + purpose: '规划草稿选中资产快照只能是对象,不能反向成为资产中心主数据。', + }, + { + type: 'check-constraint', + name: 'PlanningDraft_previewNodes_array', + relationName: 'PlanningDraft', + expressionPattern: 'previewNodes', + purpose: '规划草稿 3D 预览节点快照只能是数组。', + }, + { + type: 'check-constraint', + name: 'PlanningDraft_summary_array', + relationName: 'PlanningDraft', + expressionPattern: 'summary', + purpose: '规划草稿摘要快照只能是数组。', + }, + { + type: 'check-constraint', + name: 'PlanningDraft_warnings_array', + relationName: 'PlanningDraft', + expressionPattern: 'warnings', + purpose: '规划草稿生成警告只能是数组或空值。', + }, + { + type: 'check-constraint', + name: 'PlanningDraft_sourceSnapshot_object', + relationName: 'PlanningDraft', + expressionPattern: 'sourceSnapshot', + purpose: '规划草稿来源快照只能是对象或空值,用于追溯而不是主数据真相。', + }, + { + type: 'check-constraint', + name: 'PlanningDraft_optional_text_not_blank', + relationName: 'PlanningDraft', + expressionPattern: 'notes', + purpose: '规划草稿可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'Substrate_code_normalized', + relationName: 'Substrate', + expressionPattern: 'upper(btrim', + purpose: '基材编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'WarehouseLocation_code_normalized', + relationName: 'WarehouseLocation', + expressionPattern: 'upper(btrim', + purpose: '库位编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PackagedItem_code_normalized', + relationName: 'PackagedItem', + expressionPattern: 'upper(btrim', + purpose: '包装成品编号必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PreSkuPackagingPlan_status_allowed', + relationName: 'PreSkuPackagingPlan', + expressionPattern: 'CONFIRMED', + purpose: '准 SKU 包装方案状态只能来自固定生命周期,不能用自由字符串变成第二套流程。', + }, + { + type: 'check-constraint', + name: 'PreSkuPackagingPlan_boxes_array', + relationName: 'PreSkuPackagingPlan', + expressionPattern: 'jsonb_typeof', + purpose: '准 SKU 包装箱规必须保持数组结构,不能用对象或文本污染结构化 JSON。', + }, + { + type: 'check-constraint', + name: 'PreSkuPackagingPlan_costSummary_object', + relationName: 'PreSkuPackagingPlan', + expressionPattern: 'costSummary', + purpose: '准 SKU 包装成本摘要只能是对象或空值,避免正式入库读取到非结构化成本。', + }, + { + type: 'check-constraint', + name: 'PreSkuPackagingPlan_processDraft_object', + relationName: 'PreSkuPackagingPlan', + expressionPattern: 'processDraft', + purpose: '准 SKU 包装过程草稿只能是对象或空值,并明确不作为正式主数据。', + }, + { + type: 'check-constraint', + name: 'PreSkuPackagingPlan_optional_text_not_blank', + relationName: 'PreSkuPackagingPlan', + expressionPattern: 'sourceRefId', + purpose: '准 SKU 包装方案可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'WarehouseLocation_name_not_blank', + relationName: 'WarehouseLocation', + expressionPattern: 'name', + purpose: '库位名称必须非空,库位编码不能指向无语义的空名称。', + }, + { + type: 'check-constraint', + name: 'WarehouseLocation_optional_text_not_blank', + relationName: 'WarehouseLocation', + expressionPattern: 'zone', + purpose: '库位区/巷道/货架/箱位等可缺字段必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'BrandMaterialBinding_materialType_allowed', + relationName: 'BrandMaterialBinding', + expressionPattern: 'CARTON_PRINT', + purpose: '品牌物料类型必须来自固定枚举,避免轻仓库资料准备项变成自由文本。', + }, + { + type: 'check-constraint', + name: 'BrandMaterialBinding_identity_not_blank', + relationName: 'BrandMaterialBinding', + expressionPattern: 'materialType', + purpose: '品牌物料准备项必须有品牌和物料类型身份,不能保存空身份行。', + }, + { + type: 'check-constraint', + name: 'BrandMaterialBinding_file_metadata_consistent', + relationName: 'BrandMaterialBinding', + expressionPattern: 'fileSize', + purpose: '品牌物料文件元数据必须成套出现,不能只保存孤立 URL、文件名或大小。', + }, + { + type: 'check-constraint', + name: 'PackagedItem_status_allowed', + relationName: 'PackagedItem', + expressionPattern: 'ACTIVE', + purpose: '包装成品状态必须来自固定生命周期,轻仓库不能出现不可解释状态。', + }, + { + type: 'check-constraint', + name: 'PackagedItem_name_not_blank', + relationName: 'PackagedItem', + expressionPattern: 'name', + purpose: '包装成品名称必须非空,库存项不能只有编号没有名称。', + }, + { + type: 'check-constraint', + name: 'PackagedItem_quantities_valid', + relationName: 'PackagedItem', + expressionPattern: 'stockQuantity', + purpose: '包装成品装箱数量必须大于 0,库存数量不能为负数。', + }, + { + type: 'check-constraint', + name: 'PackagedItem_dimensions_valid', + relationName: 'PackagedItem', + expressionPattern: 'weight', + purpose: '包装成品尺寸必须为正数,重量不能为负数。', + }, + { + type: 'check-constraint', + name: 'PackagedItem_payload_object', + relationName: 'PackagedItem', + expressionPattern: 'jsonb_typeof', + purpose: '包装成品扩展载荷只能是对象或空值,避免过程快照变成任意 JSON 形态。', + }, + { + type: 'check-constraint', + name: 'Material_code_normalized', + relationName: 'Material', + expressionPattern: 'upper(btrim', + purpose: '材质花色编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'Hardware_code_normalized', + relationName: 'Hardware', + expressionPattern: 'upper(btrim', + purpose: '标准五金编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'CommonComponent_code_normalized', + relationName: 'CommonComponent', + expressionPattern: 'upper(btrim', + purpose: '通用组件编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'IntakeNonIntegratedProduct_spu_brand_normalized', + relationName: 'IntakeNonIntegratedProduct', + expressionPattern: 'upper(btrim', + purpose: '非集成开品 SPU、SPU 基础号和品牌作用域必须由数据库兜底为规范化身份。', + }, + { + type: 'check-constraint', + name: 'Product_modelNumber_normalized', + relationName: 'Product', + expressionPattern: 'upper(btrim', + purpose: '正式产品型号必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'Product_name_not_blank', + relationName: 'Product', + expressionPattern: 'name', + purpose: '正式产品名称必须非空且无首尾空格,上传链路不能写入无语义产品头。', + }, + { + type: 'check-constraint', + name: 'Product_visibility_allowed', + relationName: 'Product', + expressionPattern: 'DEPARTMENT', + purpose: '正式产品可见范围只能来自固定枚举,不能成为游离权限状态。', + }, + { + type: 'check-constraint', + name: 'Product_optional_text_not_blank', + relationName: 'Product', + expressionPattern: 'description', + purpose: '正式产品可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'ProductCollaborator_identity_not_blank', + relationName: 'ProductCollaborator', + expressionPattern: 'productId', + purpose: '产品协作者关系必须绑定有效产品和用户身份,不能保存空关系行。', + }, + { + type: 'check-constraint', + name: 'ProductCollaborator_role_allowed', + relationName: 'ProductCollaborator', + expressionPattern: 'viewer', + purpose: '协作者角色只能是 editor/viewer,权限关系不能使用自由字符串。', + }, + { + type: 'check-constraint', + name: 'ProductCollaborator_addedBy_not_blank', + relationName: 'ProductCollaborator', + expressionPattern: 'addedBy', + purpose: '协作者添加人可缺时必须使用 NULL,不能用空字符串表达缺失。', + }, + { + type: 'check-constraint', + name: 'ShareLink_token_not_blank', + relationName: 'ShareLink', + expressionPattern: 'token', + purpose: '分享链接 token 必须非空且无首尾空格,公开访问入口不能出现空身份。', + }, + { + type: 'check-constraint', + name: 'ShareLink_viewCount_non_negative', + relationName: 'ShareLink', + expressionPattern: 'viewCount', + purpose: '分享链接访问次数不能为负,访问统计只能单向累加。', + }, + { + type: 'check-constraint', + name: 'ShareLink_expiresAt_after_createdAt', + relationName: 'ShareLink', + expressionPattern: 'expiresAt', + purpose: '分享链接过期时间必须晚于创建时间,避免产生创建即异常的公开入口。', + }, + { + type: 'check-constraint', + name: 'Panel_identity_dimensions_valid', + relationName: 'Panel', + expressionPattern: 'gltfNodeName', + purpose: '旧 CAD 板件必须有规范编号、名称、节点名和正数尺寸,不能把无效几何作为板件真相。', + }, + { + type: 'check-constraint', + name: 'Panel_explosionDirection_allowed', + relationName: 'Panel', + expressionPattern: 'explosionDirection', + purpose: '板件爆炸方向只能使用固定枚举,避免装配动画写入自由文本。', + }, + { + type: 'check-constraint', + name: 'Panel_assemblySemantic_object', + relationName: 'Panel', + expressionPattern: 'assemblySemantic', + purpose: '板件装配语义 JSON 只允许对象快照,不能成为无形态约束的第二套结构。', + }, + { + type: 'check-constraint', + name: 'Panel_optional_text_not_blank', + relationName: 'Panel', + expressionPattern: 'partType', + purpose: '板件可缺文本必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'Hole_geometry_quality', + relationName: 'Hole', + expressionPattern: 'normalX', + purpose: '孔位坐标、直径、深度、法线和置信度必须有效,吸附和孔位高亮不能依赖脏几何。', + }, + { + type: 'check-constraint', + name: 'Hole_type_allowed', + relationName: 'Hole', + expressionPattern: 'shelf_pin', + purpose: '孔位类型只能使用固定枚举,孔位匹配不能被自由字符串污染。', + }, + { + type: 'check-constraint', + name: 'Hole_optional_text_not_blank', + relationName: 'Hole', + expressionPattern: 'groupId', + purpose: '孔组可缺时必须使用 NULL,不能用空字符串表达缺失。', + }, + { + type: 'check-constraint', + name: 'ProductHardware_match_status_allowed', + relationName: 'ProductHardware', + expressionPattern: 'unmatched', + purpose: '产品五金匹配状态只能来自固定枚举,BOM 和装配匹配不能出现游离状态。', + }, + { + type: 'check-constraint', + name: 'ProductHardware_match_confidence_valid', + relationName: 'ProductHardware', + expressionPattern: 'matchConfidence', + purpose: '产品五金匹配置信度必须在 0 到 1 之间。', + }, + { + type: 'check-constraint', + name: 'ProductHardware_match_method_allowed', + relationName: 'ProductHardware', + expressionPattern: 'blockName', + purpose: '产品五金匹配方式只能来自固定枚举,不能把导入来源写成自由文本。', + }, + { + type: 'check-constraint', + name: 'ProductHardware_quantities_geometry_valid', + relationName: 'ProductHardware', + expressionPattern: 'holeDistance', + purpose: '产品五金安装序号和孔距不能为负,装配排序和关联质量必须可解释。', + }, + { + type: 'check-constraint', + name: 'ProductHardware_transform_object', + relationName: 'ProductHardware', + expressionPattern: 'transform', + purpose: '产品五金位姿 JSON 必须是对象,不能存入不可解释快照。', + }, + { + type: 'check-constraint', + name: 'ProductHardware_sourceSignature_object', + relationName: 'ProductHardware', + expressionPattern: 'sourceSignature', + purpose: '产品五金 CAD 源签名只允许对象快照。', + }, + { + type: 'check-constraint', + name: 'ProductHardware_optional_text_not_blank', + relationName: 'ProductHardware', + expressionPattern: 'sourceBlockName', + purpose: '产品五金可缺文本必须使用 NULL,不能用空字符串表达缺失。', + }, + { + type: 'check-constraint', + name: 'AssemblyStep_identity_order_valid', + relationName: 'AssemblyStep', + expressionPattern: 'order', + purpose: '装配步骤必须有非负顺序和非空标题,安装流程不能出现无身份步骤。', + }, + { + type: 'check-constraint', + name: 'AssemblyStep_type_allowed', + relationName: 'AssemblyStep', + expressionPattern: 'hardware', + purpose: '装配步骤类型只能是板件、五金或自定义,不能用自由文本驱动流程。', + }, + { + type: 'check-constraint', + name: 'AssemblyStep_animation_valid', + relationName: 'AssemblyStep', + expressionPattern: 'cameraTransition', + purpose: '装配步骤动画时长、镜头切换和关联 ID 数组必须有效。', + }, + { + type: 'check-constraint', + name: 'AssemblyStep_transform_object', + relationName: 'AssemblyStep', + expressionPattern: 'transform', + purpose: '装配步骤目标变换 JSON 必须是对象。', + }, + { + type: 'check-constraint', + name: 'AssemblyStep_cameraPosition_object', + relationName: 'AssemblyStep', + expressionPattern: 'cameraPosition', + purpose: '装配步骤镜头位置 JSON 只允许对象快照。', + }, + { + type: 'check-constraint', + name: 'AssemblyStep_infoCardData_object', + relationName: 'AssemblyStep', + expressionPattern: 'infoCardData', + purpose: '装配步骤信息卡 JSON 只允许对象快照。', + }, + { + type: 'check-constraint', + name: 'AssemblyStep_optional_text_not_blank', + relationName: 'AssemblyStep', + expressionPattern: 'description', + purpose: '装配步骤可缺文本必须使用 NULL,不能用空字符串表达缺失。', + }, + { + type: 'check-constraint', + name: 'AssemblyVersion_snapshot_array', + relationName: 'AssemblyVersion', + expressionPattern: 'snapshot', + purpose: '装配版本快照必须是步骤数组,版本历史不能保存任意 JSON 形态。', + }, + { + type: 'check-constraint', + name: 'AssemblyVersion_version_positive', + relationName: 'AssemblyVersion', + expressionPattern: 'version', + purpose: '装配版本号必须为正数,版本历史不能倒退到无效编号。', + }, + { + type: 'check-constraint', + name: 'AssemblyVersion_optional_text_not_blank', + relationName: 'AssemblyVersion', + expressionPattern: 'createdBy', + purpose: '装配版本可缺文本必须使用 NULL,不能用空字符串表达缺失。', + }, + { + type: 'check-constraint', + name: 'ProductSKU_skuCode_normalized', + relationName: 'ProductSKU', + expressionPattern: 'upper(btrim', + purpose: '活动正式 SKU 编码必须由数据库兜底为大写且无首尾空格,删除态墓碑除外。', + }, + { + type: 'check-constraint', + name: 'ProductSKU_active_skuCode_no_deleted_marker_ci', + relationName: 'ProductSKU', + expressionPattern: 'lower', + purpose: '活动正式 SKU 编码不能用大小写变体绕过软删除墓碑标记检查。', + }, + { + type: 'check-constraint', + name: 'AssetVariant_code_normalized', + relationName: 'AssetVariant', + expressionPattern: 'upper(btrim', + purpose: '资产规格款编码必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'AssetVariant_barcode_normalized', + relationName: 'AssetVariant', + expressionPattern: 'upper(btrim', + purpose: '资产规格款条码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PreSkuBasicInfo_formalSkuCode_normalized', + relationName: 'PreSkuBasicInfo', + expressionPattern: 'upper(btrim', + purpose: '准 SKU 正式规格码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PreSkuBasicInfo_barcode_normalized', + relationName: 'PreSkuBasicInfo', + expressionPattern: 'upper(btrim', + purpose: '准 SKU 条码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PreSkuBasicInfo_model_normalized', + relationName: 'PreSkuBasicInfo', + expressionPattern: 'upper(btrim', + purpose: '准 SKU 型号非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PreSkuBasicInfo_versionNo_normalized', + relationName: 'PreSkuBasicInfo', + expressionPattern: 'upper(btrim', + purpose: '准 SKU 版本号非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PreSkuBasicInfo_attributeCode_normalized', + relationName: 'PreSkuBasicInfo', + expressionPattern: 'upper(btrim', + purpose: '准 SKU 属性码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PreSkuBasicInfo_drawingId_normalized', + relationName: 'PreSkuBasicInfo', + expressionPattern: 'upper(btrim', + purpose: '准 SKU 图纸号非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'PreSkuBasicInfo_source_allowed', + relationName: 'PreSkuBasicInfo', + expressionPattern: 'DRAFT_GROUP', + purpose: '准 SKU 基础资料来源只能是系统承接或人工维护,避免随意字符串变成第二套状态。', + }, + { + type: 'check-constraint', + name: 'PreSkuBasicInfo_optional_text_not_blank', + relationName: 'PreSkuBasicInfo', + expressionPattern: 'formalSkuCode', + purpose: '准 SKU 基础资料可缺失但不能用空字符串表示缺失,避免 NULL 和空值形成两套缺失状态。', + }, + { + type: 'check-constraint', + name: 'AssetSkuSyncEvent_reason_allowed', + relationName: 'AssetSkuSyncEvent', + expressionPattern: 'VARIANT_UPDATED', + purpose: '资产 SKU 同步事件原因必须来自固定枚举,不能让自由字符串变成不可调度任务。', + }, + { + type: 'check-constraint', + name: 'AssetSkuSyncEvent_counts_non_negative', + relationName: 'AssetSkuSyncEvent', + expressionPattern: 'retryCount', + purpose: '资产 SKU 同步事件计数器必须非负,数据库层阻断异常进度。', + }, + { + type: 'check-constraint', + name: 'AssetSkuSyncEvent_synced_not_over_affected', + relationName: 'AssetSkuSyncEvent', + expressionPattern: 'syncedCount', + purpose: '资产 SKU 同步事件已同步数量不能超过受影响数量。', + }, + { + type: 'check-constraint', + name: 'AssetSkuSyncEvent_completed_has_processed_at', + relationName: 'AssetSkuSyncEvent', + expressionPattern: 'processedAt', + purpose: '资产 SKU 同步事件完成后必须记录处理时间,便于追溯资料同步状态。', + }, + { + type: 'check-constraint', + name: 'ProductSkuAssetProfile_barcode_normalized', + relationName: 'ProductSkuAssetProfile', + expressionPattern: 'upper(btrim', + purpose: '正式 SKU 资产资料副本条码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'ProductSkuAssetProfile_model_normalized', + relationName: 'ProductSkuAssetProfile', + expressionPattern: 'upper(btrim', + purpose: '正式 SKU 资产资料副本型号非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'ProductSkuAssetProfile_specCode_normalized', + relationName: 'ProductSkuAssetProfile', + expressionPattern: 'upper(btrim', + purpose: '正式 SKU 资产资料副本规格码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'ProductSkuAssetProfile_primary_source_required', + relationName: 'ProductSkuAssetProfile', + expressionPattern: 'primarySourceAssetVariantId', + purpose: '正式 SKU 资产资料副本必须有主来源规格款,不能产生来源不明的对外资料。', + }, + { + type: 'check-constraint', + name: 'ProductSkuAssetProfile_source_variant_ids_required', + relationName: 'ProductSkuAssetProfile', + expressionPattern: 'sourceAssetVariantIds', + purpose: '正式 SKU 资产资料副本必须保留来源规格款集合,不能用空数组表达来源。', + }, + { + type: 'check-constraint', + name: 'ProductSkuAssetProfile_primary_source_in_source_ids', + relationName: 'ProductSkuAssetProfile', + expressionPattern: 'primarySourceAssetVariantId', + purpose: '正式 SKU 资产资料副本的主来源必须包含在来源规格款集合内。', + }, + { + type: 'check-constraint', + name: 'ProductSkuAssetProfile_optional_text_not_blank', + relationName: 'ProductSkuAssetProfile', + expressionPattern: 'specCode', + purpose: '正式 SKU 资产资料副本可缺字段必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'ProductPatentRecord_patentGroupKey_normalized', + relationName: 'ProductPatentRecord', + expressionPattern: 'upper(btrim', + purpose: '专利台账分组键必须非空、大写且无首尾空格,避免浏览器缓存旧值或手工 SQL 绕过。', + }, + { + type: 'check-constraint', + name: 'ProductPatentRecord_productId_required', + relationName: 'ProductPatentRecord', + expressionPattern: 'btrim', + purpose: '专利台账必须保留来源产品 id,不能产生无法追溯的孤儿专利记录。', + }, + { + type: 'check-constraint', + name: 'ProductPatentRecord_applicationNo_normalized', + relationName: 'ProductPatentRecord', + expressionPattern: 'upper(btrim', + purpose: '专利申请号非空时必须数据库级规范化,避免大小写和空格造成重复身份。', + }, + { + type: 'check-constraint', + name: 'ProductPatentRecord_patentNo_normalized', + relationName: 'ProductPatentRecord', + expressionPattern: 'upper(btrim', + purpose: '专利号非空时必须数据库级规范化,避免大小写和空格造成重复身份。', + }, + { + type: 'check-constraint', + name: 'ProductPatentRecord_spu_normalized', + relationName: 'ProductPatentRecord', + expressionPattern: 'upper(btrim', + purpose: '专利台账展示 SPU 非空时必须规范化,避免对账和外部只读消费出现同号不同写法。', + }, + { + type: 'check-constraint', + name: 'ProductPatentRecord_patent_identity_required', + relationName: 'ProductPatentRecord', + expressionPattern: 'patentName', + purpose: '专利台账至少要有专利名称、申请号或专利号之一,不能保存空身份记录。', + }, + { + type: 'check-constraint', + name: 'IntakeNonIntegratedSkuItem_skuCode_normalized', + relationName: 'IntakeNonIntegratedSkuItem', + expressionPattern: 'upper(btrim', + purpose: '非集成 SKU 编码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'IntakeNonIntegratedSkuItem_barcode_normalized', + relationName: 'IntakeNonIntegratedSkuItem', + expressionPattern: 'upper(btrim', + purpose: '非集成条码非空时必须由数据库兜底为大写且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_approvalNumber_normalized', + relationName: 'ApprovalWorkflow', + expressionPattern: 'upper(btrim', + purpose: '审批编号必须由数据库兜底为大写且无首尾空格,避免同一流程出现多种写法。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_title_not_blank', + relationName: 'ApprovalWorkflow', + expressionPattern: 'title', + purpose: '审批流程标题必须非空,流程记录不能只有编号没有业务语义。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_productId_not_blank', + relationName: 'ApprovalWorkflow', + expressionPattern: 'productId', + purpose: '审批流程必须绑定产品资料来源,不能产生无来源的流程记录。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_accessKey_not_blank', + relationName: 'ApprovalWorkflow', + expressionPattern: 'accessKey', + purpose: '审批流程访问密钥必须非空,链接入口不能产生空身份。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_participant_user_ids_valid', + relationName: 'ApprovalWorkflow', + expressionPattern: 'text_array_distinct_non_blank', + purpose: '审批流程参与人列表不能包含空值或重复值,避免权限范围失真。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_business_roles_distinct', + relationName: 'ApprovalWorkflow', + expressionPattern: 'array_positions', + purpose: '审批流程业务角色列表不能重复,避免责任矩阵和权限判断出现重复语义。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_fieldMatrix_array', + relationName: 'ApprovalWorkflow', + expressionPattern: 'jsonb_typeof', + purpose: '审批字段矩阵只能是数组或空值,防止过程配置 JSON 形态漂移。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_summary_object', + relationName: 'ApprovalWorkflow', + expressionPattern: 'jsonb_typeof', + purpose: '审批摘要只能是对象或空值,正式产品资料承接时不能读到非对象快照。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflow_metadata_object', + relationName: 'ApprovalWorkflow', + expressionPattern: 'jsonb_typeof', + purpose: '审批流程扩展元数据只能是对象或空值,避免无边界 JSON 成为第二套真相。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflowFieldChange_fieldKey_not_blank', + relationName: 'ApprovalWorkflowFieldChange', + expressionPattern: 'fieldKey', + purpose: '审批字段变更必须记录非空字段 key,审计日志不能失去字段指向。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflowFieldChange_source_not_blank', + relationName: 'ApprovalWorkflowFieldChange', + expressionPattern: 'source', + purpose: '审批字段变更必须记录非空来源,后续追溯不能只看到值变化。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflowFieldChange_optional_text_not_blank', + relationName: 'ApprovalWorkflowFieldChange', + expressionPattern: 'sourcePage', + purpose: '审批字段变更可缺来源页面必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'ApprovalWorkflowFieldChange_metadata_object', + relationName: 'ApprovalWorkflowFieldChange', + expressionPattern: 'jsonb_typeof', + purpose: '审批字段变更元数据只能是对象或空值,审计载荷保持可解释。', + }, + { + type: 'check-constraint', + name: 'User_email_normalized', + relationName: 'User', + expressionPattern: 'lower(btrim', + purpose: '用户邮箱必须小写且无首尾空格,登录身份和唯一池保持同一语义。', + }, + { + type: 'check-constraint', + name: 'User_name_not_blank', + relationName: 'User', + expressionPattern: 'btrim', + purpose: '用户花名必须非空且无首尾空格,权限与协作记录不能指向空身份。', + }, + { + type: 'check-constraint', + name: 'User_loginSource_not_blank', + relationName: 'User', + expressionPattern: 'loginSource', + purpose: '用户登录来源必须非空,账号来源不能出现第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'User_business_roles_distinct', + relationName: 'User', + expressionPattern: 'array_positions', + purpose: '用户业务类型不能重复,权限判断和业务归属不能出现重复语义。', + }, + { + type: 'check-constraint', + name: 'User_optional_text_not_blank', + relationName: 'User', + expressionPattern: 'dingtalkUserId', + purpose: '用户可缺身份字段必须使用 NULL,不能用空字符串制造第二套缺失状态。', + }, + { + type: 'check-constraint', + name: 'Department_identity_not_blank', + relationName: 'Department', + expressionPattern: 'fullPath', + purpose: '部门名称和完整路径必须非空且无首尾空格,组织树不能出现空身份节点。', + }, + { + type: 'check-constraint', + name: 'Department_optional_text_not_blank', + relationName: 'Department', + expressionPattern: 'externalId', + purpose: '部门外部 ID 和描述可缺时必须使用 NULL,不能用空字符串污染同步身份。', + }, + { + type: 'check-constraint', + name: 'Department_orderIndex_non_negative', + relationName: 'Department', + expressionPattern: 'orderIndex', + purpose: '部门排序值不能为负数,组织树排序保持可解释。', + }, + { + type: 'check-constraint', + name: 'Team_identity_not_blank', + relationName: 'Team', + expressionPattern: 'orderIndex', + purpose: '小组名称必须非空且排序值非负,组织小组不能保存空身份。', + }, + { + type: 'check-constraint', + name: 'EmployeeDirectory_name_not_blank', + relationName: 'EmployeeDirectory', + expressionPattern: 'btrim', + purpose: '员工目录姓名必须非空且无首尾空格,花名册不能出现空员工身份。', + }, + { + type: 'check-constraint', + name: 'EmployeeDirectory_optional_text_not_blank', + relationName: 'EmployeeDirectory', + expressionPattern: 'primaryDepartmentExternalId', + purpose: '员工目录可缺身份字段必须使用 NULL,不能用空字符串影响匹配。', + }, + { + type: 'check-constraint', + name: 'EmployeeDirectory_email_normalized', + relationName: 'EmployeeDirectory', + expressionPattern: 'lower(btrim', + purpose: '员工目录邮箱非空时必须小写且无首尾空格,外部资料匹配口径稳定。', + }, + { + type: 'check-constraint', + name: 'EmployeeDirectory_departmentExternalIds_array', + relationName: 'EmployeeDirectory', + expressionPattern: 'jsonb_typeof', + purpose: '员工外部部门 ID 集合必须是数组或空值,不能让自由 JSON 影响主部门选择。', + }, + { + type: 'check-constraint', + name: 'EmployeeDirectory_rawSnapshot_object', + relationName: 'EmployeeDirectory', + expressionPattern: 'jsonb_typeof', + purpose: '员工目录原始快照只能是对象或空值,集成缓存保持可解释。', + }, + { + type: 'check-constraint', + name: 'EmployeeLookupCache_identity_not_blank', + relationName: 'EmployeeLookupCache', + expressionPattern: 'lookupValue', + purpose: '员工查找缓存键必须非空,缓存不能产生空身份项。', + }, + { + type: 'check-constraint', + name: 'EmployeeLookupCache_result_object', + relationName: 'EmployeeLookupCache', + expressionPattern: 'jsonb_typeof', + purpose: '员工查找缓存结果只能是对象或空值,避免缓存载荷形态漂移。', + }, + { + type: 'unique-index', + name: 'ProductCategory_name_normalized_key', + relationName: 'ProductCategory', + definitionPattern: 'lower(btrim', + purpose: '产品分类名称按去空格小写后的语义唯一,不能用大小写或空格制造重复分类。', + }, + { + type: 'check-constraint', + name: 'ProductCategory_identity_valid', + relationName: 'ProductCategory', + expressionPattern: 'sortOrder', + purpose: '产品分类名称和排序是基础资料真相,必须非空、去空格且排序非负。', + }, + { + type: 'check-constraint', + name: 'ProductCategory_optional_text_not_blank', + relationName: 'ProductCategory', + expressionPattern: 'description', + purpose: '产品分类可选说明和图标不能保存空字符串。', + }, + { + type: 'unique-index', + name: 'MaterialCategory_name_normalized_key', + relationName: 'MaterialCategory', + definitionPattern: 'lower(btrim', + purpose: '材质分类名称按规范化语义唯一,花色资料不能分叉到重复分类。', + }, + { + type: 'check-constraint', + name: 'MaterialCategory_identity_valid', + relationName: 'MaterialCategory', + expressionPattern: 'sortOrder', + purpose: '材质分类名称和排序必须由数据库兜底保证有效。', + }, + { + type: 'check-constraint', + name: 'MaterialCategory_optional_text_not_blank', + relationName: 'MaterialCategory', + expressionPattern: 'icon', + purpose: '材质分类图标不能保存空字符串。', + }, + { + type: 'unique-index', + name: 'HardwareCategory_name_normalized_key', + relationName: 'HardwareCategory', + definitionPattern: 'lower(btrim', + purpose: '五金分类名称按规范化语义唯一,自动匹配和五金库不能出现重复分类。', + }, + { + type: 'check-constraint', + name: 'HardwareCategory_identity_valid', + relationName: 'HardwareCategory', + expressionPattern: 'sortOrder', + purpose: '五金分类名称和排序必须有效。', + }, + { + type: 'check-constraint', + name: 'HardwareCategory_optional_text_not_blank', + relationName: 'HardwareCategory', + expressionPattern: 'description', + purpose: '五金分类说明和图标不能保存空字符串。', + }, + { + type: 'unique-index', + name: 'CommonComponentCategory_name_normalized_key', + relationName: 'CommonComponentCategory', + definitionPattern: 'lower(btrim', + purpose: '通用组件分类名称按规范化语义唯一。', + }, + { + type: 'check-constraint', + name: 'CommonComponentCategory_identity_valid', + relationName: 'CommonComponentCategory', + expressionPattern: 'sortOrder', + purpose: '通用组件分类名称和排序必须有效。', + }, + { + type: 'check-constraint', + name: 'CommonComponentCategory_optional_text_not_blank', + relationName: 'CommonComponentCategory', + expressionPattern: 'description', + purpose: '通用组件分类说明和图标不能保存空字符串。', + }, + { + type: 'unique-index', + name: 'SubstrateCategory_name_normalized_key', + relationName: 'SubstrateCategory', + definitionPattern: 'lower(btrim', + purpose: '基材主类名称按规范化语义唯一。', + }, + { + type: 'check-constraint', + name: 'SubstrateCategory_identity_valid', + relationName: 'SubstrateCategory', + expressionPattern: 'sortOrder', + purpose: '基材主类名称和排序必须有效。', + }, + { + type: 'unique-index', + name: 'SubstrateEnvironmentGrade_name_normalized_key', + relationName: 'SubstrateEnvironmentGrade', + definitionPattern: 'lower(btrim', + purpose: '环保等级名称按规范化语义唯一。', + }, + { + type: 'check-constraint', + name: 'SubstrateEnvironmentGrade_identity_valid', + relationName: 'SubstrateEnvironmentGrade', + expressionPattern: 'sortOrder', + purpose: '环保等级名称和排序必须有效。', + }, + { + type: 'unique-index', + name: 'SubstrateSurfaceOption_name_normalized_key', + relationName: 'SubstrateSurfaceOption', + definitionPattern: 'lower(btrim', + purpose: '贴面类型名称按规范化语义唯一。', + }, + { + type: 'check-constraint', + name: 'SubstrateSurfaceOption_identity_valid', + relationName: 'SubstrateSurfaceOption', + expressionPattern: 'sortOrder', + purpose: '贴面类型名称和排序必须有效。', + }, + { + type: 'unique-index', + name: 'SubstrateSystemNameOption_name_normalized_key', + relationName: 'SubstrateSystemNameOption', + definitionPattern: 'lower(btrim', + purpose: '系统名称选项按规范化语义唯一。', + }, + { + type: 'check-constraint', + name: 'SubstrateSystemNameOption_identity_valid', + relationName: 'SubstrateSystemNameOption', + expressionPattern: 'sortOrder', + purpose: '系统名称选项名称和排序必须有效。', + }, + { + type: 'unique-index', + name: 'SubstrateThicknessSpec_name_normalized_key', + relationName: 'SubstrateThicknessSpec', + definitionPattern: 'lower(btrim', + purpose: '厚度规格名称按规范化语义唯一。', + }, + { + type: 'check-constraint', + name: 'SubstrateThicknessSpec_identity_dimensions_valid', + relationName: 'SubstrateThicknessSpec', + expressionPattern: 'thicknessMm', + purpose: '厚度规格名称、排序、厚度和板材尺寸必须有效。', + }, + { + type: 'check-constraint', + name: 'HardwareMapping_match_key_exactly_one', + relationName: 'HardwareMapping', + expressionPattern: 'signatureHash', + purpose: '五金匹配记忆每行只能表达一个匹配键,避免图块名和尺寸哈希混成一条模糊真相。', + }, + { + type: 'check-constraint', + name: 'HardwareMapping_usage_source_valid', + relationName: 'HardwareMapping', + expressionPattern: 'usageCount', + purpose: '五金匹配记忆使用次数和来源必须有效。', + }, + { + type: 'check-constraint', + name: 'SubstrateFinish_relation_ids_not_blank', + relationName: 'SubstrateFinish', + expressionPattern: 'substrateId', + purpose: '基材和花色绑定关系不能保存空引用。', + }, + { + type: 'check-constraint', + name: 'PanelMaterial_relation_ids_not_blank', + relationName: 'PanelMaterial', + expressionPattern: 'materialId', + purpose: '旧 CAD 板件材质绑定不能保存空引用。', + }, + { + type: 'check-constraint', + name: 'AssetVariantLocationBinding_relation_ids_not_blank', + relationName: 'AssetVariantLocationBinding', + expressionPattern: 'warehouseLocationId', + purpose: '规格款库位绑定不能保存空引用。', + }, + { + type: 'check-constraint', + name: 'PreSkuLocationBinding_relation_ids_not_blank', + relationName: 'PreSkuLocationBinding', + expressionPattern: 'warehouseLocationId', + purpose: '准 SKU 库位绑定不能保存空引用。', + }, + { + type: 'check-constraint', + name: 'PackagingPlan_identity_counts_valid', + relationName: 'PackagingPlan', + expressionPattern: 'boxCount', + purpose: '旧正式产品包装方案名称和箱数必须有效。', + }, + { + type: 'check-constraint', + name: 'PackagingPlan_boxes_array', + relationName: 'PackagingPlan', + expressionPattern: 'jsonb_typeof', + purpose: '旧正式产品包装方案 boxes 只能是数组快照。', + }, + { + type: 'check-constraint', + name: 'PackagingPlan_totals_valid', + relationName: 'PackagingPlan', + expressionPattern: 'totalWeight', + purpose: '旧正式产品包装方案重量和体积不能为负数。', + }, + { + type: 'check-constraint', + name: 'PackagingPlan_optional_text_not_blank', + relationName: 'PackagingPlan', + expressionPattern: 'description', + purpose: '旧正式产品包装方案可选文本不能保存空字符串。', + }, + { + type: 'check-constraint', + name: 'SystemConfig_key_valid', + relationName: 'SystemConfig', + expressionPattern: 'btrim', + purpose: '系统配置 key 是配置真相身份,必须非空且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'SystemConfig_description_not_blank', + relationName: 'SystemConfig', + expressionPattern: 'description', + purpose: '系统配置说明不能保存空字符串。', + }, + { + type: 'check-constraint', + name: 'ProviderConfig_identity_valid', + relationName: 'ProviderConfig', + expressionPattern: 'serviceType', + purpose: 'AI 服务商配置的服务类型、名称、地址、密钥和排序必须有结构化物理护栏。', + }, + { + type: 'check-constraint', + name: 'ProviderConfig_optional_fields_valid', + relationName: 'ProviderConfig', + expressionPattern: 'jsonb_typeof', + purpose: 'AI 服务商配置扩展字段必须是对象,模型名不能保存空字符串。', + }, + { + type: 'partial-unique-index', + name: 'ProviderConfig_active_service_key', + relationName: 'ProviderConfig', + predicatePattern: '"isActive" = true', + purpose: '同一个服务类型只能有一份激活配置,避免运行时读取到不确定配置。', + }, + { + type: 'check-constraint', + name: 'ThemeConfig_identity_valid', + relationName: 'ThemeConfig', + expressionPattern: 'createdBy', + purpose: '主题配置名称和创建来源必须非空且无首尾空格。', + }, + { + type: 'check-constraint', + name: 'ThemeConfig_optional_config_valid', + relationName: 'ThemeConfig', + expressionPattern: 'homeBackground', + purpose: '主题配置时间窗口和 JSON 配置形态必须可靠。', + }, + { + type: 'unique-index', + name: 'ThemeConfig_name_normalized_key', + relationName: 'ThemeConfig', + definitionPattern: 'lower(btrim', + purpose: '主题配置名称按归一化语义唯一,避免同名主题重复。', + }, + { + type: 'partial-unique-index', + name: 'ThemeConfig_default_key', + relationName: 'ThemeConfig', + predicatePattern: '"isDefault" = true', + purpose: '系统默认主题只能有一份,避免 fallback 不确定。', + }, + { + type: 'check-constraint', + name: 'IntakeNodePermissionOverride_identity_valid', + relationName: 'IntakeNodePermissionOverride', + expressionPattern: 'targetKey', + purpose: '产品资料节点权限覆盖的目标、主体和动作必须是非空结构化身份。', + }, + { + type: 'check-constraint', + name: 'IntakeNodePermissionOverride_audit_text_not_blank', + relationName: 'IntakeNodePermissionOverride', + expressionPattern: 'createdBy', + purpose: '产品资料节点权限覆盖的审计字段不能保存空字符串。', + }, + { + type: 'check-constraint', + name: 'AssistedAssemblyAiRender_identity_valid', + relationName: 'AssistedAssemblyAiRender', + expressionPattern: 'prompt', + purpose: '辅助装配 AI 生图记录必须有用户和提示词身份。', + }, + { + type: 'check-constraint', + name: 'AssistedAssemblyAiRender_json_shape_valid', + relationName: 'AssistedAssemblyAiRender', + expressionPattern: 'workspaceSnapshot', + purpose: '辅助装配 AI 生图参数、结果和快照 JSON 形态必须稳定。', + }, + { + type: 'check-constraint', + name: 'AssistedAssemblyAiRender_status_duration_valid', + relationName: 'AssistedAssemblyAiRender', + expressionPattern: 'durationMs', + purpose: '辅助装配 AI 生图状态、结果和耗时不能互相矛盾。', + }, + { + type: 'check-constraint', + name: 'AssistedAssemblyAiRender_optional_text_not_blank', + relationName: 'AssistedAssemblyAiRender', + expressionPattern: 'negativePrompt', + purpose: '辅助装配 AI 生图可选文本不能保存空字符串。', + }, + { + type: 'check-constraint', + name: 'Feedback_identity_valid', + relationName: 'Feedback', + expressionPattern: 'description', + purpose: '反馈标题、描述和分类必须是非空结构化字段。', + }, + { + type: 'check-constraint', + name: 'Feedback_images_valid', + relationName: 'Feedback', + expressionPattern: 'text_array_all_trimmed_non_blank', + purpose: '反馈图片数组不能保存空字符串。', + }, + { + type: 'check-constraint', + name: 'Feedback_comments_array', + relationName: 'Feedback', + expressionPattern: 'jsonb_typeof', + purpose: '反馈评论 JSON 必须是数组过程记录。', + }, + { + type: 'check-constraint', + name: 'Feedback_resolution_state_valid', + relationName: 'Feedback', + expressionPattern: 'resolvedAt', + purpose: '反馈解决状态必须和解决时间、解决说明保持一致。', + }, + { + type: 'check-constraint', + name: 'ActivityLog_identity_valid', + relationName: 'ActivityLog', + expressionPattern: 'action', + purpose: '操作日志必须有操作者和动作说明。', + }, + { + type: 'check-constraint', + name: 'ActivityLog_target_pair_valid', + relationName: 'ActivityLog', + expressionPattern: 'targetType', + purpose: '操作日志目标类型和目标 ID 必须同时存在或同时为空。', + }, + { + type: 'check-constraint', + name: 'ActivityLog_json_shape_valid', + relationName: 'ActivityLog', + expressionPattern: 'metadata', + purpose: '操作日志变更和元数据 JSON 必须是对象。', + }, +] + +function loadDatabaseUrl() { + if (process.env.DATABASE_URL) { + return process.env.DATABASE_URL + } + + const envPath = path.join(ROOT_DIR, '.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 + + const key = trimmed.slice(0, separatorIndex).trim() + if (key !== '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 normalizeSqlFragment(value) { + return String(value ?? '') + .replace(/\s+/g, ' ') + .replace(/""/g, '"') + .trim() +} + +function loadExternalReadonlyContractExpectedColumns() { + const sqlPath = path.join(ROOT_DIR, 'scripts/sql/check-external-readonly-contract.sql') + const sql = fs.readFileSync(sqlPath, 'utf8') + const expectedColumns = new Map() + const tuplePattern = /\('([^']+_v1)',\s*'([^']+)'\)/g + + for (const match of sql.matchAll(tuplePattern)) { + const viewName = match[1] + const columnName = match[2] + const columns = expectedColumns.get(viewName) ?? new Set() + columns.add(columnName) + expectedColumns.set(viewName, columns) + } + + return expectedColumns +} + +const EXTERNAL_STABLE_KEY_RULES = [ + { + viewName: 'materials_v1', + requiredColumns: ['material_code', 'material_name'], + uniqueColumns: ['material_code'], + }, + { + viewName: 'asset_variants_v1', + requiredColumns: ['spec_code', 'spec_name', 'asset_family_code'], + uniqueColumns: ['spec_code'], + }, + { + viewName: 'product_skus_v1', + requiredColumns: ['product_business_key', 'sku_business_key', 'sku_code'], + uniqueColumns: ['sku_business_key', 'sku_code'], + }, + { + viewName: 'product_assets_v1', + requiredColumns: ['product_business_key', 'sku_business_key', 'asset_url'], + uniqueColumns: [], + }, + { + viewName: 'product_bom_v1', + requiredColumns: ['product_business_key', 'item_code', 'item_name'], + conditionalRequiredColumns: [ + { + columnName: 'material_code', + conditionSql: "item_type = 'PANEL'", + }, + ], + uniqueColumns: [], + }, + { + viewName: 'intake_products_ready_v1', + requiredColumns: ['product_business_key'], + uniqueColumns: ['product_business_key'], + }, + { + viewName: 'intake_sku_items_ready_v1', + requiredColumns: ['product_business_key', 'sku_business_key', 'sku_code'], + uniqueColumns: ['sku_business_key'], + }, +] + +function assertSafeExternalIdentifier(identifier) { + if (!/^[a-z][a-z0-9_]*$/.test(identifier)) { + throw new Error(`外部只读契约包含非法标识符:${identifier}`) + } +} + +function quoteExternalIdentifier(identifier) { + assertSafeExternalIdentifier(identifier) + return `"${identifier}"` +} + +async function getExternalReadonlyContractSummary(client) { + const expectedColumns = loadExternalReadonlyContractExpectedColumns() + const expectedViewNames = Array.from(expectedColumns.keys()).sort() + const existingViewsResult = await client.query( + ` + SELECT table_name + FROM information_schema.views + WHERE table_schema = 'external' + AND table_name = ANY($1) + `, + [expectedViewNames], + ) + const existingViews = new Set(existingViewsResult.rows.map((row) => row.table_name)) + const existingColumnsResult = await client.query( + ` + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'external' + AND table_name = ANY($1) + `, + [expectedViewNames], + ) + const existingColumns = new Set( + existingColumnsResult.rows.map((row) => `${row.table_name}.${row.column_name}`), + ) + const missingViews = expectedViewNames.filter((viewName) => !existingViews.has(viewName)) + const missingColumns = [] + const extraColumns = [] + + for (const [viewName, columns] of expectedColumns.entries()) { + for (const columnName of columns) { + if (!existingColumns.has(`${viewName}.${columnName}`)) { + missingColumns.push({ viewName, columnName }) + } + } + } + existingColumnsResult.rows.forEach((row) => { + const expectedViewColumns = expectedColumns.get(row.table_name) + if (expectedViewColumns && !expectedViewColumns.has(row.column_name)) { + extraColumns.push({ viewName: row.table_name, columnName: row.column_name }) + } + }) + + return { + expectedViews: expectedViewNames.length, + expectedColumns: Array.from(expectedColumns.values()).reduce( + (total, columns) => total + columns.size, + 0, + ), + missingViews, + missingColumns, + extraColumns, + } +} + +async function getExternalStableKeyQualitySummary(client) { + const missingRequired = [] + const duplicateGroups = [] + const skippedViews = [] + + for (const rule of EXTERNAL_STABLE_KEY_RULES) { + const relationName = `external.${rule.viewName}` + if (!(await relationExists(client, relationName))) { + skippedViews.push({ viewName: rule.viewName, reason: 'missing_view' }) + continue + } + + for (const columnName of rule.requiredColumns) { + const column = quoteExternalIdentifier(columnName) + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM external.${quoteExternalIdentifier(rule.viewName)} + WHERE NULLIF(btrim(COALESCE(${column}::text, '')), '') IS NULL + `) + const rowCount = result.rows[0]?.row_count ?? 0 + if (rowCount > 0) { + missingRequired.push({ viewName: rule.viewName, columnName, rowCount }) + } + } + + for (const conditionalRule of rule.conditionalRequiredColumns ?? []) { + const column = quoteExternalIdentifier(conditionalRule.columnName) + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM external.${quoteExternalIdentifier(rule.viewName)} + WHERE ${conditionalRule.conditionSql} + AND NULLIF(btrim(COALESCE(${column}::text, '')), '') IS NULL + `) + const rowCount = result.rows[0]?.row_count ?? 0 + if (rowCount > 0) { + missingRequired.push({ + viewName: rule.viewName, + columnName: conditionalRule.columnName, + condition: conditionalRule.conditionSql, + rowCount, + }) + } + } + + for (const columnName of rule.uniqueColumns) { + const column = quoteExternalIdentifier(columnName) + const result = await client.query(` + SELECT COUNT(*)::integer AS duplicate_groups + FROM ( + SELECT upper(btrim(${column}::text)) AS normalized_value + FROM external.${quoteExternalIdentifier(rule.viewName)} + WHERE NULLIF(btrim(COALESCE(${column}::text, '')), '') IS NOT NULL + GROUP BY upper(btrim(${column}::text)) + HAVING COUNT(*) > 1 + ) duplicate_groups + `) + const groupCount = result.rows[0]?.duplicate_groups ?? 0 + if (groupCount > 0) { + duplicateGroups.push({ viewName: rule.viewName, columnName, groupCount }) + } + } + } + + return { + ruleCount: EXTERNAL_STABLE_KEY_RULES.length, + missingRequired, + duplicateGroups, + skippedViews, + } +} + +async function getIndexGuard(client, guard) { + const result = await client.query(` + SELECT + cls.relname AS index_name, + table_cls.relname AS table_name, + idx.indisunique AS is_unique, + pg_get_expr(idx.indpred, idx.indrelid) AS predicate, + pg_get_indexdef(cls.oid) AS definition + FROM pg_index idx + JOIN pg_class cls ON cls.oid = idx.indexrelid + JOIN pg_class table_cls ON table_cls.oid = idx.indrelid + JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace + WHERE ns.nspname = 'public' + AND cls.relname = $1 + AND table_cls.relname = $2 + LIMIT 1 + `, [guard.name, guard.relationName]) + const row = result.rows[0] + const predicate = normalizeSqlFragment(row?.predicate) + const definition = normalizeSqlFragment(row?.definition) + const hasExpectedPredicate = + guard.type !== 'partial-unique-index' || + (predicate && predicate.includes(guard.predicatePattern)) + const hasExpectedDefinition = + !guard.definitionPattern || definition.includes(guard.definitionPattern) + + return { + ...guard, + exists: Boolean(row), + isUnique: Boolean(row?.is_unique), + predicate, + definition, + ok: + Boolean(row) && + (guard.type === 'index' || Boolean(row?.is_unique)) && + hasExpectedPredicate && + hasExpectedDefinition, + } +} + +async function getTriggerGuard(client, guard) { + const result = await client.query(` + SELECT + trigger_name, + event_object_table, + action_timing, + event_manipulation + FROM information_schema.triggers + WHERE trigger_schema = 'public' + AND trigger_name = $1 + AND event_object_table = $2 + LIMIT 1 + `, [guard.name, guard.relationName]) + const row = result.rows[0] + return { + ...guard, + exists: Boolean(row), + actionTiming: row?.action_timing ?? '', + eventManipulation: row?.event_manipulation ?? '', + ok: Boolean(row), + } +} + +async function getCheckConstraintGuard(client, guard) { + const result = await client.query(` + SELECT + con.conname AS constraint_name, + table_cls.relname AS table_name, + pg_get_constraintdef(con.oid) AS definition + FROM pg_constraint con + JOIN pg_class table_cls ON table_cls.oid = con.conrelid + JOIN pg_namespace ns ON ns.oid = table_cls.relnamespace + WHERE ns.nspname = 'public' + AND con.contype = 'c' + AND con.conname = $1 + AND table_cls.relname = $2 + LIMIT 1 + `, [guard.name, guard.relationName]) + const row = result.rows[0] + const definition = normalizeSqlFragment(row?.definition) + const hasExpectedExpression = + !guard.expressionPattern || definition.includes(guard.expressionPattern) + + return { + ...guard, + exists: Boolean(row), + definition, + ok: Boolean(row) && hasExpectedExpression, + } +} + +async function getDatabaseGuardSummary(client) { + const checks = [] + for (const guard of REQUIRED_DATABASE_GUARDS) { + if (guard.type === 'trigger') { + checks.push(await getTriggerGuard(client, guard)) + } else if (guard.type === 'check-constraint') { + checks.push(await getCheckConstraintGuard(client, guard)) + } else { + checks.push(await getIndexGuard(client, guard)) + } + } + + return { + total: checks.length, + ok: checks.filter((guard) => guard.ok).length, + missingOrInvalid: checks.filter((guard) => !guard.ok), + checks, + } +} + +async function getAssetCenterSourceDataSummary(client) { + const result = await client.query(` + SELECT + (SELECT COUNT(*)::integer FROM "AssetSeries") AS asset_series_total, + (SELECT COUNT(*)::integer FROM "AssetSeries" WHERE "isActive" = true) AS active_asset_series, + (SELECT COUNT(*)::integer FROM "AssetFamily" WHERE "deletedAt" IS NULL) AS active_asset_families, + ( + SELECT COUNT(*)::integer + FROM "AssetFamily" + WHERE "deletedAt" IS NULL + AND "assetSeriesId" IS NULL + ) AS active_asset_families_without_series, + (SELECT COUNT(*)::integer FROM "AssetVariant" WHERE "deletedAt" IS NULL) AS active_asset_variants, + ( + SELECT COUNT(*)::integer + FROM "AssetVariant" + WHERE "deletedAt" IS NULL + AND NULLIF(btrim("modelUrl"), '') IS NULL + ) AS active_asset_variants_without_model_url, + ( + SELECT COUNT(*)::integer + FROM "AssetVariant" + WHERE "deletedAt" IS NULL + AND NULLIF(btrim("thumbnailUrl"), '') IS NULL + ) AS active_asset_variants_without_thumbnail_url + `) + return result.rows[0] +} + +async function getAssetVariantDuplicateBarcodeGroupCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS duplicate_groups + FROM ( + SELECT upper(btrim("barcode")) AS barcode + FROM "AssetVariant" + WHERE "deletedAt" IS NULL + AND NULLIF(btrim(COALESCE("barcode", '')), '') IS NOT NULL + GROUP BY upper(btrim("barcode")) + HAVING COUNT(*) > 1 + ) duplicate_groups + `) + return result.rows[0]?.duplicate_groups ?? 0 +} + +function assertNoPendingMigrationDryRun(appliedMigrations, options = {}) { + if (!options.requireApplied || appliedMigrations.length === 0) return + const pendingMigrations = getPendingStructureMigrationPaths(appliedMigrations) + if (pendingMigrations.length === 0) return + throw new Error( + `数据库治理迁移尚未真实落库,严格模式拒绝通过:${pendingMigrations.join(', ')}`, + ) +} + +function getMigrationApplicationStatus( + appliedMigrations, + migrationReplay = classifyAppliedMigrationPaths(appliedMigrations), +) { + return { + appliedMigrationCount: appliedMigrations.length, + pendingStructureMigrationCount: migrationReplay.structureMigrations.length, + replayableIdempotentMigrationCount: migrationReplay.idempotentMigrations.length, + pendingStructureMigrationPaths: migrationReplay.structureMigrations, + replayableIdempotentMigrationPaths: migrationReplay.idempotentMigrations, + strictReady: migrationReplay.structureMigrations.length === 0, + fullyReady: appliedMigrations.length === 0, + } +} + +function shouldApplyMissingMigrationFiles(migrationReadiness) { + return Number(migrationReadiness?.pendingMigrationCount ?? 0) > 0 +} + +async function getMigrationHistoryStatus(client) { + 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, + } + } + + const historyResult = 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 = historyResult.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, + } +} + +async function getRequiredMigrationReadiness(client, migrations = REQUIRED_MIGRATIONS) { + const checks = [] + const migrationHistory = await getMigrationHistoryStatus(client) + + for (const migration of migrations) { + const targetRelations = getMigrationTargetRelations(migration) + const relationChecks = await Promise.all( + targetRelations.map(async (relationName) => ({ + relationName, + exists: await relationExists(client, relationName), + })), + ) + const columnCheck = migration.columnCheck + ? { + ...migration.columnCheck, + exists: await columnExists( + client, + migration.columnCheck.tableName, + migration.columnCheck.columnName, + ), + } + : null + const columnDefaultChecks = Array.isArray(migration.columnDefaultChecks) + ? await Promise.all( + migration.columnDefaultChecks.map(async (check) => ({ + ...check, + matches: await columnDefaultMatches( + client, + check.tableName, + check.columnName, + check.expectedDefault, + ), + })), + ) + : [] + const guardChecks = Array.isArray(migration.guardChecks) + ? await Promise.all( + migration.guardChecks.map(async (check) => ({ + ...check, + exists: await guardExists(client, check), + })), + ) + : [] + const relationColumnChecks = Array.isArray(migration.relationColumnChecks) + ? await Promise.all( + migration.relationColumnChecks.map(async (check) => ({ + ...check, + columns: await Promise.all( + check.columnNames.map(async (columnName) => ({ + columnName, + exists: await relationColumnExists(client, check.relationName, columnName), + })), + ), + })), + ) + : [] + const missingRelations = relationChecks + .filter((check) => !check.exists) + .map((check) => check.relationName) + const dataChecks = Array.isArray(migration.dataChecks) + ? await Promise.all( + migration.dataChecks.map(async (check) => ({ + name: check.name, + expectedValue: check.expectedValue, + matches: missingRelations.length === 0 + ? await dataCheckPasses(client, check) + : false, + })), + ) + : [] + const ready = + missingRelations.length === 0 && + (!columnCheck || columnCheck.exists) && + columnDefaultChecks.every((check) => check.matches) && + guardChecks.every((check) => check.exists) && + relationColumnChecks.every((check) => check.columns.every((column) => column.exists)) && + dataChecks.every((check) => check.matches) + + checks.push({ + path: migration.path, + kind: migration.alwaysApply ? 'idempotent' : 'structure', + ready, + targetRelations: relationChecks, + ...(columnCheck && { columnCheck }), + ...(columnDefaultChecks.length > 0 && { columnDefaultChecks }), + ...(guardChecks.length > 0 && { guardChecks }), + ...(relationColumnChecks.length > 0 && { relationColumnChecks }), + ...(dataChecks.length > 0 && { dataChecks }), + }) + } + + const pendingChecks = checks.filter((check) => !check.ready) + const pendingStructureMigrationPaths = pendingChecks + .filter((check) => check.kind === 'structure') + .map((check) => check.path) + const pendingIdempotentMigrationPaths = pendingChecks + .filter((check) => check.kind === 'idempotent') + .map((check) => check.path) + const migrationHistoryReady = + migrationHistory.tableExists && + migrationHistory.unfinishedMigrationRows === 0 && + migrationHistory.rolledBackMigrationRows === 0 + + return { + mode: 'read-only-migration-status', + migrationHistory, + migrationCount: checks.length, + readyMigrationCount: checks.length - pendingChecks.length, + pendingMigrationCount: pendingChecks.length, + pendingStructureMigrationCount: pendingStructureMigrationPaths.length, + pendingIdempotentMigrationCount: pendingIdempotentMigrationPaths.length, + pendingStructureMigrationPaths, + pendingIdempotentMigrationPaths, + strictReady: pendingStructureMigrationPaths.length === 0 && migrationHistoryReady, + fullyReady: pendingChecks.length === 0 && migrationHistoryReady, + checks, + } +} + +function assertNoMasterDataTruthDrift(report) { + const driftChecks = [ + { + value: report.payloadSyncDiffCount, + message: '非集成 SKU 明细结构化表与 payload.skuItems 快照不一致,详见 payloadSyncDiffCount。', + }, + { + value: report.productSkuProfileSnapshotDiffCount, + message: '正式 SKU 资产 profile 与 attributes.assetSkuSnapshot 兼容快照不一致,详见 productSkuProfileSnapshotDiffCount。', + }, + { + value: report.productSkuProfileDeletedAtMismatchCount, + message: '正式 SKU 资产 profile 的 skuDeletedAt 与 ProductSKU.deletedAt 不一致,详见 productSkuProfileDeletedAtMismatchCount。', + }, + { + value: report.productSkuSoftDeleteTombstoneRiskCount, + message: '正式 SKU 软删除墓碑资料不完整,详见 productSkuSoftDeleteTombstoneRiskCount。', + }, + { + value: report.preSkuBasicInfoSnapshotDiffCount, + message: '准 SKU 基础资料结构化表与 displaySummary/familySelection 旧快照不一致,详见 preSkuBasicInfoSnapshotDiffCount。', + }, + { + value: report.nonIntegratedProductProfilePayloadDiffCount, + message: '非集成产品 profile 与 payload 产品头快照不一致,详见 nonIntegratedProductProfilePayloadDiffCount。', + }, + { + value: report.nonIntegratedProductStockSourcePayloadDiffCount, + message: '非集成存量来源字段 profile 与 payload.metadata 快照不一致,详见 nonIntegratedProductStockSourcePayloadDiffCount。', + }, + ] + + const drift = driftChecks.find((check) => Number(check.value ?? 0) > 0) + if (drift) { + throw new Error(drift.message) + } + + if (Number(report.duplicateSkuCodeGroups ?? 0) > 0) { + throw new Error('非集成 SKU 明细存在重复 skuCode,详见 duplicateSkuCodeGroups。') + } + if (Number(report.duplicateBarcodeGroups ?? 0) > 0) { + throw new Error('非集成 SKU 明细存在重复 barcode,详见 duplicateBarcodeGroups。') + } + if (Number(report.productSkuPriceCostStockRiskCount ?? 0) > 0) { + throw new Error('正式 SKU 存在负售价、负成本或负库存,详见 productSkuPriceCostStockRiskCount。') + } + if (Number(report.productSkuFormalTruthAttributeRiskCount ?? 0) > 0) { + throw new Error('正式 SKU attributes 仍保存正式资料字段,详见 productSkuFormalTruthAttributeRiskCount。') + } +} + +function asCount(value) { + const count = Number(value ?? 0) + return Number.isFinite(count) ? count : 0 +} + +function collectFoundationHardReadyBlockers(report) { + const blockers = [] + const add = (source, key, value, message) => { + blockers.push({ source, key, value, message }) + } + const addCount = (source, key, value, message) => { + const count = asCount(value) + if (count > 0) add(source, key, count, message) + } + const addBoolean = (source, key, ok, message) => { + if (!ok) add(source, key, ok, message) + } + + addBoolean( + 'migrationApplicationStatus', + 'fullyReady', + report.migrationApplicationStatus?.fullyReady === true, + '治理迁移必须真实落库且没有 dry-run replay 缺口。', + ) + addCount( + 'databaseGuards', + 'missingOrInvalid', + report.databaseGuards?.missingOrInvalid?.length, + '数据库物理护栏缺失或失效。', + ) + addCount( + 'assetCenterSourceData', + 'active_asset_families_without_series', + report.assetCenterSourceData?.active_asset_families_without_series, + '活动资产族必须归属到结构化系列。', + ) + addCount( + 'assetCenterSourceData', + 'active_asset_variants_without_model_url', + report.assetCenterSourceData?.active_asset_variants_without_model_url, + '活动规格款必须有模型 URL。', + ) + addCount( + 'assetCenterSourceData', + 'active_asset_variants_without_thumbnail_url', + report.assetCenterSourceData?.active_asset_variants_without_thumbnail_url, + '活动规格款必须有预览图。', + ) + addCount( + 'assetVariantDuplicateBarcodeGroups', + 'duplicateGroups', + report.assetVariantDuplicateBarcodeGroups, + '活动资产规格款条码不能重复。', + ) + addCount( + 'structuredSkuItems', + 'incomplete', + report.structuredSkuItems?.incomplete, + '非集成 SKU 明细必须全部 READY。', + ) + addCount( + 'structuredSkuItems', + 'duplicateSkuCodeGroups', + report.duplicateSkuCodeGroups, + '非集成 SKU 明细 skuCode 不能重复。', + ) + addCount( + 'structuredSkuItems', + 'duplicateBarcodeGroups', + report.duplicateBarcodeGroups, + '非集成 SKU 明细 barcode 不能重复。', + ) + addCount( + 'productSkuAssetProfiles', + 'productSkuWithoutAssetProfileCount', + report.productSkuWithoutAssetProfileCount, + '带资产快照的正式 SKU 必须有结构化资产 profile。', + ) + addCount( + 'productSkuAssetProfiles', + 'productSkuProfileDuplicateBarcodeGroups', + report.productSkuProfileDuplicateBarcodeGroups, + '正式 SKU 资产 profile 活动条码不能重复。', + ) + addCount( + 'productSkuAssetProfiles', + 'productSkuProfileDeletedAtMismatchCount', + report.productSkuProfileDeletedAtMismatchCount, + '正式 SKU 资产 profile 删除状态必须和 SKU 删除状态一致。', + ) + addCount( + 'productSkuAssetProfiles', + 'productSkuSoftDeleteTombstoneRiskCount', + report.productSkuSoftDeleteTombstoneRiskCount, + '正式 SKU 软删除墓碑资料必须完整。', + ) + addCount( + 'productSkuTruth', + 'productSkuMissingNameCount', + report.productSkuMissingNameCount, + '活动正式 SKU 必须有结构化名称。', + ) + addCount( + 'productSkuTruth', + 'productSkuPriceCostStockRiskCount', + report.productSkuPriceCostStockRiskCount, + '正式 SKU 售价、成本和库存不能为负。', + ) + addCount( + 'productSkuTruth', + 'productSkuFormalTruthAttributeRiskCount', + report.productSkuFormalTruthAttributeRiskCount, + '正式 SKU attributes 不能保存正式资料字段。', + ) + addCount( + 'preSkuBasicInfo', + 'preSkuMissingBasicInfoCount', + report.preSkuMissingBasicInfoCount, + '准 SKU 必须有结构化基础资料。', + ) + addCount( + 'preSkuBasicInfo', + 'preSkuDuplicateFormalSkuCodeGroups', + report.preSkuDuplicateFormalSkuCodeGroups, + '准 SKU 正式规格码不能重复。', + ) + addCount( + 'preSkuBasicInfo', + 'preSkuDuplicateBarcodeGroups', + report.preSkuDuplicateBarcodeGroups, + '准 SKU 条码不能重复。', + ) + addCount( + 'preSkuLifecycle', + 'preSkuFormalizationLifecycleMismatchCount', + report.preSkuFormalizationLifecycleMismatchCount, + '准 SKU 正式化状态必须和正式 SKU 来源关系一致。', + ) + addCount( + 'draftProcessSnapshots', + 'draftGroupInvalidProcessShapeCount', + report.draftGroupInvalidProcessShapeCount, + '草稿组过程快照形态必须稳定。', + ) + addCount( + 'draftProcessSnapshots', + 'preSkuInvalidProcessShapeCount', + report.preSkuInvalidProcessShapeCount, + '准 SKU 过程快照形态必须稳定。', + ) + addCount( + 'nonIntegratedProductProfiles', + 'nonIntegratedProductMissingProfileCount', + report.nonIntegratedProductMissingProfileCount, + '非集成产品必须有结构化产品 profile。', + ) + addCount( + 'nonIntegratedProductProfiles', + 'missing_product_name', + report.nonIntegratedProductProfiles?.missing_product_name, + '非集成产品 profile 必须有产品名称。', + ) + addCount( + 'nonIntegratedProductProfiles', + 'missing_product_category', + report.nonIntegratedProductProfiles?.missing_product_category, + '非集成产品 profile 必须有品类。', + ) + addCount( + 'nonIntegratedProductProfiles', + 'missing_development_kind', + report.nonIntegratedProductProfiles?.missing_development_kind, + '非集成产品 profile 必须有开发类型。', + ) + addCount( + 'productPatentRecords', + 'temporary_id_count', + report.productPatentRecords?.temporary_id_count, + '专利台账不能保留草稿态临时随机 id。', + ) + + const qualityDecisionRisks = report.masterDataQualityDecisions || {} + for (const key of [ + 'openApprovedIncomplete', + 'blankReasonRisk', + 'placeholderReasonRisk', + 'placeholderDecidedByRisk', + 'placeholderResolvedByRisk', + 'duplicateOpenTargetRisk', + ]) { + addCount( + 'masterDataQualityDecisions', + key, + qualityDecisionRisks[key], + '主数据质量决策只能作为分阶段审计线索,不能保留开放缺口或占位内容。', + ) + } + + const driftChecks = [ + ['snapshotDrift', 'payloadSyncDiffCount', report.payloadSyncDiffCount], + ['snapshotDrift', 'productSkuProfileSnapshotDiffCount', report.productSkuProfileSnapshotDiffCount], + ['snapshotDrift', 'preSkuBasicInfoSnapshotDiffCount', report.preSkuBasicInfoSnapshotDiffCount], + ['snapshotDrift', 'nonIntegratedProductProfilePayloadDiffCount', report.nonIntegratedProductProfilePayloadDiffCount], + ['snapshotDrift', 'nonIntegratedProductStockSourcePayloadDiffCount', report.nonIntegratedProductStockSourcePayloadDiffCount], + ] + for (const [source, key, value] of driftChecks) { + addCount(source, key, value, '结构化主数据和兼容/过程快照不能漂移。') + } + + addBoolean( + 'externalReadonlyContract', + 'externalIntakeSkuItemsView.exists', + report.externalIntakeSkuItemsView?.exists === true, + '外部只读 SKU 明细视图必须存在。', + ) + addBoolean( + 'externalReadonlyContract', + 'externalIntakeReadyViews.exists', + report.externalIntakeReadyViews?.exists === true, + '外部只读 ready 视图必须存在。', + ) + addBoolean( + 'externalReadonlyContract', + 'externalProductSkusView.exists', + report.externalProductSkusView?.exists === true, + '外部正式 SKU 视图必须存在。', + ) + addBoolean( + 'externalReadonlyContract', + 'externalProductDetailViews.exists', + report.externalProductDetailViews?.exists === true, + '外部正式产品素材/BOM 明细视图必须存在。', + ) + addCount( + 'externalReadonlyContract', + 'missingViews', + report.externalReadonlyContract?.missingViews?.length, + '外部只读契约视图不能缺失。', + ) + addCount( + 'externalReadonlyContract', + 'missingColumns', + report.externalReadonlyContract?.missingColumns?.length, + '外部只读视图不能缺少字段字典承诺列。', + ) + addCount( + 'externalReadonlyContract', + 'extraColumns', + report.externalReadonlyContract?.extraColumns?.length, + '外部只读视图不能暴露字段字典未登记列。', + ) + addCount( + 'externalStableKeyQuality', + 'missingRequired', + report.externalStableKeyQuality?.missingRequired?.length, + '外部只读视图稳定业务键不能缺失。', + ) + addCount( + 'externalStableKeyQuality', + 'duplicateGroups', + report.externalStableKeyQuality?.duplicateGroups?.length, + '外部只读视图稳定业务键不能重复。', + ) + addCount( + 'externalViewJsonSourceDependencyRisks', + 'rowCount', + report.externalViewJsonSourceDependencyRisks?.length, + '外部只读视图不能依赖内部 JSON 源。', + ) + addCount( + 'externalViewIdFallbackRisks', + 'rowCount', + report.externalViewIdFallbackRisks?.length, + '外部只读视图业务编码字段不能用内部 id 兜底。', + ) + addCount( + 'externalIntakeProductPeopleSourceRisks', + 'rowCount', + report.externalIntakeProductPeopleSourceRisks?.length, + '外部非集成产品负责人字段必须优先读取结构化 profile。', + ) + + return blockers +} + +function buildFoundationHardReadyGate(report) { + const blockers = collectFoundationHardReadyBlockers(report) + return { + hardReady: blockers.length === 0, + blockerCount: blockers.length, + blockerSourceCounts: blockers.reduce((counts, blocker) => { + counts[blocker.source] = (counts[blocker.source] || 0) + 1 + return counts + }, {}), + blockers, + } +} + +function assertFoundationHardReadyGate(report) { + const gate = report.foundationHardReadyGate || buildFoundationHardReadyGate(report) + if (gate.hardReady) return + const first = gate.blockers[0] + throw new Error( + `数据库主数据地基尚未 hard-ready:${first?.source || 'unknown'}.${first?.key || 'unknown'},详见 foundationHardReadyGate.blockers。`, + ) +} + +async function getStructuredSkuSummary(client) { + const result = await client.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE "dataQualityStatus" = 'READY')::integer AS ready, + COUNT(*) FILTER (WHERE "dataQualityStatus" = 'INCOMPLETE')::integer AS incomplete + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + `) + return result.rows[0] +} + +async function getIncompleteSkuItemSamples(client) { + const result = await client.query(` + SELECT + sku."id", + sku."productId", + product."spu", + sku."skuItemId", + sku."skuCode", + sku."specName", + sku."model", + sku."barcode", + sku."missingRequiredFields" + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + AND sku."dataQualityStatus" = 'INCOMPLETE' + ORDER BY product."spu", sku."sortOrder", sku."id" + LIMIT 20 + `) + return result.rows +} + +async function getDuplicateGroupCount(client, columnName) { + const result = await client.query(` + SELECT COUNT(*)::integer AS duplicate_groups + FROM ( + SELECT upper(btrim("${columnName}")) AS normalized_value + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + AND NULLIF(btrim("${columnName}"), '') IS NOT NULL + GROUP BY upper(btrim("${columnName}")) + HAVING COUNT(*) > 1 + ) duplicate_groups + `) + return result.rows[0]?.duplicate_groups ?? 0 +} + +async function getPayloadSyncDiffCount(client) { + const result = await client.query(` + WITH payload_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_payload_sku_items AS ( + SELECT + payload_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 payload_sku_items + ), + payload_snapshot AS ( + SELECT + product_id, + sku_item_id, + sort_order, + COALESCE(sku_item ->> 'skuCode', '') AS sku_code, + COALESCE(sku_item ->> 'specName', '') AS spec_name, + COALESCE(sku_item ->> 'model', '') AS model, + COALESCE(sku_item ->> 'barcode', '') AS barcode + FROM deduped_payload_sku_items + ), + structured_snapshot AS ( + SELECT + sku."productId" AS product_id, + sku."skuItemId" AS sku_item_id, + sku."sortOrder" AS sort_order, + sku."skuCode" AS sku_code, + sku."specName" AS spec_name, + sku."model" AS model, + sku."barcode" AS barcode + FROM "IntakeNonIntegratedSkuItem" sku + JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId" + WHERE product."deletedAt" IS NULL + ) + SELECT COUNT(*)::integer AS row_count + FROM payload_snapshot payload + FULL JOIN structured_snapshot structured + ON structured.product_id = payload.product_id + AND structured.sku_item_id = payload.sku_item_id + WHERE payload.product_id IS NULL + OR structured.product_id IS NULL + OR payload.sort_order IS DISTINCT FROM structured.sort_order + OR payload.sku_code IS DISTINCT FROM structured.sku_code + OR payload.spec_name IS DISTINCT FROM structured.spec_name + OR payload.model IS DISTINCT FROM structured.model + OR payload.barcode IS DISTINCT FROM structured.barcode + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getExternalViewSummary(client) { + const exists = await relationExists(client, 'external.intake_sku_items_v1') + if (!exists) { + return { exists: false } + } + + const result = await client.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE data_quality_status = 'READY')::integer AS ready, + COUNT(*) FILTER (WHERE data_quality_status = 'INCOMPLETE')::integer AS incomplete + FROM external.intake_sku_items_v1 + `) + return { + exists: true, + ...result.rows[0], + } +} + +async function getExternalReadyViewSummary(client) { + const productsExists = await relationExists(client, 'external.intake_products_ready_v1') + const skuItemsExists = await relationExists(client, 'external.intake_sku_items_ready_v1') + if (!productsExists || !skuItemsExists) { + return { exists: false, productsExists, skuItemsExists } + } + + const result = await client.query(` + SELECT + (SELECT COUNT(*)::integer FROM external.intake_products_ready_v1) AS ready_products, + (SELECT COUNT(*)::integer FROM external.intake_sku_items_ready_v1) AS ready_sku_items, + ( + SELECT COUNT(*)::integer + FROM external.intake_sku_items_ready_v1 + WHERE data_quality_status <> 'READY' + ) AS ready_sku_quality_violations, + ( + SELECT COUNT(*)::integer + FROM external.intake_products_ready_v1 + WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL + ) AS ready_product_business_key_missing, + ( + SELECT COUNT(*)::integer + FROM external.intake_sku_items_ready_v1 + WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL + OR NULLIF(btrim(COALESCE(sku_business_key, '')), '') IS NULL + ) AS ready_sku_business_key_missing, + ( + SELECT COUNT(*)::integer + FROM ( + SELECT product_business_key + FROM external.intake_products_ready_v1 + GROUP BY product_business_key + HAVING COUNT(*) > 1 + ) duplicate_groups + ) AS ready_product_business_key_duplicate_groups, + ( + SELECT COUNT(*)::integer + FROM ( + SELECT sku_business_key + FROM external.intake_sku_items_ready_v1 + GROUP BY sku_business_key + HAVING COUNT(*) > 1 + ) duplicate_groups + ) AS ready_sku_business_key_duplicate_groups + `) + return { + exists: true, + ...result.rows[0], + } +} + +async function getProductSkuAssetProfileSummary(client) { + const result = await client.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE "sourceStatus" = 'ACTIVE')::integer AS active, + COUNT(*) FILTER (WHERE "sourceStatus" = 'DELETED')::integer AS deleted, + COUNT(*) FILTER (WHERE "sourceStatus" = 'MISSING')::integer AS missing + FROM "ProductSkuAssetProfile" + `) + return result.rows[0] +} + +async function getProductSkuWithoutAssetProfileCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "ProductSKU" sku + JOIN "Product" product ON product."id" = sku."productId" + LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" + WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND jsonb_typeof(sku."attributes"::jsonb -> 'assetSkuSnapshot') = 'object' + AND profile."id" IS NULL + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getProductSkuMissingNameCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "ProductSKU" sku + JOIN "Product" product ON product."id" = sku."productId" + WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND NULLIF(btrim(COALESCE(sku."name", '')), '') IS NULL + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getProductSkuPriceCostStockRiskCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "ProductSKU" + WHERE "price" < 0 + OR ("costPrice" IS NOT NULL AND "costPrice" < 0) + OR "stock" < 0 + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getProductSkuFormalTruthAttributeRiskCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "ProductSKU" + WHERE attributes ?| ARRAY[ + 'skuName', + 'model', + 'specCode', + 'barcode', + 'category', + 'color', + 'material', + 'size' + ] + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getProductSkuProfileSnapshotDiffCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "ProductSKU" sku + JOIN "Product" product ON product."id" = sku."productId" + JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id" + WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND jsonb_typeof(sku."attributes"::jsonb -> 'assetSkuSnapshot') = 'object' + AND ( + COALESCE(profile."model", '') IS DISTINCT FROM COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'model', '') + OR COALESCE(profile."specCode", '') IS DISTINCT FROM COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'specCode', '') + OR COALESCE(profile."barcode", '') IS DISTINCT FROM COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'barcode', '') + OR COALESCE(profile."imageUrl", '') IS DISTINCT FROM COALESCE(sku."attributes"::jsonb -> 'assetSkuSnapshot' ->> 'imageUrl', '') + ) + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getProductSkuProfileDuplicateBarcodeGroupCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS duplicate_groups + FROM ( + SELECT upper(btrim(profile."barcode")) AS barcode + FROM "ProductSkuAssetProfile" profile + JOIN "ProductSKU" sku ON sku."id" = profile."skuId" + JOIN "Product" product ON product."id" = sku."productId" + WHERE sku."deletedAt" IS NULL + AND product."deletedAt" IS NULL + AND profile."sourceStatus" = 'ACTIVE' + AND NULLIF(btrim(COALESCE(profile."barcode", '')), '') IS NOT NULL + GROUP BY upper(btrim(profile."barcode")) + HAVING COUNT(*) > 1 + ) duplicate_groups + `) + return result.rows[0]?.duplicate_groups ?? 0 +} + +async function getProductSkuProfileDeletedAtMismatchCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "ProductSkuAssetProfile" profile + JOIN "ProductSKU" sku ON sku."id" = profile."skuId" + WHERE profile."skuDeletedAt" IS DISTINCT FROM sku."deletedAt" + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getProductSkuSoftDeleteTombstoneRiskCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "ProductSKU" + WHERE "deletedAt" IS NOT NULL + AND ( + "deletedCodeAt" IS NULL + OR ( + ("deletedByProductId" IS NULL AND "productDeletedAt" IS NOT NULL) + OR ("deletedByProductId" IS NOT NULL AND "productDeletedAt" IS NULL) + OR ("deletedByProductId" IS NOT NULL AND NULLIF(btrim("deletedByProductId"), '') IS NULL) + ) + ) + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getPreSkuMissingBasicInfoCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "PreSku" pre_sku + LEFT JOIN "PreSkuBasicInfo" basic_info ON basic_info."preSkuId" = pre_sku."id" + WHERE basic_info."id" IS NULL + AND ( + 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 ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), '') IS NOT NULL + ) + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getPreSkuBasicInfoSnapshotDiffCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "PreSku" pre_sku + JOIN "PreSkuBasicInfo" basic_info ON basic_info."preSkuId" = pre_sku."id" + WHERE COALESCE(basic_info."model", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'model', pre_sku."familySelection"::jsonb ->> 'model', '')), ''), '') + OR COALESCE(basic_info."formalSkuCode", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'formalSkuCode', pre_sku."familySelection"::jsonb ->> 'formalSkuCode', '')), ''), '') + OR COALESCE(basic_info."barcode", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'barcode', pre_sku."familySelection"::jsonb ->> 'barcode', '')), ''), '') + OR COALESCE(basic_info."specName", '') IS DISTINCT FROM COALESCE(NULLIF(btrim(COALESCE(pre_sku."displaySummary"::jsonb ->> 'specName', pre_sku."familySelection"::jsonb ->> 'specName', '')), ''), '') + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getPreSkuBasicInfoDuplicateGroupCount(client, columnName) { + const result = await client.query(` + SELECT COUNT(*)::integer AS duplicate_groups + FROM ( + SELECT upper(btrim(basic_info."${columnName}")) AS normalized_value + FROM "PreSkuBasicInfo" basic_info + JOIN "PreSku" pre_sku ON pre_sku."id" = basic_info."preSkuId" + WHERE pre_sku."status" <> 'ARCHIVED' + AND NULLIF(btrim(basic_info."${columnName}"), '') IS NOT NULL + GROUP BY upper(btrim(basic_info."${columnName}")) + HAVING COUNT(*) > 1 + ) duplicate_groups + `) + return result.rows[0]?.duplicate_groups ?? 0 +} + +async function getDraftGroupInvalidProcessShapeCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "DraftGroup" + WHERE NULLIF(btrim(code), '') IS NULL + OR code <> btrim(code) + OR NULLIF(btrim(name), '') IS NULL + OR name <> btrim(name) + OR jsonb_typeof(summary::jsonb) <> 'object' + OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL) + OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL) + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getPreSkuInvalidProcessShapeCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "PreSku" + WHERE sequence <= 0 + OR NULLIF(btrim("defaultName"), '') IS NULL + OR "defaultName" <> btrim("defaultName") + OR NULLIF(btrim("sourceCandidateKey"), '') IS NULL + OR "sourceCandidateKey" <> btrim("sourceCandidateKey") + OR jsonb_typeof("familySelection"::jsonb) <> 'object' + OR jsonb_typeof("variantSelection"::jsonb) <> 'object' + OR jsonb_typeof("previewNodes"::jsonb) <> 'array' + OR jsonb_typeof("displaySummary"::jsonb) <> 'object' + OR jsonb_typeof("bomPreview"::jsonb) <> 'array' + OR ( + "recommendedHardware" IS NOT NULL + AND jsonb_typeof("recommendedHardware"::jsonb) NOT IN ('array', 'null') + ) + OR ( + "explosionPreview" IS NOT NULL + AND jsonb_typeof("explosionPreview"::jsonb) NOT IN ('array', 'null') + ) + OR jsonb_typeof("installStepPreview"::jsonb) <> 'array' + OR ( + "packagingSnapshot" IS NOT NULL + AND jsonb_typeof("packagingSnapshot"::jsonb) NOT IN ('object', 'null') + ) + OR ("weightPreview" IS NOT NULL AND "weightPreview" < 0) + OR EXISTS ( + SELECT 1 + FROM unnest("componentCodes") AS item + WHERE NULLIF(btrim(item), '') IS NULL + ) + OR ("customName" IS NOT NULL AND NULLIF(btrim("customName"), '') IS NULL) + OR (notes IS NOT NULL AND NULLIF(btrim(notes), '') IS NULL) + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getPreSkuFormalizationLifecycleMismatchCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM ( + SELECT pre_sku."id" + FROM "PreSku" pre_sku + LEFT JOIN "ProductSKU" sku ON sku."sourcePreSkuId" = pre_sku."id" + WHERE pre_sku.status = 'FORMALIZED'::"PreSkuStatus" + AND sku."id" IS NULL + + UNION ALL + + SELECT pre_sku."id" + FROM "ProductSKU" sku + JOIN "PreSku" pre_sku ON pre_sku."id" = sku."sourcePreSkuId" + WHERE sku."deletedAt" IS NULL + AND pre_sku.status <> 'FORMALIZED'::"PreSkuStatus" + ) mismatches + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getPreSkuFormalizationLifecycleMismatchSamples(client) { + const result = await client.query(` + SELECT + mismatches."mismatchReason", + mismatches."preSkuId", + draft_group."id" AS "draftGroupId", + draft_group."code" AS "draftGroupCode", + pre_sku."sequence", + pre_sku."status"::text AS "status", + mismatches."linkedFormalSkuId", + mismatches."linkedSkuCode", + mismatches."linkedSkuDeletedAt", + mismatches."linkedProductId", + mismatches."linkedProductDeletedAt" + FROM ( + SELECT + 'formalized_without_product_sku' AS "mismatchReason", + pre_sku."id" AS "preSkuId", + NULL::text AS "linkedFormalSkuId", + NULL::text AS "linkedSkuCode", + NULL::timestamp AS "linkedSkuDeletedAt", + NULL::text AS "linkedProductId", + NULL::timestamp AS "linkedProductDeletedAt" + FROM "PreSku" pre_sku + LEFT JOIN "ProductSKU" sku ON sku."sourcePreSkuId" = pre_sku."id" + WHERE pre_sku.status = 'FORMALIZED'::"PreSkuStatus" + AND sku."id" IS NULL + + UNION ALL + + SELECT + 'active_product_sku_points_to_unformalized_pre_sku' AS "mismatchReason", + pre_sku."id" AS "preSkuId", + sku."id" AS "linkedFormalSkuId", + sku."skuCode" AS "linkedSkuCode", + sku."deletedAt" AS "linkedSkuDeletedAt", + product."id" AS "linkedProductId", + product."deletedAt" AS "linkedProductDeletedAt" + FROM "ProductSKU" sku + JOIN "PreSku" pre_sku ON pre_sku."id" = sku."sourcePreSkuId" + JOIN "Product" product ON product."id" = sku."productId" + WHERE sku."deletedAt" IS NULL + AND pre_sku.status <> 'FORMALIZED'::"PreSkuStatus" + ) mismatches + JOIN "PreSku" pre_sku ON pre_sku."id" = mismatches."preSkuId" + JOIN "DraftGroup" draft_group ON draft_group."id" = pre_sku."draftGroupId" + ORDER BY draft_group."code", pre_sku."sequence", mismatches."mismatchReason" + LIMIT 20 + `) + return result.rows +} + +async function getNonIntegratedProductProfileSummary(client) { + const result = await client.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productName"), '') IS NULL)::integer AS missing_product_name, + COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productCategory"), '') IS NULL)::integer AS missing_product_category, + COUNT(*) FILTER (WHERE NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL)::integer AS missing_development_kind + FROM "IntakeNonIntegratedProductProfile" profile + JOIN "IntakeNonIntegratedProduct" product ON product."id" = profile."productId" + WHERE product."deletedAt" IS NULL + `) + return result.rows[0] +} + +async function getNonIntegratedProductProfileQualitySamples(client) { + const result = await client.query(` + SELECT + product."id", + product."spu", + profile."productName", + profile."productCategory", + profile."productDevelopmentKind", + ARRAY_REMOVE(ARRAY[ + CASE WHEN NULLIF(btrim(profile."productName"), '') IS NULL THEN 'productName' END, + CASE WHEN NULLIF(btrim(profile."productCategory"), '') IS NULL THEN 'productCategory' END, + CASE WHEN NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL THEN 'productDevelopmentKind' END + ], NULL) AS "missingFields" + FROM "IntakeNonIntegratedProduct" product + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."deletedAt" IS NULL + AND ( + NULLIF(btrim(profile."productName"), '') IS NULL + OR NULLIF(btrim(profile."productCategory"), '') IS NULL + OR NULLIF(btrim(profile."productDevelopmentKind"), '') IS NULL + ) + ORDER BY product."spu", product."id" + LIMIT 20 + `) + return result.rows +} + +async function getNonIntegratedProductMissingProfileCount(client) { + const result = await client.query(` + SELECT COUNT(*)::integer AS row_count + FROM "IntakeNonIntegratedProduct" product + LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id" + WHERE product."deletedAt" IS NULL + AND profile."id" IS NULL + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getNonIntegratedProductProfilePayloadDiffCount(client) { + const result = await client.query(` + WITH payload_profile AS ( + SELECT + product."id" AS product_id, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productName'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'previewImageName'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'spu'), ''), '') AS product_name, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productImage'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'previewImage'), ''), '') AS product_image_url, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'productCategory'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'productCategory'), ''), '') AS product_category, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'salesCategory'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'salesCategory'), ''), '') AS sales_category, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'year'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'year'), ''), '') AS product_year, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'currentStage'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'projectStatus'), ''), '') AS product_status, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productDevelopmentKind'), ''), '') AS product_development_kind, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'goodsType'), ''), '') AS goods_type, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'materialType'), ''), '') AS material_type, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productionType'), ''), '') AS production_type, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'size'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'size'), ''), '') AS size, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'color'), ''), '') AS color, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'tagMaterial'), ''), '') AS tag_material, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'priceBand'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'targetPriceBand'), ''), '') AS price_band, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandFlowNo'), ''), '') AS demand_flow_no, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'processTemplate'), ''), '') AS process_template, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'productPlanning'), ''), NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'planningOwner'), ''), '') AS product_planning, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'brandPlanning'), ''), '') AS brand_planning, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'demandDepartment'), ''), '') AS demand_department, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'spuOwner'), ''), '') AS spu_owner, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'operationsOwner'), ''), '') AS operations_owner, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'purchaseOwner'), ''), NULLIF(btrim(product."payload"::jsonb ->> 'purchaser'), ''), '') AS purchase_owner, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualDirector'), ''), '') AS visual_director, + COALESCE(NULLIF(btrim(product."payload"::jsonb ->> 'visualPlanning'), ''), '') AS visual_planning, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'openingReference' ->> 'structureDesigner'), ''), '') AS structure_designer + FROM "IntakeNonIntegratedProduct" product + WHERE product."deletedAt" IS NULL + ) + SELECT COUNT(*)::integer AS row_count + FROM payload_profile payload + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = payload.product_id + WHERE COALESCE(profile."productName", '') IS DISTINCT FROM payload.product_name + OR COALESCE(profile."productImageUrl", '') IS DISTINCT FROM payload.product_image_url + OR COALESCE(profile."productCategory", '') IS DISTINCT FROM payload.product_category + OR COALESCE(profile."salesCategory", '') IS DISTINCT FROM payload.sales_category + OR COALESCE(profile."productYear", '') IS DISTINCT FROM payload.product_year + OR COALESCE(profile."productStatus", '') IS DISTINCT FROM payload.product_status + OR COALESCE(profile."productDevelopmentKind", '') IS DISTINCT FROM payload.product_development_kind + OR COALESCE(profile."goodsType", '') IS DISTINCT FROM payload.goods_type + OR COALESCE(profile."materialType", '') IS DISTINCT FROM payload.material_type + OR COALESCE(profile."productionType", '') IS DISTINCT FROM payload.production_type + OR COALESCE(profile."size", '') IS DISTINCT FROM payload.size + OR COALESCE(profile."color", '') IS DISTINCT FROM payload.color + OR COALESCE(profile."tagMaterial", '') IS DISTINCT FROM payload.tag_material + OR COALESCE(profile."priceBand", '') IS DISTINCT FROM payload.price_band + OR COALESCE(profile."demandFlowNo", '') IS DISTINCT FROM payload.demand_flow_no + OR COALESCE(profile."processTemplate", '') IS DISTINCT FROM payload.process_template + OR COALESCE(profile."productPlanning", '') IS DISTINCT FROM payload.product_planning + OR COALESCE(profile."brandPlanning", '') IS DISTINCT FROM payload.brand_planning + OR COALESCE(profile."demandDepartment", '') IS DISTINCT FROM payload.demand_department + OR COALESCE(profile."spuOwner", '') IS DISTINCT FROM payload.spu_owner + OR COALESCE(profile."operationsOwner", '') IS DISTINCT FROM payload.operations_owner + OR COALESCE(profile."purchaseOwner", '') IS DISTINCT FROM payload.purchase_owner + OR COALESCE(profile."visualDirector", '') IS DISTINCT FROM payload.visual_director + OR COALESCE(profile."visualPlanning", '') IS DISTINCT FROM payload.visual_planning + OR COALESCE(profile."structureDesigner", '') IS DISTINCT FROM payload.structure_designer + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getNonIntegratedProductStockSourcePayloadDiffCount(client) { + const result = await client.query(` + WITH payload_stock_source AS ( + SELECT + product."id" AS product_id, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSpu'), ''), '') AS source_spu, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSkuId'), ''), '') AS source_sku_id, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceCategory'), ''), '') AS source_category, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceItemType'), ''), '') AS source_item_type, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') AS source_sales_status, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceEnabled'), ''), '') AS source_enabled, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceStockDisabled'), ''), '') AS source_stock_disabled, + CASE + WHEN jsonb_typeof(product."payload"::jsonb -> 'skuItems') = 'array' + THEN jsonb_array_length(product."payload"::jsonb -> 'skuItems') + ELSE 0 + END AS stock_sku_count, + COALESCE(NULLIF(btrim(product."payload"::jsonb -> 'metadata' ->> 'sourceSalesStatus'), ''), '') = '销售' AS stock_has_on_sale_sku + FROM "IntakeNonIntegratedProduct" product + WHERE product."deletedAt" IS NULL + ) + SELECT COUNT(*)::integer AS row_count + FROM payload_stock_source payload + JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = payload.product_id + WHERE COALESCE(profile."sourceSpu", '') IS DISTINCT FROM payload.source_spu + OR COALESCE(profile."sourceSkuId", '') IS DISTINCT FROM payload.source_sku_id + OR COALESCE(profile."sourceCategory", '') IS DISTINCT FROM payload.source_category + OR COALESCE(profile."sourceItemType", '') IS DISTINCT FROM payload.source_item_type + OR COALESCE(profile."sourceSalesStatus", '') IS DISTINCT FROM payload.source_sales_status + OR COALESCE(profile."sourceEnabled", '') IS DISTINCT FROM payload.source_enabled + OR COALESCE(profile."sourceStockDisabled", '') IS DISTINCT FROM payload.source_stock_disabled + OR COALESCE(profile."stockSkuCount", 0) IS DISTINCT FROM payload.stock_sku_count + OR COALESCE(profile."stockHasOnSaleSku", false) IS DISTINCT FROM payload.stock_has_on_sale_sku + `) + return result.rows[0]?.row_count ?? 0 +} + +async function getExternalProductSkusSummary(client) { + const exists = await relationExists(client, 'external.product_skus_v1') + if (!exists) { + return { exists: false } + } + + const result = await client.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(sku_code, '')), '') IS NULL)::integer AS missing_sku_code, + COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(sku_name, '')), '') IS NULL)::integer AS missing_sku_name, + COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(spec_code, '')), '') IS NULL)::integer AS missing_spec_code, + COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(barcode, '')), '') IS NULL)::integer AS missing_barcode, + COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL)::integer AS missing_product_business_key, + COUNT(*) FILTER (WHERE NULLIF(btrim(COALESCE(sku_business_key, '')), '') IS NULL)::integer AS missing_sku_business_key, + ( + SELECT COUNT(*)::integer + FROM ( + SELECT sku_business_key + FROM external.product_skus_v1 + GROUP BY sku_business_key + HAVING COUNT(*) > 1 + ) duplicate_groups + ) AS duplicate_sku_business_key_groups + FROM external.product_skus_v1 + `) + return { + exists: true, + ...result.rows[0], + } +} + +async function getExternalProductDetailViewsSummary(client) { + const productAssetsExists = await relationExists(client, 'external.product_assets_v1') + const productBomExists = await relationExists(client, 'external.product_bom_v1') + if (!productAssetsExists || !productBomExists) { + return { exists: false, productAssetsExists, productBomExists } + } + + const result = await client.query(` + SELECT + (SELECT COUNT(*)::integer FROM external.product_assets_v1) AS product_assets_total, + ( + SELECT COUNT(*)::integer + FROM external.product_assets_v1 + WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL + ) AS product_assets_missing_product_business_key, + ( + SELECT COUNT(*)::integer + FROM external.product_assets_v1 + WHERE NULLIF(btrim(COALESCE(sku_business_key, '')), '') IS NULL + ) AS product_assets_missing_sku_business_key, + (SELECT COUNT(*)::integer FROM external.product_bom_v1) AS product_bom_total, + ( + SELECT COUNT(*)::integer + FROM external.product_bom_v1 + WHERE NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NULL + ) AS product_bom_missing_product_business_key, + ( + SELECT COUNT(*)::integer + FROM external.product_bom_v1 + WHERE sku_id IS NOT NULL + AND NULLIF(btrim(COALESCE(sku_business_key, '')), '') IS NULL + ) AS product_bom_missing_sku_business_key_with_sku_id + `) + return { + exists: true, + ...result.rows[0], + } +} + +async function getProductPatentRecordSummary(client) { + const result = await client.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE NULLIF(btrim("productId"), '') IS NULL)::integer AS missing_product_id, + COUNT(*) FILTER (WHERE NULLIF(btrim("patentGroupKey"), '') IS NULL)::integer AS missing_group_key, + COUNT(*) FILTER (WHERE NULLIF(btrim("patentName"), '') IS NULL AND NULLIF(btrim("patentNo"), '') IS NULL AND NULLIF(btrim("applicationNo"), '') IS NULL)::integer AS missing_patent_identity, + COUNT(*) FILTER (WHERE btrim(id) ~ '^patent-[0-9]+-[A-Za-z0-9]+$')::integer AS temporary_id_count + FROM "ProductPatentRecord" + `) + return result.rows[0] +} + +async function getIntakeReferenceLibrarySummary(client) { + const result = await client.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE "isActive" = true)::integer AS active, + COUNT(*) FILTER (WHERE NULLIF(btrim("itemKey"), '') IS NULL)::integer AS missing_item_key, + COUNT(*) FILTER (WHERE NULLIF(btrim("code"), '') IS NULL)::integer AS missing_code + FROM "IntakeReferenceLibraryItem" + `) + return result.rows[0] +} + +async function getSpuNumberCounterSummary(client) { + const exists = await relationExists(client, '"SpuNumberCounter"') + if (!exists) { + return { exists: false } + } + + const result = await client.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE NULLIF(btrim("productCategory"), '') IS NULL)::integer AS missing_product_category, + COUNT(*) FILTER (WHERE NULLIF(btrim("prefix"), '') IS NULL)::integer AS missing_prefix, + ( + SELECT COUNT(*)::integer + FROM ( + SELECT upper(btrim("prefix")) AS normalized_prefix + FROM "SpuNumberCounter" + WHERE NULLIF(btrim("prefix"), '') IS NOT NULL + GROUP BY upper(btrim("prefix")) + HAVING COUNT(*) > 1 + ) duplicate_prefixes + ) AS duplicate_prefix_groups, + COUNT(*) FILTER (WHERE "lastUsedIndex" < 0)::integer AS invalid_index + FROM "SpuNumberCounter" + `) + return { + exists: true, + ...result.rows[0], + } +} + +async function getExternalViewJsonSourceDependencyRisks(client) { + const result = await client.query(` + SELECT + 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 + `) + return result.rows +} + +async function getExternalViewIdFallbackRisks(client) { + const result = await client.query(` + SELECT + viewname AS view_name, + pg_get_viewdef(format('%I.%I', schemaname, viewname)::regclass) AS view_definition + FROM pg_views + WHERE schemaname = 'external' + ORDER BY viewname + `) + const risks = [] + + for (const row of result.rows) { + const viewDefinition = normalizeSqlFragment(row.view_definition) + const aliasPattern = /\bAS\s+([a-z_]+(?:_code|_business_key))\b/gi + for (const match of viewDefinition.matchAll(aliasPattern)) { + const aliasStart = match.index ?? 0 + const expressionStart = Math.max( + viewDefinition.lastIndexOf(',', aliasStart), + viewDefinition.lastIndexOf(' SELECT ', aliasStart), + viewDefinition.lastIndexOf(' UNION ALL SELECT ', aliasStart), + ) + const expression = viewDefinition.slice(expressionStart + 1, aliasStart).trim() + if (!/(?:^|[^\w])(?:[a-z_]+\.)?"?id"?(?:[^\w]|$)/i.test(expression)) { + continue + } + risks.push({ + view_name: row.view_name, + column_name: match[1], + risk: 'id_fallback_in_business_column', + expression, + }) + } + } + + return risks +} + +async function getExternalIntakeProductPeopleSourceRisks(client) { + const exists = await relationExists(client, 'external.intake_products_v1') + if (!exists) { + return [{ view_name: 'intake_products_v1', column_name: '*', risk: 'missing_view' }] + } + + const result = await client.query(` + SELECT pg_get_viewdef('external.intake_products_v1'::regclass) AS view_definition + `) + const viewDefinition = normalizeSqlFragment(result.rows[0]?.view_definition) + const requiredSources = [ + { + columnName: 'designer', + sourcePattern: /profile\."structureDesigner"/, + risk: 'designer_must_prefer_profile_structure_designer', + }, + { + columnName: 'operator', + sourcePattern: /profile\."productPlanning"/, + risk: 'operator_must_prefer_profile_product_planning', + }, + { + columnName: 'purchaser', + sourcePattern: /profile\."purchaseOwner"/, + risk: 'purchaser_must_prefer_profile_purchase_owner', + }, + ] + + return requiredSources + .filter((source) => !source.sourcePattern.test(viewDefinition)) + .map((source) => ({ + view_name: 'intake_products_v1', + column_name: source.columnName, + risk: source.risk, + })) +} + +async function getLegacyCadStructureSummary(client) { + const result = await client.query(` + WITH panel_duplicate_code_scope_risk AS ( + SELECT COUNT(*)::integer AS row_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 + ) + SELECT + ( + SELECT COUNT(*)::integer + 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 ("partType" IS NOT NULL AND NULLIF(btrim("partType"), '') IS NULL) + OR ("explosionDirection" IS NOT NULL AND "explosionDirection" NOT IN ('auto', 'up', 'down', 'left', 'right', 'front', 'back')) + OR ("assemblySemantic" IS NOT NULL AND jsonb_typeof("assemblySemantic") NOT IN ('object', 'null')) + ) AS panel_identity_dimension_risk, + (SELECT row_count FROM panel_duplicate_code_scope_risk) AS panel_duplicate_code_scope_risk, + ( + SELECT COUNT(*)::integer + 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) + ) AS hole_geometry_quality_risk, + ( + SELECT COUNT(*)::integer + 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) + ) AS product_hardware_quality_risk, + ( + SELECT COUNT(*)::integer + 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 EXISTS (SELECT 1 FROM unnest("hardwareIds") AS item WHERE NULLIF(btrim(item), '') IS NULL) + OR cardinality("hardwareIds") <> (SELECT COUNT(DISTINCT btrim(item))::integer FROM unnest("hardwareIds") AS item) + OR EXISTS (SELECT 1 FROM unnest("partIds") AS item WHERE NULLIF(btrim(item), '') IS NULL) + OR cardinality("partIds") <> (SELECT COUNT(DISTINCT btrim(item))::integer FROM unnest("partIds") AS item) + ) AS assembly_step_quality_risk, + ( + SELECT COUNT(*)::integer + 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) + ) AS assembly_version_snapshot_risk + `) + return result.rows[0] +} + +async function getReferenceOptionAndMappingSummary(client) { + const result = await client.query(` + WITH duplicate_reference_option_names AS ( + SELECT COUNT(*)::integer AS row_count + FROM ( + SELECT 'ProductCategory' AS table_name, lower(btrim(name)) AS normalized_name FROM "ProductCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + UNION ALL SELECT 'MaterialCategory', lower(btrim(name)) FROM "MaterialCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + UNION ALL SELECT 'HardwareCategory', lower(btrim(name)) FROM "HardwareCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + UNION ALL SELECT 'CommonComponentCategory', lower(btrim(name)) FROM "CommonComponentCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + UNION ALL SELECT 'SubstrateCategory', lower(btrim(name)) FROM "SubstrateCategory" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + UNION ALL SELECT 'SubstrateEnvironmentGrade', lower(btrim(name)) FROM "SubstrateEnvironmentGrade" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + UNION ALL SELECT 'SubstrateSurfaceOption', lower(btrim(name)) FROM "SubstrateSurfaceOption" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + UNION ALL SELECT 'SubstrateSystemNameOption', lower(btrim(name)) FROM "SubstrateSystemNameOption" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + UNION ALL SELECT 'SubstrateThicknessSpec', lower(btrim(name)) FROM "SubstrateThicknessSpec" GROUP BY lower(btrim(name)) HAVING COUNT(*) > 1 + ) duplicated + ) + SELECT + ( + SELECT COUNT(*)::integer + FROM ( + SELECT name, "sortOrder", description, icon FROM "ProductCategory" + UNION ALL SELECT name, "sortOrder", NULL::text AS description, icon FROM "MaterialCategory" + UNION ALL SELECT name, "sortOrder", description, icon FROM "HardwareCategory" + UNION ALL SELECT name, "sortOrder", description, icon FROM "CommonComponentCategory" + UNION ALL SELECT name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateCategory" + UNION ALL SELECT name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateEnvironmentGrade" + UNION ALL SELECT name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateSurfaceOption" + UNION ALL SELECT name, "sortOrder", NULL::text AS description, NULL::text AS icon FROM "SubstrateSystemNameOption" + ) options + 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) + ) AS reference_option_identity_risk, + (SELECT row_count FROM duplicate_reference_option_names) AS duplicate_reference_option_name_risk, + ( + 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 + ) AS substrate_thickness_spec_risk, + ( + SELECT COUNT(*)::integer + 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") + ) AS hardware_mapping_identity_risk, + ( + SELECT COUNT(*)::integer + FROM "SubstrateFinish" + WHERE NULLIF(btrim("substrateId"), '') IS NULL + OR NULLIF(btrim("finishId"), '') IS NULL + ) AS substrate_finish_relation_risk, + ( + SELECT COUNT(*)::integer + FROM "PanelMaterial" + WHERE NULLIF(btrim("panelId"), '') IS NULL + OR NULLIF(btrim("materialId"), '') IS NULL + ) AS panel_material_relation_risk, + ( + SELECT COUNT(*)::integer + FROM "AssetVariantLocationBinding" + WHERE NULLIF(btrim("assetVariantId"), '') IS NULL + OR NULLIF(btrim("warehouseLocationId"), '') IS NULL + ) AS asset_variant_location_binding_risk, + ( + SELECT COUNT(*)::integer + FROM "PreSkuLocationBinding" + WHERE NULLIF(btrim("preSkuId"), '') IS NULL + OR NULLIF(btrim("warehouseLocationId"), '') IS NULL + ) AS pre_sku_location_binding_risk, + ( + SELECT COUNT(*)::integer + 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) + ) AS packaging_plan_quality_risk + `) + return result.rows[0] +} + +async function getConfigAuditPermissionSummary(client) { + const result = await client.query(` + SELECT + ( + SELECT COUNT(*)::integer + FROM "SystemConfig" + WHERE NULLIF(btrim(key), '') IS NULL + OR key <> btrim(key) + OR (description IS NOT NULL AND NULLIF(btrim(description), '') IS NULL) + ) AS system_config_identity_risk, + ( + SELECT COUNT(*)::integer + 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') + ) AS provider_config_identity_risk, + ( + SELECT COUNT(*)::integer + FROM ( + SELECT "serviceType" + FROM "ProviderConfig" + WHERE "isActive" = true + GROUP BY "serviceType" + HAVING COUNT(*) > 1 + ) duplicated_active + ) AS provider_config_active_conflict_risk, + ( + SELECT COUNT(*)::integer + 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') + ) AS theme_config_identity_risk, + ( + SELECT COUNT(*)::integer + FROM ( + SELECT lower(btrim(name)) + FROM "ThemeConfig" + GROUP BY lower(btrim(name)) + HAVING COUNT(*) > 1 + ) duplicated_names + ) AS theme_config_duplicate_name_risk, + ( + SELECT GREATEST(COUNT(*)::integer - 1, 0) + FROM "ThemeConfig" + WHERE "isDefault" = true + ) AS theme_config_default_conflict_risk, + ( + SELECT COUNT(*)::integer + 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) + ) AS intake_node_permission_override_identity_risk, + ( + SELECT COUNT(*)::integer + 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) + ) AS assisted_assembly_ai_render_quality_risk, + ( + SELECT COUNT(*)::integer + 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 array_position(images, '') IS NOT NULL + 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)) + ) AS feedback_quality_risk, + ( + SELECT COUNT(*)::integer + 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') + ) AS activity_log_quality_risk + `) + return result.rows[0] +} + +async function getMasterDataQualityDecisionSummary(client) { + if (!await relationExists(client, '"MasterDataQualityDecision"')) { + return { + tableExists: false, + openApprovedIncomplete: 0, + resolved: 0, + blankReasonRisk: 0, + placeholderReasonRisk: 0, + placeholderDecidedByRisk: 0, + placeholderResolvedByRisk: 0, + duplicateOpenTargetRisk: 0, + } + } + + const result = await client.query(` + WITH placeholder_tokens AS ( + SELECT unnest(ARRAY[ + 'unknown', 'unk', 'n/a', 'na', 'null', 'none', 'todo', 'tbd', + 'default', 'test', '测试', '待补', '待补齐', '待确认', '未知', + '默认', '默认值', '暂无' + ]) AS token + ) + SELECT + COUNT(*) FILTER (WHERE "status" = 'APPROVED_INCOMPLETE')::integer AS open_approved_incomplete, + COUNT(*) FILTER (WHERE "status" = 'RESOLVED')::integer AS resolved, + COUNT(*) FILTER ( + WHERE NULLIF(btrim(COALESCE("reason", '')), '') IS NULL + OR NULLIF(btrim(COALESCE("decidedBy", '')), '') IS NULL + )::integer AS blank_reason_risk, + COUNT(*) FILTER ( + WHERE NULLIF(btrim(COALESCE("reason", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("reason"), '\\s+', '', 'g')) IN ( + SELECT token FROM placeholder_tokens + ) + OR regexp_replace(btrim("reason"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + )::integer AS placeholder_reason_risk, + COUNT(*) FILTER ( + WHERE NULLIF(btrim(COALESCE("decidedBy", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("decidedBy"), '\\s+', '', 'g')) IN ( + SELECT token FROM placeholder_tokens + ) + OR regexp_replace(btrim("decidedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + )::integer AS placeholder_decided_by_risk, + COUNT(*) FILTER ( + WHERE NULLIF(btrim(COALESCE("resolvedBy", '')), '') IS NOT NULL + AND ( + lower(regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g')) IN ( + SELECT token FROM placeholder_tokens + ) + OR regexp_replace(btrim("resolvedBy"), '\\s+', '', 'g') ~ '(待补|待确认|默认值)' + ) + )::integer AS placeholder_resolved_by_risk, + ( + SELECT COUNT(*)::integer + FROM ( + SELECT "targetType", "targetId" + FROM "MasterDataQualityDecision" + WHERE "status" = 'APPROVED_INCOMPLETE' + GROUP BY "targetType", "targetId" + HAVING COUNT(*) > 1 + ) duplicate_open_targets + ) AS duplicate_open_target_risk + FROM "MasterDataQualityDecision" + `) + const row = result.rows[0] + return { + tableExists: true, + openApprovedIncomplete: row.open_approved_incomplete, + resolved: row.resolved, + blankReasonRisk: row.blank_reason_risk, + placeholderReasonRisk: row.placeholder_reason_risk, + placeholderDecidedByRisk: row.placeholder_decided_by_risk, + placeholderResolvedByRisk: row.placeholder_resolved_by_risk, + duplicateOpenTargetRisk: row.duplicate_open_target_risk, + } +} + +async function buildReport(client, mode, appliedMigrations, migrationReplay = classifyAppliedMigrationPaths(appliedMigrations)) { + const report = { + mode, + appliedMigrations, + migrationReplay, + migrationApplicationStatus: getMigrationApplicationStatus(appliedMigrations, migrationReplay), + databaseGuards: await getDatabaseGuardSummary(client), + assetCenterSourceData: await getAssetCenterSourceDataSummary(client), + assetVariantDuplicateBarcodeGroups: await getAssetVariantDuplicateBarcodeGroupCount(client), + structuredSkuItems: await getStructuredSkuSummary(client), + incompleteSkuItemSamples: await getIncompleteSkuItemSamples(client), + duplicateSkuCodeGroups: await getDuplicateGroupCount(client, 'skuCode'), + duplicateBarcodeGroups: await getDuplicateGroupCount(client, 'barcode'), + payloadSyncDiffCount: await getPayloadSyncDiffCount(client), + productSkuAssetProfiles: await getProductSkuAssetProfileSummary(client), + productSkuMissingNameCount: await getProductSkuMissingNameCount(client), + productSkuPriceCostStockRiskCount: await getProductSkuPriceCostStockRiskCount(client), + productSkuFormalTruthAttributeRiskCount: await getProductSkuFormalTruthAttributeRiskCount(client), + productSkuWithoutAssetProfileCount: await getProductSkuWithoutAssetProfileCount(client), + productSkuProfileSnapshotDiffCount: await getProductSkuProfileSnapshotDiffCount(client), + productSkuProfileDuplicateBarcodeGroups: await getProductSkuProfileDuplicateBarcodeGroupCount(client), + productSkuProfileDeletedAtMismatchCount: await getProductSkuProfileDeletedAtMismatchCount(client), + productSkuSoftDeleteTombstoneRiskCount: await getProductSkuSoftDeleteTombstoneRiskCount(client), + preSkuMissingBasicInfoCount: await getPreSkuMissingBasicInfoCount(client), + preSkuBasicInfoSnapshotDiffCount: await getPreSkuBasicInfoSnapshotDiffCount(client), + preSkuDuplicateFormalSkuCodeGroups: await getPreSkuBasicInfoDuplicateGroupCount(client, 'formalSkuCode'), + preSkuDuplicateBarcodeGroups: await getPreSkuBasicInfoDuplicateGroupCount(client, 'barcode'), + draftGroupInvalidProcessShapeCount: await getDraftGroupInvalidProcessShapeCount(client), + preSkuInvalidProcessShapeCount: await getPreSkuInvalidProcessShapeCount(client), + preSkuFormalizationLifecycleMismatchCount: await getPreSkuFormalizationLifecycleMismatchCount(client), + preSkuFormalizationLifecycleMismatchSamples: await getPreSkuFormalizationLifecycleMismatchSamples(client), + nonIntegratedProductProfiles: await getNonIntegratedProductProfileSummary(client), + nonIntegratedProductProfileQualitySamples: await getNonIntegratedProductProfileQualitySamples(client), + nonIntegratedProductMissingProfileCount: await getNonIntegratedProductMissingProfileCount(client), + nonIntegratedProductProfilePayloadDiffCount: await getNonIntegratedProductProfilePayloadDiffCount(client), + nonIntegratedProductStockSourcePayloadDiffCount: await getNonIntegratedProductStockSourcePayloadDiffCount(client), + productPatentRecords: await getProductPatentRecordSummary(client), + intakeReferenceLibraryItems: await getIntakeReferenceLibrarySummary(client), + spuNumberCounters: await getSpuNumberCounterSummary(client), + externalIntakeSkuItemsView: await getExternalViewSummary(client), + externalIntakeReadyViews: await getExternalReadyViewSummary(client), + externalReadonlyContract: await getExternalReadonlyContractSummary(client), + externalStableKeyQuality: await getExternalStableKeyQualitySummary(client), + externalProductSkusView: await getExternalProductSkusSummary(client), + externalProductDetailViews: await getExternalProductDetailViewsSummary(client), + externalViewJsonSourceDependencyRisks: await getExternalViewJsonSourceDependencyRisks(client), + externalViewIdFallbackRisks: await getExternalViewIdFallbackRisks(client), + externalIntakeProductPeopleSourceRisks: await getExternalIntakeProductPeopleSourceRisks(client), + legacyCadStructure: await getLegacyCadStructureSummary(client), + referenceOptionAndMapping: await getReferenceOptionAndMappingSummary(client), + configAuditPermission: await getConfigAuditPermissionSummary(client), + masterDataQualityDecisions: await getMasterDataQualityDecisionSummary(client), + } + return { + ...report, + foundationHardReadyGate: buildFoundationHardReadyGate(report), + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + validateRequiredMigrationCatalog() + const databaseUrl = loadDatabaseUrl() + if (!databaseUrl) { + throw new Error('DATABASE_URL 未配置,无法验证数据库主数据地基。') + } + + const pool = new Pool({ connectionString: databaseUrl, connectionTimeoutMillis: 5000 }) + const client = await pool.connect() + let transactionStarted = false + + try { + if (args.readOnlyMigrationStatus) { + const migrationReadiness = await getRequiredMigrationReadiness(client) + writeJsonReport(args.outputFile, migrationReadiness) + console.log(JSON.stringify(migrationReadiness, null, 2)) + if (!migrationReadiness.fullyReady) { + throw new Error( + `数据库治理迁移尚未全部真实落库:${[ + ...migrationReadiness.pendingStructureMigrationPaths, + ...migrationReadiness.pendingIdempotentMigrationPaths, + ].join(', ')}`, + ) + } + return + } + + if (args.requireApplied) { + const migrationReadiness = await getRequiredMigrationReadiness(client) + if (!migrationReadiness.fullyReady) { + console.log(JSON.stringify(migrationReadiness, null, 2)) + throw new Error( + `数据库治理迁移尚未全部真实落库,严格模式拒绝通过:${[ + ...migrationReadiness.pendingStructureMigrationPaths, + ...migrationReadiness.pendingIdempotentMigrationPaths, + ].join(', ')}`, + ) + } + } + + await client.query('BEGIN') + transactionStarted = true + const migrationReadinessBeforeReplay = await getRequiredMigrationReadiness(client) + const appliedMigrations = shouldApplyMissingMigrationFiles(migrationReadinessBeforeReplay) + ? await applyMissingMigrationFiles(client) + : [] + const migrationReplay = classifyAppliedMigrationPaths(appliedMigrations) + const mode = migrationReplay.structureMigrations.length > 0 + ? 'migration-dry-run' + : migrationReplay.idempotentMigrations.length > 0 + ? 'idempotent-migration-replay' + : 'current' + const migrationApplicationStatus = getMigrationApplicationStatus( + appliedMigrations, + migrationReplay, + ) + if (args.requireApplied && !migrationApplicationStatus.strictReady) { + const blockedReport = { + mode, + appliedMigrations, + migrationReplay, + migrationApplicationStatus, + rolledBack: true, + } + await client.query('ROLLBACK') + writeFoundationHardReadyOutputs(args, blockedReport) + printFoundationHardReadyReport(args, blockedReport) + assertNoPendingMigrationDryRun(appliedMigrations, args) + } + assertNoPendingMigrationDryRun(appliedMigrations, args) + + const report = await buildReport(client, mode, appliedMigrations, migrationReplay) + await client.query('ROLLBACK') + const reportWithRollback = { ...report, rolledBack: true } + writeFoundationHardReadyOutputs(args, reportWithRollback) + printFoundationHardReadyReport(args, reportWithRollback) + if (report.databaseGuards.missingOrInvalid.length > 0) { + throw new Error('数据库主数据物理护栏缺失或失效,详见 databaseGuards.missingOrInvalid。') + } + if ( + !report.externalIntakeSkuItemsView.exists || + !report.externalIntakeReadyViews.exists || + report.externalReadonlyContract.missingViews.length > 0 || + !report.externalProductSkusView.exists || + !report.externalProductDetailViews.exists + ) { + throw new Error('外部只读核心视图缺失,详见 externalIntakeSkuItemsView / externalIntakeReadyViews / externalReadonlyContract / externalProductSkusView / externalProductDetailViews。') + } + if (report.externalReadonlyContract.missingColumns.length > 0) { + throw new Error('外部只读视图缺少字段字典承诺列,详见 externalReadonlyContract.missingColumns。') + } + if (report.externalReadonlyContract.extraColumns.length > 0) { + throw new Error('外部只读视图暴露了字段字典未登记列,详见 externalReadonlyContract.extraColumns。') + } + if ( + report.externalStableKeyQuality.missingRequired.length > 0 || + report.externalStableKeyQuality.duplicateGroups.length > 0 + ) { + throw new Error('外部只读视图稳定业务键缺失或重复,详见 externalStableKeyQuality。') + } + if (report.externalViewJsonSourceDependencyRisks.length > 0) { + throw new Error('外部只读视图仍依赖内部 JSON 源,详见 externalViewJsonSourceDependencyRisks。') + } + if (report.externalViewIdFallbackRisks.length > 0) { + throw new Error('外部只读视图业务编码字段仍使用内部 id 兜底,详见 externalViewIdFallbackRisks。') + } + if ( + report.externalProductSkusView.missing_product_business_key > 0 || + report.externalProductSkusView.missing_sku_business_key > 0 + ) { + throw new Error('外部正式 SKU 视图缺少稳定业务键,详见 externalProductSkusView。') + } + if (report.externalProductSkusView.duplicate_sku_business_key_groups > 0) { + throw new Error('外部正式 SKU 视图存在重复稳定业务键,详见 externalProductSkusView。') + } + if ( + report.externalProductDetailViews.product_assets_missing_product_business_key > 0 || + report.externalProductDetailViews.product_assets_missing_sku_business_key > 0 || + report.externalProductDetailViews.product_bom_missing_product_business_key > 0 || + report.externalProductDetailViews.product_bom_missing_sku_business_key_with_sku_id > 0 + ) { + throw new Error('外部正式产品素材/BOM 明细视图缺少稳定业务键,详见 externalProductDetailViews。') + } + if (report.externalIntakeProductPeopleSourceRisks.length > 0) { + throw new Error( + '外部非集成产品负责人字段没有优先读取结构化 profile,详见 externalIntakeProductPeopleSourceRisks。', + ) + } + if (report.externalIntakeReadyViews.ready_sku_quality_violations > 0) { + throw new Error('外部 ready SKU 视图混入非 READY 明细,详见 externalIntakeReadyViews.ready_sku_quality_violations。') + } + if ( + report.externalIntakeReadyViews.ready_product_business_key_missing > 0 || + report.externalIntakeReadyViews.ready_sku_business_key_missing > 0 + ) { + throw new Error('外部 ready 视图缺少稳定业务键,详见 externalIntakeReadyViews。') + } + if ( + report.externalIntakeReadyViews.ready_product_business_key_duplicate_groups > 0 || + report.externalIntakeReadyViews.ready_sku_business_key_duplicate_groups > 0 + ) { + throw new Error('外部 ready 视图存在重复稳定业务键,详见 externalIntakeReadyViews。') + } + if (report.productPatentRecords.temporary_id_count > 0) { + throw new Error('专利台账仍存在草稿态临时随机 id,详见 productPatentRecords.temporary_id_count。') + } + assertNoMasterDataTruthDrift(report) + assertFoundationHardReadyGate(report) + } catch (error) { + if (transactionStarted) { + await client.query('ROLLBACK').catch(() => undefined) + } + throw error + } finally { + client.release() + await pool.end() + } +} + +function formatError(error) { + if (!(error instanceof Error)) { + return String(error) + } + + const details = [ + error.message, + error.name ? `name=${error.name}` : '', + error.code ? `code=${error.code}` : '', + error.cause ? `cause=${error.cause}` : '', + ].filter(Boolean) + + return details.length > 0 ? details.join('\n') : error.stack || String(error) +} + +if (require.main === module) { + main().catch((error) => { + console.error(formatError(error)) + process.exit(1) + }) +} + +module.exports = { + REQUIRED_MIGRATIONS, + REQUIRED_DATABASE_GUARDS, + parseArgs, + assertNoPendingMigrationDryRun, + getMigrationApplicationStatus, + shouldApplyMissingMigrationFiles, + getRequiredMigrationReadiness, + getExternalReadonlyContractSummary, + getExternalStableKeyQualitySummary, + getExternalViewIdFallbackRisks, + getPreSkuFormalizationLifecycleMismatchSamples, + assertNoMasterDataTruthDrift, + buildFoundationHardReadyGate, + buildFoundationHardReadySummary, + assertFoundationHardReadyGate, + printFoundationHardReadyReport, + getStructuredSkuSummary, + getNonIntegratedProductProfileSummary, + getDuplicateGroupCount, + buildReport, + getMigrationHistoryStatus, + getPendingStructureMigrationPaths, + validateRequiredMigrationCatalog, +} diff --git a/furniture-3d-system/scripts/verify-master-data-readiness.js b/furniture-3d-system/scripts/verify-master-data-readiness.js new file mode 100644 index 00000000..9daf44cb --- /dev/null +++ b/furniture-3d-system/scripts/verify-master-data-readiness.js @@ -0,0 +1,899 @@ +const { spawnSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') +const { hasSqlDiff } = 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 COMMANDS = [ + { + id: 'prisma-validate', + label: 'Prisma schema 校验', + command: process.execPath, + args: [PRISMA_CLI, 'validate'], + }, + { + id: 'prisma-migrate-status', + label: 'Prisma 官方迁移状态', + command: process.execPath, + args: [PRISMA_CLI, 'migrate', 'status'], + }, + { + id: 'current-schema-diff', + label: '当前库到 Prisma schema 差异', + command: process.execPath, + args: [ + PRISMA_CLI, + 'migrate', + 'diff', + '--from-config-datasource', + '--to-schema', + 'prisma/schema.prisma', + '--script', + '--exit-code', + ], + }, + { + id: 'truth-sources', + label: '真相源矩阵', + command: process.execPath, + args: ['scripts/verify-master-data-truth-sources.js'], + }, + { + id: 'code-boundaries', + label: '代码层主数据边界', + command: process.execPath, + args: ['scripts/verify-master-data-code-boundaries.js'], + }, + { + id: 'compatibility-snapshot-read', + label: '兼容快照读点专项', + command: process.execPath, + args: ['scripts/verify-master-data-code-boundaries.js', '--mode', 'compatibility-snapshot-read'], + }, + { + id: 'process-snapshot-read', + label: '过程快照读点专项', + command: process.execPath, + args: ['scripts/verify-master-data-code-boundaries.js', '--mode', 'process-snapshot-read'], + }, + { + id: 'product-sku-attributes-write', + label: '正式 SKU attributes 写入专项', + command: process.execPath, + args: ['scripts/verify-master-data-code-boundaries.js', '--mode', 'product-sku-attributes-write'], + }, + { + id: 'reference-estimator-cost-boundary', + label: '参考图预估成本边界专项', + command: process.execPath, + args: [ + 'scripts/verify-master-data-code-boundaries.js', + '--mode', + 'reference-estimator-cost-boundary', + ], + }, + { + id: 'filesystem-master-data-source', + label: '文件系统反向造数边界专项', + command: process.execPath, + args: [ + 'scripts/verify-master-data-code-boundaries.js', + '--mode', + 'filesystem-master-data-source', + ], + }, + { + id: 'migration-status', + label: '治理迁移真实落库状态', + command: process.execPath, + args: ['scripts/verify-master-data-foundation.js', '--read-only-migration-status'], + }, + { + id: 'foundation-dry-run', + label: '数据库地基 dry-run 巡检', + command: process.execPath, + args: ['scripts/verify-master-data-foundation.js'], + }, +] + +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) { + throw new Error('--output 需要提供输出文件路径。') + } + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('--output 需要提供输出文件路径。') + } + continue + } + throw new Error(`未知参数:${arg}`) + } + + return options +} + +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 extractJson(stdout) { + const text = String(stdout ?? '').trim() + if (!text) return null + const jsonStartLineIndex = text + .split(/\r?\n/) + .findIndex((line) => { + const trimmed = line.trim() + return trimmed.startsWith('{') || trimmed.startsWith('[') + }) + if (jsonStartLineIndex === -1) return null + + const jsonText = text.split(/\r?\n/).slice(jsonStartLineIndex).join('\n').trim() + if (jsonText.startsWith('[')) return JSON.parse(jsonText) + + const firstBrace = jsonText.indexOf('{') + const lastBrace = jsonText.lastIndexOf('}') + if (firstBrace === -1 || lastBrace === -1 || lastBrace < firstBrace) { + return null + } + + return JSON.parse(jsonText.slice(firstBrace, lastBrace + 1)) +} + +function runCommand(commandConfig) { + const result = spawnSync(commandConfig.command, commandConfig.args, { + cwd: ROOT_DIR, + encoding: 'utf8', + windowsHide: true, + }) + + return evaluateCommandResult(commandConfig, result) +} + +function evaluateCommandResult(commandConfig, result) { + let data = null + let jsonError = '' + + try { + data = extractJson(result.stdout) + } catch (error) { + jsonError = error instanceof Error ? error.message : String(error) + } + + return { + id: commandConfig.id, + label: commandConfig.label, + command: [commandConfig.command, ...commandConfig.args].join(' '), + ok: result.status === 0, + status: result.status, + data, + jsonError, + error: result.error ? String(result.error.message ?? result.error) : '', + stdout: String(result.stdout ?? '').trim(), + stderr: String(result.stderr ?? '').trim(), + } +} + +function countPendingPrismaMigrations(stdout) { + return listPendingPrismaMigrations(stdout).length +} + +function listPendingPrismaMigrations(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 migrations = [] + 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 + migrations.push(line) + } + return migrations +} + +function addPositiveCounterBlocker(blockers, source, data, keyPath, message) { + const value = keyPath.reduce((current, key) => current?.[key], data) + if (typeof value === 'number' && value > 0) { + const blocker = { + source, + key: keyPath.join('.'), + value, + message, + } + blockers.push(blocker) + return blocker + } + return null +} + +function addNonEmptyArrayBlocker(blockers, source, data, keyPath, message) { + const value = keyPath.reduce((current, key) => current?.[key], data) + if (Array.isArray(value) && value.length > 0) { + blockers.push({ + source, + key: keyPath.join('.'), + value: value.length, + message, + }) + } +} + +function summarizeSamples(samples = [], limit = 5) { + if (!Array.isArray(samples)) return { sampleCount: 0, samples: [] } + return { + sampleCount: samples.length, + samples: samples.slice(0, limit), + truncated: samples.length > limit, + } +} + +function summarizeSamplesByMissingField(samples = [], fieldName, limit = 5) { + if (!Array.isArray(samples)) return { sampleCount: 0, samples: [] } + const matchedSamples = samples.filter((sample) => + Array.isArray(sample?.missingFields) && sample.missingFields.includes(fieldName), + ) + return summarizeSamples(matchedSamples, limit) +} + +function summarizePaths(paths = [], limit = 20) { + if (!Array.isArray(paths)) return { pathCount: 0, paths: [] } + return { + pathCount: paths.length, + paths: paths.slice(0, limit), + truncated: paths.length > limit, + } +} + +function summarizeSqlDiff(stdout = '', limit = 5) { + const meaningfulLines = String(stdout ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => + line && + !line.startsWith('--') && + !line.startsWith('Loaded Prisma config') && + !line.startsWith('Prisma schema loaded'), + ) + const normalizedSql = meaningfulLines.join('\n') + const statements = normalizedSql.match(/\b(?:CREATE|ALTER|DROP)\b[\s\S]*?;/gi) ?? [] + const previews = statements.map((statement) => statement.replace(/\s+/g, ' ').trim()) + + if (previews.length > 0) { + return { + sqlStatementCount: previews.length, + statements: previews.slice(0, limit), + truncated: previews.length > limit, + } + } + + return { + sqlStatementCount: 0, + statements: meaningfulLines.slice(0, limit), + truncated: meaningfulLines.length > limit, + } +} + +function buildMissingMigrationHistoryEvidence(data = {}) { + return { + migrationHistory: { + tableExists: false, + totalMigrationRows: Number(data.migrationHistory?.totalMigrationRows ?? 0), + unfinishedMigrationRows: Number(data.migrationHistory?.unfinishedMigrationRows ?? 0), + rolledBackMigrationRows: Number(data.migrationHistory?.rolledBackMigrationRows ?? 0), + }, + requiredSequence: [ + '重新生成可校验数据库备份。', + '重新运行数据库地基 preflight,确认目标库身份和结构 diff。', + '结构 diff 未归零前,先走授权结构修复演练和 apply-structure。', + '当前库到 schema、migrations 到当前库的 diff 都归零后,再按演练清单逐项 baseline resolve。', + 'baseline resolve 完成后,再应用治理护栏迁移并重跑 readiness。', + ], + forbiddenShortcut: '禁止在结构 diff 未归零或没有演练证据时直接整批 migrate resolve / migrate deploy。', + } +} + +function collectCommandFailureBlockers(results) { + return results + .filter((result) => !result.ok) + .map((result) => ({ + source: result.id, + key: 'command', + value: result.status, + message: `${result.label} 未通过。`, + })) +} + +function collectMigrationBlockers(migrationStatus) { + if (!migrationStatus?.data) { + return [{ + source: 'migration-status', + key: 'data', + value: null, + message: '没有拿到治理迁移只读状态 JSON,不能判断真实落库状态。', + }] + } + + const data = migrationStatus.data + const blockers = [] + if (!data.migrationHistory?.tableExists) { + blockers.push({ + source: 'migration-status', + key: 'migrationHistory.tableExists', + value: false, + message: '数据库缺少 Prisma _prisma_migrations 历史,不能把现有结构视为可审计迁移结果。', + evidence: buildMissingMigrationHistoryEvidence(data), + }) + } + addPositiveCounterBlocker( + blockers, + 'migration-status', + data, + ['migrationHistory', 'unfinishedMigrationRows'], + '存在未完成的 Prisma migration。', + ) + addPositiveCounterBlocker( + blockers, + 'migration-status', + data, + ['migrationHistory', 'rolledBackMigrationRows'], + '存在已回滚的 Prisma migration。', + ) + const pendingStructureBlocker = addPositiveCounterBlocker( + blockers, + 'migration-status', + data, + ['pendingStructureMigrationCount'], + '仍有结构治理迁移没有真实落库。', + ) + if (pendingStructureBlocker) { + pendingStructureBlocker.evidence = summarizePaths(data.pendingStructureMigrationPaths) + } + const pendingIdempotentBlocker = addPositiveCounterBlocker( + blockers, + 'migration-status', + data, + ['pendingIdempotentMigrationCount'], + '仍有幂等视图或护栏迁移没有真实存在,strict 前不能进入历史清洗。', + ) + if (pendingIdempotentBlocker) { + pendingIdempotentBlocker.evidence = summarizePaths(data.pendingIdempotentMigrationPaths) + } + if (data.fullyReady !== true) { + blockers.push({ + source: 'migration-status', + key: 'fullyReady', + value: data.fullyReady, + message: '治理迁移只读状态未达到 fullyReady。', + }) + } + return blockers +} + +function collectPrismaMigrateStatusBlockers(prismaMigrateStatus) { + if (!prismaMigrateStatus) return [] + + const pendingMigrations = listPendingPrismaMigrations(prismaMigrateStatus.stdout) + const pendingCount = pendingMigrations.length + if (pendingCount <= 0) return [] + + return [{ + source: 'prisma-migrate-status', + key: 'pendingMigrations', + value: pendingCount, + message: 'Prisma 官方迁移状态仍显示未应用 migration,数据库历史不能视为稳固。', + evidence: { + migrationCount: pendingCount, + migrations: pendingMigrations.slice(0, 20), + truncated: pendingCount > 20, + }, + }] +} + +function collectCurrentSchemaDiffBlockers(currentSchemaDiff) { + if (!currentSchemaDiff) return [] + + if (hasSqlDiff(currentSchemaDiff.stdout)) { + return [{ + source: 'current-schema-diff', + key: 'hasSqlDiff', + value: true, + message: '当前数据库结构和 schema.prisma 不一致,底层结构未补齐。', + evidence: summarizeSqlDiff(currentSchemaDiff.stdout), + }] + } + + if (!currentSchemaDiff.ok) { + return [{ + source: 'current-schema-diff', + key: 'command', + value: currentSchemaDiff.status, + message: '当前库到 schema.prisma 的 diff 执行失败,不能证明结构一致。', + }] + } + + return [] +} + +function collectDryRunQualityBlockers(dryRun) { + if (!dryRun?.data) { + return [{ + source: 'foundation-dry-run', + key: 'data', + value: null, + message: '没有拿到数据库地基 dry-run JSON,不能判断历史质量阻断项。', + }] + } + + const blockers = [] + const data = dryRun.data + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['assetCenterSourceData', 'active_asset_families_without_series'], + '仍有活动资产族缺少系列归属,资产中心源数据不能视为十分稳固。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['assetCenterSourceData', 'active_asset_variants_without_model_url'], + '仍有活动规格款缺少模型文件,资产中心源数据不能视为十分稳固。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['assetCenterSourceData', 'active_asset_variants_without_thumbnail_url'], + '仍有活动规格款缺少预览图,资产中心源数据不能视为十分稳固。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['assetVariantDuplicateBarcodeGroups'], + '资产中心规格款存在重复条码组,源头条码池不能视为十分稳固。', + ) + const incompleteSkuBlocker = addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['structuredSkuItems', 'incomplete'], + '仍有非集成 SKU 明细是 INCOMPLETE,不能视为十分稳固。', + ) + if (incompleteSkuBlocker) { + incompleteSkuBlocker.evidence = summarizeSamples(data.incompleteSkuItemSamples) + } + const missingProductCategoryBlocker = addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['nonIntegratedProductProfiles', 'missing_product_category'], + '仍有非集成产品头缺品类。', + ) + if (missingProductCategoryBlocker) { + missingProductCategoryBlocker.evidence = summarizeSamplesByMissingField( + data.nonIntegratedProductProfileQualitySamples, + 'productCategory', + ) + } + const missingDevelopmentKindBlocker = addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['nonIntegratedProductProfiles', 'missing_development_kind'], + '仍有非集成产品头缺开发类型。', + ) + if (missingDevelopmentKindBlocker) { + missingDevelopmentKindBlocker.evidence = summarizeSamplesByMissingField( + data.nonIntegratedProductProfileQualitySamples, + 'productDevelopmentKind', + ) + } + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['nonIntegratedProductMissingProfileCount'], + '仍有非集成产品缺少结构化产品头 profile,不能只依赖 payload 快照。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['duplicateSkuCodeGroups'], + '非集成 SKU 明细存在重复 skuCode 组。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['duplicateBarcodeGroups'], + '非集成 SKU 明细存在重复 barcode 组。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['payloadSyncDiffCount'], + '非集成 SKU 结构化表与 payload.skuItems 快照不一致,单一真相源尚未收口。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['nonIntegratedProductProfilePayloadDiffCount'], + '非集成产品 profile 与 payload 产品头快照不一致,单一真相源尚未收口。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['nonIntegratedProductStockSourcePayloadDiffCount'], + '非集成存量来源字段 profile 与 payload.metadata 快照不一致。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['productSkuMissingNameCount'], + '仍有活动正式 SKU 缺少名称,正式商品资料不能视为十分稳固。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['productSkuPriceCostStockRiskCount'], + '仍有正式 SKU 售价、成本价或库存为负,正式成本地基不能视为十分稳固。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['productSkuFormalTruthAttributeRiskCount'], + '正式 SKU attributes 仍保存正式资料字段,JSON 兼容袋尚未退出主数据真相。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['productSkuWithoutAssetProfileCount'], + '仍有正式 SKU 缺少资产资料结构化同步副本。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['preSkuMissingBasicInfoCount'], + '仍有准 SKU 缺少基础资料结构化记录。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['productSkuProfileSnapshotDiffCount'], + '正式 SKU 资产 profile 与 attributes.assetSkuSnapshot 兼容快照不一致。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['productSkuProfileDuplicateBarcodeGroups'], + '正式 SKU 资产资料同步副本存在重复活动条码组。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['productSkuProfileDeletedAtMismatchCount'], + '正式 SKU 资产 profile 的 skuDeletedAt 与 ProductSKU.deletedAt 不一致,软删除生命周期副本尚未收口。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['productSkuSoftDeleteTombstoneRiskCount'], + '正式 SKU 软删除墓碑资料不完整,条码释放、恢复冲突检查和级联恢复不能视为可审计。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['preSkuBasicInfoSnapshotDiffCount'], + '准 SKU 基础资料结构化表与旧快照不一致。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['preSkuDuplicateFormalSkuCodeGroups'], + '准 SKU 基础资料存在重复规格款编码组。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['preSkuDuplicateBarcodeGroups'], + '准 SKU 基础资料存在重复条码组。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['draftGroupInvalidProcessShapeCount'], + '草稿组本体存在身份、可选文本或 summary JSON 形态问题。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['preSkuInvalidProcessShapeCount'], + '准 SKU 本体存在身份、过程快照、组件编码或重量形态问题。', + ) + const preSkuFormalizationBlocker = addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['preSkuFormalizationLifecycleMismatchCount'], + '准 SKU 正式化状态与正式 SKU 来源关系不一致。', + ) + if (preSkuFormalizationBlocker) { + preSkuFormalizationBlocker.evidence = summarizeSamples( + data.preSkuFormalizationLifecycleMismatchSamples, + ) + } + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['masterDataQualityDecisions', 'openApprovedIncomplete'], + '仍有开放的主数据不完整批准记录;hard-ready 前必须补齐真实主数据或关闭决策。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['masterDataQualityDecisions', 'blankReasonRisk'], + '主数据质量决策存在空原因或空确认人,审计证据不完整。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['masterDataQualityDecisions', 'placeholderReasonRisk'], + '主数据质量决策原因存在 UNKNOWN/待补齐/默认值等占位文本,不能作为真实业务确认依据。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['masterDataQualityDecisions', 'placeholderDecidedByRisk'], + '主数据质量决策确认人存在 UNKNOWN/待补齐/默认值等占位文本,审计责任链不可信。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['masterDataQualityDecisions', 'placeholderResolvedByRisk'], + '主数据质量决策解决人存在 UNKNOWN/待补齐/默认值等占位文本,关闭记录不可追溯。', + ) + addPositiveCounterBlocker( + blockers, + 'foundation-dry-run', + data, + ['masterDataQualityDecisions', 'duplicateOpenTargetRisk'], + '主数据质量决策存在同一目标多条开放记录,清洗审计链不唯一。', + ) + addNonEmptyArrayBlocker( + blockers, + 'foundation-dry-run', + data, + ['externalViewJsonSourceDependencyRisks'], + '外部只读视图仍依赖内部 JSON 源。', + ) + addNonEmptyArrayBlocker( + blockers, + 'foundation-dry-run', + data, + ['externalViewIdFallbackRisks'], + '外部只读视图业务编码字段仍使用内部 id 兜底。', + ) + addNonEmptyArrayBlocker( + blockers, + 'foundation-dry-run', + data, + ['externalIntakeProductPeopleSourceRisks'], + '外部非集成产品负责人字段没有优先读取结构化 profile。', + ) + return blockers +} + +function buildReadinessNextActionPlan(blockers = []) { + const keySet = new Set(blockers.map((blocker) => blocker.key)) + const sourceKeySet = new Set(blockers.map((blocker) => `${blocker.source}:${blocker.key}`)) + const stages = [] + + if ( + sourceKeySet.has('current-schema-diff:hasSqlDiff') || + sourceKeySet.has('migration-status:migrationHistory.tableExists') || + sourceKeySet.has('prisma-migrate-status:pendingMigrations') + ) { + stages.push({ + id: 'repair-prisma-structure-and-history', + status: 'required-before-any-cleanup', + reason: '当前库结构、Prisma 迁移历史或官方 pending migration 未对齐。', + commands: [ + 'npm run db:backup:postgres', + 'npm run db:preflight-database-foundation-change:audit', + 'npm run db:foundation:rehearse-structure-repair', + 'npm run db:foundation:apply-structure-repair:audit', + 'npm run db:foundation:apply-structure-repair -- --phase=apply-structure --apply --confirm=apply-prisma-structure-repair --expected-sha256 --expected-database-identity --expected-backup-sha256 ', + 'npm run db:preflight-database-foundation-change:audit', + 'npm run db:foundation:apply-structure-repair -- --phase=resolve-baseline --apply --confirm=apply-prisma-structure-repair --expected-sha256 --expected-database-identity --expected-backup-sha256 ', + ], + guard: '没有新鲜备份、preflight、结构修复演练和用户明确授权前,不能真实写库。', + }) + } + + if ( + keySet.has('pendingStructureMigrationCount') || + keySet.has('pendingIdempotentMigrationCount') || + keySet.has('fullyReady') + ) { + stages.push({ + id: 'apply-governance-migrations', + status: 'after-prisma-history-ready', + reason: '治理结构、外部只读视图、约束或触发器尚未真实落库。', + 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 --expected-backup-sha256 ', + 'npm run db:foundation:apply-required -- --apply --confirm=apply-master-data-foundation-migrations --expected-database-identity --expected-backup-sha256 ', + ], + guard: '必须在结构修复和 baseline resolve 完成后执行;bootstrap-repair 只处理缺失结构和登记过 repairSql 的真实回填,finalize 才应用最终治理护栏;不能代替 Prisma 结构修复。', + }) + } + + if ( + keySet.has('structuredSkuItems.incomplete') || + keySet.has('nonIntegratedProductProfiles.missing_product_category') || + keySet.has('nonIntegratedProductProfiles.missing_development_kind') || + keySet.has('masterDataQualityDecisions.openApprovedIncomplete') || + keySet.has('masterDataQualityDecisions.blankReasonRisk') || + keySet.has('masterDataQualityDecisions.placeholderReasonRisk') || + keySet.has('masterDataQualityDecisions.placeholderDecidedByRisk') || + keySet.has('masterDataQualityDecisions.placeholderResolvedByRisk') || + keySet.has('masterDataQualityDecisions.duplicateOpenTargetRisk') + ) { + stages.push({ + id: 'clean-historical-master-data-fields', + status: 'after-governance-migrations-ready', + reason: '历史 SKU 明细或产品头缺少业务确认后的结构化主数据。', + commands: [ + 'npm run db:foundation:audit:cleanup-template', + 'npm run db:cleanup-master-data:validate -- --plan --output .master-data-audits/cleanup-plan-validation-current.json', + 'npm run db:cleanup-master-data -- --plan ', + 'npm run db:cleanup-master-data -- --apply --plan --expected-database-identity --expected-backup-sha256 ', + ], + guard: + '计划只能填写业务确认后的真实值;validate-plan/resultSummary 中 approvedIncompleteRows 必须为 0,remainingMissingFieldCounts 必须为空,UNKNOWN/待补齐/默认值不能让 readiness 变绿。', + }) + } + + if (keySet.has('preSkuFormalizationLifecycleMismatchCount')) { + stages.push({ + id: 'resolve-pre-sku-formalization-lifecycle', + status: 'dedicated-plan-required', + reason: '准 SKU FORMALIZED 状态和正式 SKU 来源关系不一致。', + commands: [ + 'npm run db:foundation:plan-pre-sku-formalization-repair -- --output .master-data-audits/pre-sku-formalization-repair-plan-current.json', + 'npm run db:foundation:validate-pre-sku-formalization-repair -- --plan .master-data-audits/pre-sku-formalization-repair-plan-current.json --output .master-data-audits/pre-sku-formalization-repair-plan-validation-current.json', + 'npm run db:foundation:apply-pre-sku-formalization-repair -- --template --output .master-data-audits/pre-sku-formalization-repair-execution-template-current.json', + 'npm run db:foundation:apply-pre-sku-formalization-repair -- --plan --output .master-data-audits/pre-sku-formalization-repair-execution-dry-run-current.json', + 'npm run db:foundation:apply-pre-sku-formalization-repair -- --apply --confirm=I_UNDERSTAND_THIS_UPDATES_PRE_SKU_LIFECYCLE --plan --expected-database-identity --expected-backup-sha256 ', + ], + guard: '不能由通用主数据清洗脚本处理;必须走专用执行计划,校验 currentEvidence/evidenceFingerprint、数据库身份、备份 SHA 和业务确认原因。', + }) + } + + return { + stageCount: stages.length, + stages, + } +} + +function buildReadinessReport(results) { + const byId = new Map(results.map((result) => [result.id, result])) + const prismaMigrateStatus = byId.get('prisma-migrate-status') + const currentSchemaDiff = byId.get('current-schema-diff') + const migrationStatus = byId.get('migration-status') + const dryRun = byId.get('foundation-dry-run') + const blockers = [ + ...collectCommandFailureBlockers(results), + ...collectPrismaMigrateStatusBlockers(prismaMigrateStatus), + ...collectCurrentSchemaDiffBlockers(currentSchemaDiff), + ...collectMigrationBlockers(migrationStatus), + ...collectDryRunQualityBlockers(dryRun), + ] + + const uniqueBlockers = blockers.filter((blocker, index, list) => { + const key = `${blocker.source}:${blocker.key}:${blocker.message}` + return list.findIndex((item) => `${item.source}:${item.key}:${item.message}` === key) === index + }) + const nextActionPlan = buildReadinessNextActionPlan(uniqueBlockers) + + return { + status: uniqueBlockers.length === 0 ? 'hard-ready' : 'not-hard-ready', + hardReady: uniqueBlockers.length === 0, + checkedAt: new Date().toISOString(), + commandCount: results.length, + passedCommandCount: results.filter((result) => result.ok).length, + blockerCount: uniqueBlockers.length, + blockers: uniqueBlockers, + nextActionPlan, + commandSummaries: results.map((result) => ({ + id: result.id, + label: result.label, + ok: result.ok, + status: result.status, + jsonParsed: Boolean(result.data), + jsonError: result.jsonError, + error: result.error, + stdoutPreview: result.stdout.slice(0, 240), + })), + } +} + +function main() { + const options = parseArgs() + const results = COMMANDS.map(runCommand) + const report = buildReadinessReport(results) + writeJsonReport(options.outputPath, report) + console.log(JSON.stringify(report, null, 2)) + if (!report.hardReady) { + process.exit(1) + } +} + +if (require.main === module) { + main() +} + +module.exports = { + COMMANDS, + addPositiveCounterBlocker, + addNonEmptyArrayBlocker, + buildReadinessReport, + countPendingPrismaMigrations, + evaluateCommandResult, + extractJson, + listPendingPrismaMigrations, + parseArgs, + buildMissingMigrationHistoryEvidence, + summarizeSqlDiff, + buildReadinessNextActionPlan, + writeJsonReport, +} diff --git a/furniture-3d-system/scripts/verify-master-data-truth-sources.js b/furniture-3d-system/scripts/verify-master-data-truth-sources.js new file mode 100644 index 00000000..5cf8fd15 --- /dev/null +++ b/furniture-3d-system/scripts/verify-master-data-truth-sources.js @@ -0,0 +1,510 @@ +const fs = require('node:fs') +const path = require('node:path') +const { CORE_MASTER_DATA_TRUTH_SOURCES } = require('./master-data-truth-sources') +const { REQUIRED_DATABASE_GUARDS } = require('./verify-master-data-foundation') +const { + approvedRawSqlMasterDataWritePaths, + approvedRawSqlMasterDataWriteTableScopes, + approvedServerMasterDataWriteModelScopes, + getSchemaJsonFieldInventory, + getApprovedMasterDataWriteBoundaryFiles, +} = require('./verify-master-data-code-boundaries') + +const ROOT_DIR = path.resolve(__dirname, '..') +const PRISMA_SCHEMA_PATH = path.join(ROOT_DIR, 'prisma/schema.prisma') + +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 。') + } + options.outputPath = value + index += 1 + continue + } + if (arg.startsWith('--output=')) { + options.outputPath = arg.slice('--output='.length) + if (!options.outputPath) { + throw new Error('缺少 --output 。') + } + 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 readPrismaModels(schemaPath = PRISMA_SCHEMA_PATH) { + return new Set(Object.keys(readPrismaModelFields(schemaPath))) +} + +function readPrismaModelFields(schemaPath = PRISMA_SCHEMA_PATH) { + const content = fs.readFileSync(schemaPath, 'utf8') + const modelFields = {} + let currentModel = '' + + for (const line of content.split(/\r?\n/)) { + const modelMatch = line.match(/^model\s+(\w+)\s*\{/) + if (modelMatch) { + currentModel = modelMatch[1] + modelFields[currentModel] = new Set() + continue + } + if (currentModel && /^}/.test(line)) { + currentModel = '' + continue + } + if (!currentModel) continue + + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) continue + const fieldName = trimmed.split(/\s+/)[0] + if (fieldName && !fieldName.startsWith('@')) { + modelFields[currentModel].add(fieldName) + } + } + + return modelFields +} + +function resolveSchemaFieldReference(value) { + if (typeof value !== 'string') return null + const match = value.match(/^([A-Z]\w*)\.(\w+)/) + if (!match) return null + return { + modelName: match[1], + fieldName: match[2], + } +} + +function toPrismaDelegateName(modelName) { + if (typeof modelName !== 'string' || modelName.length === 0) return '' + return `${modelName[0].toLowerCase()}${modelName.slice(1)}` +} + +function buildDelegateToModelNameMap(prismaModelFields) { + return new Map( + Object.keys(prismaModelFields).map((modelName) => [ + toPrismaDelegateName(modelName), + modelName, + ]), + ) +} + +function approvedTableScopeIncludes(scope, relationName) { + if (scope === '*') return true + return Boolean(scope?.has?.(relationName)) +} + +function collectTruthSourceSchemaSnapshotFields(truthSources) { + const fields = new Set() + + for (const entry of truthSources) { + for (const snapshot of entry.compatibilitySnapshots ?? []) { + const reference = resolveSchemaFieldReference(snapshot) + if (!reference) continue + fields.add(`${reference.modelName}.${reference.fieldName}`) + } + } + + return fields +} + +function collectTruthSourceWriteScopeCoverage(truthSources) { + const coverage = new Set() + for (const entry of truthSources) { + for (const filePath of entry.writeBoundary ?? []) { + for (const relationName of entry.primaryRelations ?? []) { + coverage.add(`${filePath}::${relationName}`) + } + } + } + return coverage +} + +function collectUnmappedApprovedWriteScopes({ + truthSources, + prismaModelFields, + approvedWriteBoundaryFileSet, + approvedWriteBoundaryModelScopes, + approvedRawSqlWriteBoundaryFiles, + approvedRawSqlWriteTableScopes, +}) { + const coverage = collectTruthSourceWriteScopeCoverage(truthSources) + const delegateToModelName = buildDelegateToModelNameMap(prismaModelFields) + const issues = [] + const approvedRawSqlWriteBoundaryFileSet = new Set(approvedRawSqlWriteBoundaryFiles ?? []) + + for (const [filePath, scopes] of approvedWriteBoundaryModelScopes.entries()) { + if (!approvedWriteBoundaryFileSet.has(filePath)) continue + for (const scope of scopes) { + const relationName = delegateToModelName.get(scope) + if (!relationName || coverage.has(`${filePath}::${relationName}`)) continue + issues.push({ + filePath, + relationName, + scope, + source: 'server-model-scope', + reason: + '代码层批准写入该主数据表,但真相源矩阵没有反向声明这个写入口,容易形成隐形第二入口。', + }) + } + } + + for (const [filePath, tableScopes] of approvedRawSqlWriteTableScopes.entries()) { + if (!approvedRawSqlWriteBoundaryFileSet.has(filePath) || tableScopes === '*') continue + for (const relationName of tableScopes) { + if (!Object.prototype.hasOwnProperty.call(prismaModelFields, relationName)) continue + if (coverage.has(`${filePath}::${relationName}`)) continue + issues.push({ + filePath, + relationName, + scope: relationName, + source: 'raw-sql-table-scope', + reason: + 'Raw SQL 白名单允许写入该主数据表,但真相源矩阵没有反向声明这个写入口。', + }) + } + } + + return issues.sort((left, right) => + `${left.filePath}:${left.relationName}`.localeCompare(`${right.filePath}:${right.relationName}`), + ) +} + +function addEntryIssue(issuesById, id, type, detail) { + if (!id) return + if (!issuesById.has(id)) issuesById.set(id, []) + issuesById.get(id).push({ type, detail }) +} + +function buildTruthSourceEntryAudits(truthSources, issueReport) { + const issuesById = new Map() + + for (const id of issueReport.duplicateIds ?? []) { + addEntryIssue(issuesById, id, 'duplicate-id', { id }) + } + for (const issue of issueReport.missingPrimaryRelations ?? []) { + addEntryIssue(issuesById, issue.id, 'missing-primary-relation', issue) + } + for (const issue of issueReport.missingRequiredGuards ?? []) { + addEntryIssue(issuesById, issue.id, 'missing-required-guard', issue) + } + for (const issue of issueReport.misassignedRequiredGuards ?? []) { + addEntryIssue(issuesById, issue.id, 'misassigned-required-guard', issue) + } + for (const issue of issueReport.missingSnapshotFields ?? []) { + addEntryIssue(issuesById, issue.id, 'missing-snapshot-field', issue) + } + for (const issue of issueReport.missingWriteBoundaryFiles ?? []) { + addEntryIssue(issuesById, issue.id, 'missing-write-boundary-file', issue) + } + for (const issue of issueReport.missingWriteBoundaryClassifications ?? []) { + addEntryIssue(issuesById, issue.id, 'missing-write-boundary-classification', issue) + } + for (const issue of issueReport.missingPrimaryRelationWriteScopes ?? []) { + addEntryIssue(issuesById, issue.id, 'missing-primary-relation-write-scope', issue) + } + for (const issue of issueReport.incompleteEntries ?? []) { + addEntryIssue(issuesById, issue.id, 'incomplete-entry', issue) + } + + return truthSources.map((entry) => { + const issues = issuesById.get(entry.id) ?? [] + return { + id: entry.id, + layer: entry.layer, + ok: issues.length === 0, + issueCount: issues.length, + issues, + primaryRelationCount: (entry.primaryRelations ?? []).length, + compatibilitySnapshotCount: (entry.compatibilitySnapshots ?? []).length, + writeBoundaryCount: (entry.writeBoundary ?? []).length, + requiredGuardCount: (entry.requiredGuardNames ?? []).length, + } + }) +} + +function validateTruthSourceManifest({ + truthSources = CORE_MASTER_DATA_TRUTH_SOURCES, + prismaModelFields = readPrismaModelFields(), + schemaJsonFields = getSchemaJsonFieldInventory(), + databaseGuards = REQUIRED_DATABASE_GUARDS, + approvedWriteBoundaryFiles = getApprovedMasterDataWriteBoundaryFiles(), + approvedWriteBoundaryModelScopes = approvedServerMasterDataWriteModelScopes, + approvedRawSqlWriteBoundaryFiles = Array.from(approvedRawSqlMasterDataWritePaths.keys()), + approvedRawSqlWriteTableScopes = approvedRawSqlMasterDataWriteTableScopes, + rootDir = ROOT_DIR, + fileExists = (relativePath) => fs.existsSync(path.join(rootDir, relativePath)), +} = {}) { + const prismaModels = new Set(Object.keys(prismaModelFields)) + const guardNames = new Set(databaseGuards.map((guard) => guard.name)) + const guardRelationByName = new Map( + databaseGuards.map((guard) => [guard.name, guard.relationName ?? '']), + ) + const truthSourceSchemaSnapshotFields = + collectTruthSourceSchemaSnapshotFields(truthSources) + const compatibilitySnapshotSchemaFields = schemaJsonFields.filter( + (field) => field.category === 'compatibility-snapshot', + ) + const unmappedCompatibilitySnapshotFields = + compatibilitySnapshotSchemaFields + .filter((field) => !truthSourceSchemaSnapshotFields.has(field.id)) + .map((field) => ({ + id: field.id, + model: field.model, + field: field.field, + category: field.category, + targetState: field.targetState, + reason: + '代码层 JSON 字段分类标为兼容快照,但真相源矩阵未声明该 Model.field,容易形成第二套快照台账。', + })) + const seenIds = new Set() + const duplicateIds = [] + const missingPrimaryRelations = [] + const missingRequiredGuards = [] + const misassignedRequiredGuards = [] + const missingSnapshotFields = [] + const missingWriteBoundaryFiles = [] + const missingWriteBoundaryClassifications = [] + const missingPrimaryRelationWriteScopes = [] + const incompleteEntries = [] + const coveredPrimaryRelations = new Set() + const approvedWriteBoundaryFileSet = new Set([ + ...(approvedWriteBoundaryFiles.server ?? []), + ...(approvedWriteBoundaryFiles.script ?? []), + ...(approvedRawSqlWriteBoundaryFiles ?? []), + ]) + const unmappedApprovedWriteScopes = collectUnmappedApprovedWriteScopes({ + truthSources, + prismaModelFields, + approvedWriteBoundaryFileSet, + approvedWriteBoundaryModelScopes, + approvedRawSqlWriteBoundaryFiles, + approvedRawSqlWriteTableScopes, + }) + + for (const entry of truthSources) { + if (seenIds.has(entry.id)) duplicateIds.push(entry.id) + seenIds.add(entry.id) + + const requiredArrayFields = [ + 'primaryRelations', + 'compatibilitySnapshots', + 'notTruthSources', + 'writeBoundary', + 'requiredGuardNames', + ] + for (const field of requiredArrayFields) { + if (!Array.isArray(entry[field]) || entry[field].length === 0) { + incompleteEntries.push({ id: entry.id, field }) + } + } + if (!entry.layer) { + incompleteEntries.push({ id: entry.id, field: 'layer' }) + } + + for (const relationName of entry.primaryRelations ?? []) { + coveredPrimaryRelations.add(relationName) + if (!prismaModels.has(relationName)) { + missingPrimaryRelations.push({ id: entry.id, relationName }) + } + } + + const entryPrimaryRelations = new Set(entry.primaryRelations ?? []) + for (const guardName of entry.requiredGuardNames ?? []) { + if (!guardNames.has(guardName)) { + missingRequiredGuards.push({ id: entry.id, guardName }) + continue + } + + const relationName = guardRelationByName.get(guardName) + if (relationName && !entryPrimaryRelations.has(relationName)) { + misassignedRequiredGuards.push({ + id: entry.id, + guardName, + relationName, + primaryRelations: Array.from(entryPrimaryRelations), + }) + } + } + + for (const snapshot of entry.compatibilitySnapshots ?? []) { + const reference = resolveSchemaFieldReference(snapshot) + if (!reference) continue + + const fields = prismaModelFields[reference.modelName] + if (!fields?.has(reference.fieldName)) { + missingSnapshotFields.push({ + id: entry.id, + snapshot, + modelName: reference.modelName, + fieldName: reference.fieldName, + }) + } + } + + for (const filePath of entry.writeBoundary ?? []) { + if (!fileExists(filePath)) { + missingWriteBoundaryFiles.push({ id: entry.id, filePath }) + continue + } + if (!approvedWriteBoundaryFileSet.has(filePath)) { + missingWriteBoundaryClassifications.push({ + id: entry.id, + filePath, + reason: + '真相源矩阵声明的写入边界必须同时进入代码层主数据写入口批准表。', + }) + } + } + + const approvedModelsForEntry = new Set() + const approvedRawSqlTablesForEntry = new Set() + for (const filePath of entry.writeBoundary ?? []) { + const scopes = approvedWriteBoundaryModelScopes.get?.(filePath) + if (scopes) { + for (const model of scopes) { + approvedModelsForEntry.add(model) + } + } + + const tableScopes = approvedRawSqlWriteTableScopes.get?.(filePath) + if (tableScopes === '*') { + approvedRawSqlTablesForEntry.add('*') + continue + } + if (tableScopes) { + for (const relationName of tableScopes) { + approvedRawSqlTablesForEntry.add(relationName) + } + } + } + + for (const relationName of entry.primaryRelations ?? []) { + if (!prismaModels.has(relationName)) continue + const model = toPrismaDelegateName(relationName) + if ( + !approvedModelsForEntry.has(model) && + !approvedTableScopeIncludes(approvedRawSqlTablesForEntry, relationName) + ) { + missingPrimaryRelationWriteScopes.push({ + id: entry.id, + relationName, + model, + writeBoundary: entry.writeBoundary ?? [], + approvedModels: Array.from(approvedModelsForEntry).sort(), + reason: + '真相源主表必须被至少一个声明的写入边界批准写入,不能只批准文件名。', + }) + } + } + } + + const uncoveredGuardRelations = Array.from( + new Set( + databaseGuards + .map((guard) => guard.relationName) + .filter((relationName) => relationName && !coveredPrimaryRelations.has(relationName)), + ), + ).sort() + + const report = { + truthSourceCount: truthSources.length, + prismaModelCount: prismaModels.size, + databaseGuardCount: databaseGuards.length, + truthSourceEntries: truthSources.map((entry) => ({ + id: entry.id, + layer: entry.layer, + primaryRelations: entry.primaryRelations ?? [], + compatibilitySnapshots: entry.compatibilitySnapshots ?? [], + notTruthSources: entry.notTruthSources ?? [], + writeBoundary: entry.writeBoundary ?? [], + requiredGuardNames: entry.requiredGuardNames ?? [], + })), + duplicateIds, + missingPrimaryRelations, + missingRequiredGuards, + misassignedRequiredGuards, + missingSnapshotFields, + missingWriteBoundaryFiles, + missingWriteBoundaryClassifications, + missingPrimaryRelationWriteScopes, + unmappedApprovedWriteScopes, + uncoveredGuardRelations, + incompleteEntries, + compatibilitySnapshotSchemaFieldCount: compatibilitySnapshotSchemaFields.length, + unmappedCompatibilitySnapshotFields, + } + const truthSourceEntryAudits = buildTruthSourceEntryAudits(truthSources, report) + + return { + ...report, + truthSourceEntryAuditCount: truthSourceEntryAudits.length, + truthSourceEntryIssueCount: truthSourceEntryAudits.reduce( + (total, entry) => total + entry.issueCount, + 0, + ), + truthSourceEntryAudits, + ok: + duplicateIds.length === 0 && + missingPrimaryRelations.length === 0 && + missingRequiredGuards.length === 0 && + misassignedRequiredGuards.length === 0 && + missingSnapshotFields.length === 0 && + missingWriteBoundaryFiles.length === 0 && + missingWriteBoundaryClassifications.length === 0 && + missingPrimaryRelationWriteScopes.length === 0 && + unmappedApprovedWriteScopes.length === 0 && + uncoveredGuardRelations.length === 0 && + incompleteEntries.length === 0 && + unmappedCompatibilitySnapshotFields.length === 0, + } +} + +function main() { + const options = parseArgs() + const report = validateTruthSourceManifest() + writeJsonReport(options.outputPath, report) + console.log(JSON.stringify(report, null, 2)) + if (!report.ok) { + process.exit(1) + } +} + +if (require.main === module) { + main() +} + +module.exports = { + PRISMA_SCHEMA_PATH, + parseArgs, + readPrismaModelFields, + readPrismaModels, + collectTruthSourceSchemaSnapshotFields, + buildTruthSourceEntryAudits, + buildDelegateToModelNameMap, + collectTruthSourceWriteScopeCoverage, + collectUnmappedApprovedWriteScopes, + approvedTableScopeIncludes, + resolveSchemaFieldReference, + toPrismaDelegateName, + validateTruthSourceManifest, + writeJsonReport, +} diff --git a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx index 8bef05c2..579c3752 100644 --- a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx @@ -205,6 +205,12 @@ jest.mock('@/server/services/intake-non-integrated-product.service', () => ({ }, })) +jest.mock('@/server/services/category.service', () => ({ + categoryService: { + listActive: jest.fn().mockResolvedValue([]), + }, +})) + function createDraftGroupDetail() { return { draftGroup: createMockDraftGroupList()[0], diff --git a/furniture-3d-system/src/app/__tests__/intake-page.test.tsx b/furniture-3d-system/src/app/__tests__/intake-page.test.tsx index 2fcd72a3..adff9dfa 100644 --- a/furniture-3d-system/src/app/__tests__/intake-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/intake-page.test.tsx @@ -36,6 +36,114 @@ jest.mock('@/lib/server-session', () => ({ }), })) +jest.mock('@/server/services/draft-group.service', () => ({ + draftGroupService: { + list: jest.fn().mockResolvedValue([ + { + id: 'draft-1', + code: 'DG-001', + name: '包装中草稿', + status: 'PACKAGING', + assetSeriesName: '单板桌', + }, + { + id: 'draft-2', + code: 'DG-002', + name: '待入库草稿', + status: 'READY_FOR_INTAKE', + assetSeriesName: '单板桌', + }, + { + id: 'draft-3', + code: 'DG-002', + name: '未包装草稿', + status: 'DRAFT', + assetSeriesName: '单板桌', + }, + { + id: 'draft-4', + code: 'DG-003', + name: '已入库草稿', + status: 'FORMALIZED', + assetSeriesName: '单板桌', + }, + { + id: 'draft-5', + code: 'DG-004', + name: '已归档草稿', + status: 'ARCHIVED', + assetSeriesName: '单板桌', + }, + ]), + getById: jest.fn().mockResolvedValue({ + draftGroup: { + id: 'draft-1', + code: 'DG-001', + name: '包装中草稿', + status: 'PACKAGING', + assetSeriesName: '单板桌', + }, + preSkus: [], + }), + }, +})) + +jest.mock('@/server/services/intake.service', () => ({ + intakeService: { + recommendExistingProducts: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@/server/services/warehouse.service', () => ({ + warehouseService: { + listLocations: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@/server/services/asset-center.service', () => ({ + assetCenterService: { + listSubstrates: jest.fn().mockResolvedValue([]), + getSubstrateDictionaryBundle: jest.fn().mockResolvedValue({ + categories: [], + substrateTypes: [], + processTypes: [], + }), + }, +})) + +jest.mock('@/server/services/material.service', () => ({ + materialService: { + findAll: jest.fn().mockResolvedValue({ items: [] }), + findAllCategories: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@/server/services/category.service', () => ({ + categoryService: { + listActive: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@/server/services/intake-node-permission-settings.service', () => ({ + intakeNodePermissionSettingsService: { + getSettings: jest.fn().mockResolvedValue({ overrides: {} }), + }, +})) + +jest.mock('@/server/services/intake-non-integrated-product.service', () => ({ + intakeNonIntegratedProductService: { + listPage: jest.fn().mockResolvedValue({ + products: [], + pageInfo: { + page: 1, + pageSize: 50, + total: 0, + totalPages: 1, + }, + }), + }, +})) + jest.mock('@/server/services', () => ({ draftGroupService: { list: jest.fn().mockResolvedValue([ @@ -114,10 +222,23 @@ jest.mock('@/server/services', () => ({ jest.mock('../intake/IntakeClient', () => ({ __esModule: true, - default: ({ eligibleDraftGroups }: { eligibleDraftGroups?: Array<{ id: string; name: string }> }) => ( + default: ({ + eligibleDraftGroups, + eligibleSkuList, + }: { + eligibleDraftGroups?: Array<{ id: string; name: string }> + eligibleSkuList?: Array<{ id: string; code: string; skuFields?: { skuCode: string } }> + }) => (
IntakeClient
{eligibleDraftGroups?.map((group) =>
{group.name}
)} + {eligibleSkuList?.map((sku) => ( +
+ {sku.code} + {' / '} + {sku.skuFields?.skuCode} +
+ ))}
), })) @@ -132,4 +253,56 @@ describe('IntakePage', () => { expect(screen.queryByText('已入库草稿')).not.toBeInTheDocument() expect(screen.queryByText('已归档草稿')).not.toBeInTheDocument() }) + + it('准 SKU 首屏编码优先读取结构化 basicInfo', async () => { + const draftGroupServiceMock = jest.requireMock( + '@/server/services/draft-group.service', + ).draftGroupService + + draftGroupServiceMock.list.mockResolvedValueOnce([ + { + id: 'draft-structured', + code: 'DG-STRUCTURED', + name: '结构化草稿', + status: 'PACKAGING', + assetSeriesName: '单板桌', + }, + ]) + draftGroupServiceMock.getById.mockResolvedValue({ + draftGroup: { + id: 'draft-structured', + code: 'DG-STRUCTURED', + name: '结构化草稿', + status: 'PACKAGING', + assetSeriesName: '单板桌', + }, + preSkus: [ + { + id: 'pre-sku-1', + sequence: 1, + defaultName: '默认准 SKU', + customName: null, + status: 'PACKAGING', + componentCodes: [], + basicInfo: { + formalSkuCode: 'STRUCTURED-SKU', + }, + displaySummary: { + skuCode: 'LEGACY-SKU', + }, + familySelection: {}, + variantSelection: { + slots: [], + hardwareSelections: [], + }, + bomPreview: [], + }, + ], + }) + + render(await IntakePage({ searchParams: Promise.resolve({}) })) + + expect(screen.getByText('STRUCTURED-SKU / STRUCTURED-SKU')).toBeInTheDocument() + expect(screen.queryByText(/LEGACY-SKU/)).not.toBeInTheDocument() + }) }) diff --git a/furniture-3d-system/src/app/admin/approval-workflows/AdminApprovalWorkflowClient.tsx b/furniture-3d-system/src/app/admin/approval-workflows/AdminApprovalWorkflowClient.tsx index 4f1b43c2..817a03f0 100644 --- a/furniture-3d-system/src/app/admin/approval-workflows/AdminApprovalWorkflowClient.tsx +++ b/furniture-3d-system/src/app/admin/approval-workflows/AdminApprovalWorkflowClient.tsx @@ -1,18 +1,27 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import type { ApprovalWorkflowStatus } from '@prisma/client' -import { Copy, ExternalLink, Search } from 'lucide-react' +import { Copy, ExternalLink, GitBranch, Search } from 'lucide-react' import { buildApprovalWorkflowUrl, listApprovalWorkflows, type ApprovalWorkflowListItem, } from '@/app/approval-workflows/approvalWorkflowApi' import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types' +import { + ObsidianControlShell, + ObsidianDataRow, + ObsidianInspectorSection, + ObsidianPanel, + ObsidianRailButton, + ObsidianRailGroup, +} from '@/components/platform/ObsidianControlShell' +import { cn } from '@/lib/utils' const STATUS_LABELS: Record = { - '': '全部状态', + '': '全部', DRAFT: '草稿', PENDING: '待处理', IN_PROGRESS: '进行中', @@ -29,13 +38,16 @@ export function AdminApprovalWorkflowClient() { const [copiedKey, setCopiedKey] = useState(null) const [workflows, setWorkflows] = useState([]) - const load = async () => { + const load = useCallback(async (filters: { + keyword: string + status: ApprovalWorkflowStatus | '' + }) => { try { setLoading(true) setError(null) const items = await listApprovalWorkflows({ - keyword: keyword.trim() || undefined, - status, + keyword: filters.keyword.trim() || undefined, + status: filters.status, }) setWorkflows(items) } catch (loadError) { @@ -43,11 +55,11 @@ export function AdminApprovalWorkflowClient() { } finally { setLoading(false) } - } + }, []) useEffect(() => { - void load() - }, []) + void load({ keyword: '', status: '' }) + }, [load]) const sortedWorkflows = useMemo( () => [...workflows].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), @@ -64,128 +76,136 @@ export function AdminApprovalWorkflowClient() { } } - return ( -
-
-
-
-
-

后台流程记录

-

流程单总表

-

- 这里负责找旧流程、按审批编号排查、复制链接补发,不替代开品页里的主发起入口。 -

-
- - 返回后台 - -
+ const statusCounts = useMemo(() => { + const counts: Record = { '': workflows.length } + for (const w of workflows) { + counts[w.status] = (counts[w.status] ?? 0) + 1 + } + return counts + }, [workflows]) -
- - + return ( + + {(Object.entries(STATUS_LABELS) as Array<[ApprovalWorkflowStatus | '', string]>).map(([key, label]) => ( + setStatus(key)} + label={label} + meta={statusCounts[key] ? String(statusCounts[key]) : undefined} + /> + ))} + + } + inspector={ +
+ + + + + +
+ } + > +
+
+
+
+ + setKeyword(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') void load({ keyword, status }) }} + placeholder="搜索审批编号、标题、产品型号" + className="w-full border-0 bg-transparent text-xs text-[#cbd8d5] outline-none placeholder:text-[#5a6a6d]" + /> +
- - {error ? ( -
+
+
+ {error && ( +
{error}
- ) : null} -
- -
+ )} {loading ? ( -
正在加载流程记录...
+ +

正在加载…

+
) : sortedWorkflows.length === 0 ? ( -
当前没有符合条件的流程记录。
+ +

当前没有符合条件的流程记录

+
) : ( -
+
{sortedWorkflows.map((workflow) => ( -
+
-

{workflow.title}

- +

{workflow.title}

+ {STATUS_LABELS[workflow.status]}
-

- 审批编号:{workflow.approvalNumber} · 产品:{workflow.product.modelNumber} / {workflow.product.name} +

+ {workflow.approvalNumber} · {workflow.product.modelNumber} / {workflow.product.name}

-

- 参与业务:{workflow.businessRoles.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role]).join(' / ') || '未配置'} -

-

- 最近更新:{new Date(workflow.updatedAt).toLocaleString('zh-CN')} +

+ {workflow.businessRoles.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role]).join(' / ') || '未配置'} · {new Date(workflow.updatedAt).toLocaleString('zh-CN')}

-
+
- - 打开流程页 + + 打开 查看需求
-
+
))}
)}
-
+ ) } diff --git a/furniture-3d-system/src/app/api/approval-workflows/route.test.ts b/furniture-3d-system/src/app/api/approval-workflows/route.test.ts index babc7657..31b918a5 100644 --- a/furniture-3d-system/src/app/api/approval-workflows/route.test.ts +++ b/furniture-3d-system/src/app/api/approval-workflows/route.test.ts @@ -21,7 +21,7 @@ jest.mock('@/server/services/approval-workflow.service', () => ({ }, })) -jest.mock('@/server/services', () => ({ +jest.mock('@/server/services/intake-non-integrated-product.service', () => ({ intakeNonIntegratedProductService: { getById: (...args: unknown[]) => mockGetById(...args), }, diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts index 297275cc..e81824c3 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/route.ts @@ -49,6 +49,14 @@ export async function PUT(request: NextRequest, context: RouteContext) { typeof (rawBody as { workspaceCategory?: unknown }).workspaceCategory === 'string' ? ((rawBody as { workspaceCategory: string }).workspaceCategory.trim() || null) : null + const formalSpuAssignmentProductCategory = + rawBody && + typeof rawBody === 'object' && + typeof (rawBody as { formalSpuAssignmentProductCategory?: unknown }) + .formalSpuAssignmentProductCategory === 'string' + ? ((rawBody as { formalSpuAssignmentProductCategory: string }) + .formalSpuAssignmentProductCategory.trim() || null) + : null if (!product || typeof product !== 'object' || Array.isArray(product)) { return jsonError( @@ -66,7 +74,7 @@ export async function PUT(request: NextRequest, context: RouteContext) { id: decodedId, }, ctx.user, - { workspaceCategory }, + { workspaceCategory, formalSpuAssignmentProductCategory }, ) return jsonSuccess({ product: savedProduct }, 200, ctx.requestId) } catch (error) { diff --git a/furniture-3d-system/src/app/api/intake/product-patents/route.ts b/furniture-3d-system/src/app/api/intake/product-patents/route.ts new file mode 100644 index 00000000..10f4ac2f --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/product-patents/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from 'next/server' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productPatentService } from '@/server/services' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const records = await productPatentService.list() + return jsonSuccess({ records }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +export async function PUT(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const rawBody = await request.json().catch(() => null) + const records = + rawBody && typeof rawBody === 'object' && Array.isArray((rawBody as { records?: unknown }).records) + ? (rawBody as { records: unknown[] }).records + : null + + if (!records) { + return jsonError( + ErrorCode.VALIDATION_FAILED, + '专利台账参数不正确:records 必须是数组', + 400, + ctx.requestId, + ) + } + + const savedRecords = await productPatentService.replaceAll(records) + return jsonSuccess({ records: savedRecords }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/intake/reference-library/route.ts b/furniture-3d-system/src/app/api/intake/reference-library/route.ts new file mode 100644 index 00000000..8c1037bc --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/reference-library/route.ts @@ -0,0 +1,69 @@ +import { NextRequest } from 'next/server' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeReferenceLibraryService } from '@/server/services' + +function readLibraryTypeFromRequest(request: NextRequest) { + return request.nextUrl.searchParams.get('type') ?? '' +} + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const libraryType = readLibraryTypeFromRequest(request) + if (!libraryType) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少资料库类型 type', 400, ctx.requestId) + } + + try { + const items = await intakeReferenceLibraryService.list(libraryType) + return jsonSuccess({ items }, 200, ctx.requestId) + } catch (error) { + return jsonError( + ErrorCode.VALIDATION_FAILED, + error instanceof Error ? error.message : '资料库类型不正确', + 400, + ctx.requestId, + ) + } + }, + { requireAuth: true }, + ) +} + +export async function PUT(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const rawBody = await request.json().catch(() => null) + const body = rawBody && typeof rawBody === 'object' ? rawBody as { type?: unknown; items?: unknown } : null + const libraryType = typeof body?.type === 'string' ? body.type : readLibraryTypeFromRequest(request) + const items = Array.isArray(body?.items) ? body.items : null + + if (!libraryType) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少资料库类型 type', 400, ctx.requestId) + } + if (!items) { + return jsonError(ErrorCode.VALIDATION_FAILED, '资料库 items 必须是数组', 400, ctx.requestId) + } + + try { + const savedItems = await intakeReferenceLibraryService.replaceAll({ + libraryType, + items, + userId: ctx.user.id, + }) + return jsonSuccess({ items: savedItems }, 200, ctx.requestId) + } catch (error) { + return jsonError( + ErrorCode.VALIDATION_FAILED, + error instanceof Error ? error.message : '资料库保存失败', + 400, + ctx.requestId, + ) + } + }, + { requireAuth: true, requireInternalContentWrite: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/intake/spu-reservations/route.test.ts b/furniture-3d-system/src/app/api/intake/spu-reservations/route.test.ts new file mode 100644 index 00000000..27331bce --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/spu-reservations/route.test.ts @@ -0,0 +1,54 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockHandleRequest: any = jest.fn() +const mockJsonError: any = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonError: (...args: unknown[]) => mockJsonError(...args), +})) + +const { POST } = require('./route') + +describe('POST /api/intake/spu-reservations', () => { + beforeEach(() => { + jest.clearAllMocks() + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-spu-reservation', + user: { id: 'user-1' }, + }), + ) + mockJsonError.mockImplementation((code, message, status, requestId) => ({ + success: false, + code, + message, + status, + requestId, + })) + }) + + it('阻断单独 SPU 占号,避免号段和产品保存事务断裂', async () => { + const request = { + json: jest.fn(async () => ({ productCategory: '书架' })), + } + + const response = await POST(request as any) + + expect(mockHandleRequest).toHaveBeenCalledWith( + request, + expect.any(Function), + { requireAuth: true, requireInternalContentWrite: true }, + ) + expect(response).toEqual( + expect.objectContaining({ + success: false, + status: 409, + message: expect.stringContaining('同一事务内分配 SPU 并落库'), + }), + ) + expect(request.json).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/app/api/intake/spu-reservations/route.ts b/furniture-3d-system/src/app/api/intake/spu-reservations/route.ts new file mode 100644 index 00000000..ecb6df5e --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/spu-reservations/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError } from '@/server/core/response' + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + return jsonError( + ErrorCode.VALIDATION_FAILED, + '正式 SPU 不支持单独占号;请保存产品资料,由后端在同一事务内分配 SPU 并落库', + 409, + ctx.requestId, + ) + }, + { requireAuth: true, requireInternalContentWrite: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index 85ebcc32..17dd1818 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -303,7 +303,7 @@ const packagingMaterialSchema = z.object({ assetFamilyId: z.string().trim().min(1).optional().nullable(), code: z.string().trim().min(1).optional().nullable(), name: z.string().trim().min(1).optional().nullable(), - quantity: z.number().min(0), + quantity: z.number().positive(), unitCost: z.number().optional().nullable(), totalCost: z.number().optional().nullable(), notes: z.string().optional().nullable(), @@ -319,23 +319,23 @@ const packagingBoxSchema = z.object({ }), items: z.array(packagingBoxItemSchema), materials: z.array(packagingMaterialSchema), - estimatedWeightKg: z.number().optional().nullable().default(null), - estimatedCost: z.number().optional().nullable().default(null), + estimatedWeightKg: z.number().nonnegative().optional().nullable().default(null), + estimatedCost: z.number().nonnegative().optional().nullable().default(null), notes: z.string().nullable().default(null), }) const packagingCostSummarySchema = z.object({ - materialCost: z.number().optional().nullable().default(null), - totalCost: z.number().optional().nullable().default(null), - totalWeightKg: z.number().optional().nullable().default(null), - totalVolumeM3: z.number().optional().nullable().default(null), + materialCost: z.number().nonnegative().optional().nullable().default(null), + totalCost: z.number().nonnegative().optional().nullable().default(null), + totalWeightKg: z.number().nonnegative().optional().nullable().default(null), + totalVolumeM3: z.number().nonnegative().optional().nullable().default(null), notes: z.string().nullable().default(null), }) export const savePreSkuPackagingPlanSchema = z.object({ sourceType: z.enum(['PRODUCT', 'PRE_SKU', 'DRAFT_GROUP']).optional(), sourceRefId: z.string().trim().optional().nullable(), - status: z.string().trim().optional(), + status: z.enum(['DRAFT', 'CONFIRMED', 'ARCHIVED']).optional(), boxes: z.array(packagingBoxSchema), costSummary: packagingCostSummarySchema.optional().nullable(), processDraft: z.record(z.string(), z.unknown()).optional().nullable(), @@ -441,6 +441,22 @@ export const formalizeDraftGroupSchema = z.object({ message: '新建 SPU 时必须选择产品品类', }) } + + if (value.targetMode === 'CREATE_PRODUCT' && !value.modelNumber?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['modelNumber'], + message: '新建 SPU 时必须填写型号', + }) + } + + if (value.targetMode === 'CREATE_PRODUCT' && !value.productName?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['productName'], + message: '新建 SPU 时必须填写产品名称', + }) + } }) export const warehouseLocationCreateSchema = z.object({ diff --git a/furniture-3d-system/src/app/api/trash/products/[id]/route.ts b/furniture-3d-system/src/app/api/trash/products/[id]/route.ts index 88a4a47a..236e815e 100644 --- a/furniture-3d-system/src/app/api/trash/products/[id]/route.ts +++ b/furniture-3d-system/src/app/api/trash/products/[id]/route.ts @@ -14,6 +14,7 @@ import { productService } from '@/server/services/product.service' import { timeAsync } from '@/server/monitoring/performance' import { AppError, ErrorCode } from '@/server/core/errors' import { prisma } from '@/lib/prisma' +import { logActivity } from '@/server/services/logger.service' /** * POST /api/trash/products/[id] @@ -49,16 +50,14 @@ export async function POST( ) // 记录操作日志 - await prisma.activityLog.create({ - data: { - userId: reqCtx.user.id, - type: 'PRODUCT_UPDATE', - targetType: 'Product', - targetId: id, - action: `从回收站恢复产品`, - metadata: { - restoredSkuCount: result.restoredSkuCount, - }, + await logActivity({ + userId: reqCtx.user.id, + type: 'PRODUCT_UPDATE', + targetType: 'Product', + targetId: id, + action: `从回收站恢复产品`, + metadata: { + restoredSkuCount: result.restoredSkuCount, }, }) @@ -110,19 +109,17 @@ export async function DELETE( ) // 记录操作日志 - await prisma.activityLog.create({ - data: { - userId: reqCtx.user.id, - type: 'PRODUCT_DELETE', - targetType: 'Product', - targetId: id, - action: `彻底删除产品: ${product.modelNumber} - ${product.name}`, - metadata: { - modelNumber: product.modelNumber, - name: product.name, - deletedSkuCount: result.deletedSkuCount, - permanent: true, - }, + await logActivity({ + userId: reqCtx.user.id, + type: 'PRODUCT_DELETE', + targetType: 'Product', + targetId: id, + action: `彻底删除产品: ${product.modelNumber} - ${product.name}`, + metadata: { + modelNumber: product.modelNumber, + name: product.name, + deletedSkuCount: result.deletedSkuCount, + permanent: true, }, }) diff --git a/furniture-3d-system/src/app/api/trash/skus/[id]/route.ts b/furniture-3d-system/src/app/api/trash/skus/[id]/route.ts index 5ea0eaa5..42240662 100644 --- a/furniture-3d-system/src/app/api/trash/skus/[id]/route.ts +++ b/furniture-3d-system/src/app/api/trash/skus/[id]/route.ts @@ -14,6 +14,7 @@ import { skuService } from '@/server/services/sku.service' import { timeAsync } from '@/server/monitoring/performance' import { AppError, ErrorCode } from '@/server/core/errors' import { prisma } from '@/lib/prisma' +import { logActivity } from '@/server/services/logger.service' /** * POST /api/trash/skus/[id] @@ -38,17 +39,15 @@ export async function POST( ) // 记录操作日志 - await prisma.activityLog.create({ - data: { - userId: reqCtx.user.id, - type: 'SKU_UPDATE', - targetType: 'ProductSKU', - targetId: id, - action: `从回收站恢复 SKU: ${result.skuCode}`, - metadata: { - skuCode: result.skuCode, - productId: result.productId, - }, + await logActivity({ + userId: reqCtx.user.id, + type: 'SKU_UPDATE', + targetType: 'ProductSKU', + targetId: id, + action: `从回收站恢复 SKU: ${result.skuCode}`, + metadata: { + skuCode: result.skuCode, + productId: result.productId, }, }) @@ -97,18 +96,16 @@ export async function DELETE( ) // 记录操作日志 - await prisma.activityLog.create({ - data: { - userId: reqCtx.user.id, - type: 'SKU_DELETE', - targetType: 'ProductSKU', - targetId: id, - action: `彻底删除 SKU: ${sku.skuCode}`, - metadata: { - skuCode: sku.skuCode, - productId: sku.productId, - permanent: true, - }, + await logActivity({ + userId: reqCtx.user.id, + type: 'SKU_DELETE', + targetType: 'ProductSKU', + targetId: id, + action: `彻底删除 SKU: ${sku.skuCode}`, + metadata: { + skuCode: sku.skuCode, + productId: sku.productId, + permanent: true, }, }) diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx index e612732a..a2a13165 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx @@ -4,27 +4,14 @@ import { startTransition, useState, useMemo, useCallback, useEffect, useRef } fr import Link from 'next/link' import { ArrowRight, - ChevronDown, - ChevronRight, - FileUp, Layers3, Link2, Package2, - Pencil, - Plus, - Search, Shapes, - SwatchBook, - Trash2, - X, - AlertTriangle, } from 'lucide-react' import { Badge } from '@/components/ui/Badge' import Button from '@/components/ui/Button' -import Checkbox from '@/components/ui/Checkbox' -import Input, { Textarea } from '@/components/ui/Input' import Modal from '@/components/ui/Modal' -import Select from '@/components/ui/Select' import { calculateMenuPosition, ContextMenu, @@ -35,9 +22,6 @@ import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog' import { useToast, ToastContainer } from '@/components/ui/Toast' import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop' import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' -import type { HolePanelBounds } from '@/components/3d/HoleOverlay2D' -import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer' -import type { PlatformMetricItem } from '@/components/platform/types' import { applyAssetVariantUvConfigToMaterial, getAssetVariantUvSurfaceConfig, @@ -45,7 +29,6 @@ import { updateAssetVariantUvConfig, type AssetVariantUvConfig, type AssetVariantUvProjectionAxis, - type AssetVariantUvSurfaceConfig, } from '@/lib/asset-variant-render-config' import { inferCadImportDefaults } from '@/lib/asset-cad-import-defaults' import type { NodePositionOffset, NodeRotationState } from '@/lib/3d/engine' @@ -56,7 +39,6 @@ import { getStandardConnectionRoleDefinitions, mergeConnectionRoleDefinitions, normalizeConnectionRoles, - resolveConnectionRoleLabel, type ConnectionRoleDefinition, } from '@/lib/open-product/connection-role-catalog' import { buildManualAssemblyLaunchHref } from '@/lib/open-product/manual-assembly-launch' @@ -65,9 +47,7 @@ import { normalizeAssetVariantsWithUv, } from '@/lib/open-product/asset-variant-uv' import { calculateEstimatedCost } from '@/lib/open-product/substrate-rules' -import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog' import { canOperateDesignWorkspace as canOperateDesignWorkspaceForUser } from '@/lib/role-scope' -import { cn } from '@/lib/utils' import { createAssetFamily, createAssetVariantWithSimilarity, @@ -83,99 +63,74 @@ import { uploadModelFile, } from '@/lib/open-product/platform-api' import { parseZipFile } from '@/lib/zip-parser' -import { ModelUploader } from '@/components/upload/ModelUploader' -import type { MaterialDTO } from '@/server/services' -import type { MaterialCategoryDTO } from '@/server/services' import type { AssetCadImportResult, AssetFamilyDTO, AssetOperationProgressDTO, - AssetSeriesDTO, SimilarAssetVariantCandidateDTO, AssetVariantDTO, - SubstrateDTO, - SubstrateDictionaryBundleDTO, } from '@/server/services' -// ---- Props ---- +// ---- 从拆分模块导入 ---- -interface AssetCenterClientProps { - initialFamilies: AssetFamilyDTO[] - initialVariants: AssetVariantDTO[] - initialSubstrates: SubstrateDTO[] - initialSubstrateDictionaries?: SubstrateDictionaryBundleDTO - initialAssetSeries: AssetSeriesDTO[] - finishes: MaterialDTO[] - materialCategories: MaterialCategoryDTO[] - metrics: PlatformMetricItem[] - user: { - id: string - name: string - email: string - role: string - businessRoles?: string[] | null - departmentPaths?: string[] | null - avatar: string | null - department: { id: string; name: string; fullPath?: string | null } | null - } -} +import type { + AssetCenterClientProps, + AssetTypeFilter, + CadImportForm, + PreviewNodeOffsetMap, + PreviewNodeRotationMap, + PreviewNodeTransformState, + ReviewDialogState, + ReviewVisibilityFilter, + VariantForm, + VariantUvEditorFormState, +} from './asset-center-types' +import { + ASSET_CENTER_GIZMO_AXIS_COLORS, + ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY, + emptyCadImportForm, + emptyVariantForm, +} from './asset-center-types' +import { + arePreviewNodeTransformStatesEqual, + buildAssetVariantHoleOverlayState, + buildCadImportFieldErrors, + buildVariantIdentityPrefix, + canReviewAssets, + clonePreviewNodeOffsetMap, + clonePreviewNodeRotationMap, + clonePreviewNodeTransformState, + createAssetOperationId, + createVariantUvEditorFormState, + familyBelongsToSeries, + familyVisibleInSeriesContext, + getSimilarityConfidenceLabel, + getSimilarityConfidenceTone, + getVisibleFamiliesForTab, + isEditableElement, + isImportableAssetType, + isPackagingMaterialLibraryFamily, + matchesRedoShortcut, + matchesReviewVisibilityFilter, + matchesUndoShortcut, + normalizeCadImportResult, + normalizeSimilarCandidates, + parseVariantUvEditorFormState, + sortByName, + splitConnectionRoles, + toNumberRecord, + toOptionalNumber, +} from './asset-center-utils' +import { RoleEditorModal } from './RoleEditorModal' +import { ReviewRejectModal } from './ReviewRejectModal' +import { VariantEditModal } from './VariantEditModal' +import { FamilyEditModal } from './FamilyEditModal' +import { CadImportModal } from './CadImportModal' +import { FamilyListPanel } from './FamilyListPanel' +import { SubstrateSubmenu, UvSettingsSubmenu } from './PreviewSubmenus' +import { AssetCenterSidebarControls } from './AssetCenterSidebarControls' -type PreviewNodeOffsetMap = Record -type PreviewNodeRotationMap = Record -type PreviewNodeTransformState = { - offsets: PreviewNodeOffsetMap - rotations: PreviewNodeRotationMap -} - -type Vector3Tuple = [number, number, number] - -type VariantUvEditorFormState = { - textureRepeat: string - textureRepeatU: string - textureRepeatV: string - textureRotation: string - textureSizeMm: string - textureProjectionAxis: '' | AssetVariantUvProjectionAxis -} - -const ASSET_CENTER_GIZMO_AXIS_COLORS = { - X: '#8f625d', - Y: '#5f766e', - Z: '#5f6d82', -} satisfies Record<'X' | 'Y' | 'Z', string> - -const UV_PROJECTION_AXIS_OPTIONS = [ - { value: '', label: '自动' }, - { value: 'x', label: 'X 轴' }, - { value: 'y', label: 'Y 轴' }, - { value: 'z', label: 'Z 轴' }, -] satisfies Array<{ value: '' | AssetVariantUvProjectionAxis; label: string }> - -const VARIANT_SAVE_PROGRESS_STEPS = [ - { step: 'validating', label: '校验提交' }, - { step: 'creating', label: '创建草稿' }, - { step: 'thumbnail', label: '生成预览' }, - { step: 'similarity', label: '相似检测' }, - { step: 'syncing', label: '同步界面' }, - { step: 'completed', label: '完成' }, -] satisfies Array<{ - step: AssetOperationProgressDTO['step'] - label: string -}> - -// ---- 工具函数 ---- - -function sortByName(items: T[]): T[] { - return [...items].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN')) -} - -function createAssetOperationId(prefix: string): string { - const cryptoApi = globalThis.crypto - if (typeof cryptoApi?.randomUUID === 'function') { - return `${prefix}-${cryptoApi.randomUUID()}` - } - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` -} +// ---- 主组件内的渲染辅助函数 ---- function buildInitialVariantSaveProgress(isDuplicateVariantMode: boolean): AssetOperationProgressDTO { return { @@ -189,563 +144,6 @@ function buildInitialVariantSaveProgress(isDuplicateVariantMode: boolean): Asset } } -function renderVariantSaveProgress(progress: AssetOperationProgressDTO) { - const normalizedProgress = Math.max(0, Math.min(100, progress.progress)) - const currentIndex = VARIANT_SAVE_PROGRESS_STEPS.findIndex((item) => item.step === progress.step) - const completedIndex = - progress.status === 'completed' - ? VARIANT_SAVE_PROGRESS_STEPS.length - 1 - : Math.max(0, currentIndex) - - return ( -
-
-
-

- {progress.title} -

-

- {progress.detail} -

-
- - {normalizedProgress}% - -
-
-
-
-
- {VARIANT_SAVE_PROGRESS_STEPS.map((item, index) => { - const isCurrent = item.step === progress.step - const isDone = progress.status === 'completed' || index < completedIndex - return ( - - {item.label} - - ) - })} -
-
- ) -} - -function formatUvNumber(value: number | null | undefined) { - return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' -} - -function formatUvRotationDegrees(value: number | null | undefined) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return '' - } - const degrees = value * 180 / Math.PI - return Number.isInteger(degrees) ? String(degrees) : String(Number(degrees.toFixed(2))) -} - -function createVariantUvEditorFormState( - config: AssetVariantUvSurfaceConfig | null | undefined, -): VariantUvEditorFormState { - return { - textureRepeat: formatUvNumber(config?.textureRepeat), - textureRepeatU: formatUvNumber(config?.textureRepeatU), - textureRepeatV: formatUvNumber(config?.textureRepeatV), - textureRotation: formatUvRotationDegrees(config?.textureRotation), - textureSizeMm: formatUvNumber(config?.textureSizeMm), - textureProjectionAxis: config?.textureProjectionAxis ?? '', - } -} - -function parseVariantUvEditorFormState( - state: VariantUvEditorFormState, -): AssetVariantUvSurfaceConfig | null { - const parseField = (value: string) => { - const normalized = value.trim() - if (!normalized) { - return undefined - } - - const parsed = Number(normalized) - return Number.isFinite(parsed) ? parsed : undefined - } - - const textureRepeat = parseField(state.textureRepeat) - const textureRepeatU = parseField(state.textureRepeatU) - const textureRepeatV = parseField(state.textureRepeatV) - const textureRotationDegrees = parseField(state.textureRotation) - const textureRotation = - textureRotationDegrees === undefined ? undefined : textureRotationDegrees * Math.PI / 180 - const textureSizeMm = parseField(state.textureSizeMm) - const textureProjectionAxis = - state.textureProjectionAxis === 'x' || - state.textureProjectionAxis === 'y' || - state.textureProjectionAxis === 'z' - ? state.textureProjectionAxis - : undefined - - if ( - textureRepeat === undefined && - textureRepeatU === undefined && - textureRepeatV === undefined && - textureRotation === undefined && - textureSizeMm === undefined && - textureProjectionAxis === undefined - ) { - return null - } - - return { - ...(textureRepeat !== undefined ? { textureRepeat } : {}), - ...(textureRepeatU !== undefined ? { textureRepeatU } : {}), - ...(textureRepeatV !== undefined ? { textureRepeatV } : {}), - ...(textureRotation !== undefined ? { textureRotation } : {}), - ...(textureSizeMm !== undefined ? { textureSizeMm } : {}), - ...(textureProjectionAxis !== undefined ? { textureProjectionAxis } : {}), - } -} - -function isEditableElement(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) { - return false - } - - const tagName = target.tagName - return ( - tagName === 'INPUT' || - tagName === 'TEXTAREA' || - tagName === 'SELECT' || - target.isContentEditable - ) -} - -function matchesUndoShortcut(event: KeyboardEvent) { - const hasCommandKey = event.ctrlKey || event.metaKey - if (!hasCommandKey || event.altKey || event.shiftKey) { - return false - } - - const code = event.code.toLowerCase() - const key = event.key.toLowerCase() - return code === 'keyz' || key === 'z' -} - -function matchesRedoShortcut(event: KeyboardEvent) { - const hasCommandKey = event.ctrlKey || event.metaKey - if (!hasCommandKey || event.altKey) { - return false - } - - const code = event.code.toLowerCase() - const key = event.key.toLowerCase() - return ( - (event.shiftKey && (code === 'keyz' || key === 'z')) || - (!event.shiftKey && (code === 'keyy' || key === 'y')) - ) -} - -function clonePreviewNodeOffsetMap(source: PreviewNodeOffsetMap): PreviewNodeOffsetMap { - return Object.fromEntries( - Object.entries(source).map(([nodeName, offset]) => [nodeName, [...offset] as [number, number, number]]), - ) -} - -function clonePreviewNodeRotationMap(source: PreviewNodeRotationMap): PreviewNodeRotationMap { - return Object.fromEntries( - Object.entries(source).map(([nodeName, rotation]) => [ - nodeName, - [...rotation] as [number, number, number, number], - ]), - ) -} - -function arePreviewNodeOffsetMapsEqual(left: PreviewNodeOffsetMap, right: PreviewNodeOffsetMap) { - const leftEntries = Object.entries(left) - const rightEntries = Object.entries(right) - - if (leftEntries.length !== rightEntries.length) { - return false - } - - return leftEntries.every(([nodeName, offset]) => { - const target = right[nodeName] - return ( - Boolean(target) && - target[0] === offset[0] && - target[1] === offset[1] && - target[2] === offset[2] - ) - }) -} - -function arePreviewNodeRotationMapsEqual(left: PreviewNodeRotationMap, right: PreviewNodeRotationMap) { - const leftEntries = Object.entries(left) - const rightEntries = Object.entries(right) - - if (leftEntries.length !== rightEntries.length) { - return false - } - - return leftEntries.every(([nodeName, rotation]) => { - const target = right[nodeName] - return ( - Boolean(target) && - target[0] === rotation[0] && - target[1] === rotation[1] && - target[2] === rotation[2] && - target[3] === rotation[3] - ) - }) -} - -function clonePreviewNodeTransformState(source: PreviewNodeTransformState): PreviewNodeTransformState { - return { - offsets: clonePreviewNodeOffsetMap(source.offsets), - rotations: clonePreviewNodeRotationMap(source.rotations), - } -} - -function arePreviewNodeTransformStatesEqual( - left: PreviewNodeTransformState, - right: PreviewNodeTransformState, -) { - return ( - arePreviewNodeOffsetMapsEqual(left.offsets, right.offsets) && - arePreviewNodeRotationMapsEqual(left.rotations, right.rotations) - ) -} - -function toNumberRecord(values: Record): Record { - const result: Record = {} - for (const [key, value] of Object.entries(values)) { - const parsed = Number(value) - if (Number.isFinite(parsed) && parsed > 0) { - result[key] = parsed - } - } - return result -} - -function toOptionalNumber(value: string): number | null { - if (value.trim().length === 0) { - return null - } - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null -} - -function getStatusLabel(status: string) { - if (status === 'AVAILABLE') return '可用' - if (status === 'DISABLED') return '停用' - if (status === 'PENDING_REVIEW') return '待审核' - if (status === 'REJECTED') return '已驳回' - return '草稿' -} - -function shouldShowCompactStatus(status: string) { - return status !== 'DRAFT' -} - -function getCompactStatusClassName(status: string) { - if (status === 'AVAILABLE') { - return compactRowSuccessStatusClassName - } - if (status === 'REJECTED') { - return compactRowDangerStatusClassName - } - if (status === 'DISABLED') { - return compactRowWarningStatusClassName - } - return compactRowNeutralStatusClassName -} - -function renderCompactStatus(status: string, testId?: string) { - if (!shouldShowCompactStatus(status)) { - return null - } - - return ( - - {getStatusLabel(status)} - - ) -} - -function getSimilarityConfidenceTone(confidence: SimilarAssetVariantCandidateDTO['confidence']) { - if (confidence === 'HIGH') return 'warning' - if (confidence === 'MEDIUM') return 'info' - return 'default' -} - -function getSimilarityConfidenceLabel(confidence: SimilarAssetVariantCandidateDTO['confidence']) { - if (confidence === 'HIGH') return '高相似' - if (confidence === 'MEDIUM') return '中相似' - return '低相似' -} - -function normalizeSimilarCandidates( - candidates: SimilarAssetVariantCandidateDTO[] | null | undefined, -): SimilarAssetVariantCandidateDTO[] { - return Array.isArray(candidates) ? candidates : [] -} - -function normalizeCadImportResult(result: AssetCadImportResult): AssetCadImportResult { - return { - ...result, - warnings: Array.isArray(result.warnings) ? result.warnings : [], - similarCandidates: normalizeSimilarCandidates(result.similarCandidates), - similarityNotice: result.similarityNotice ?? null, - } -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function readRecordArray(value: unknown): Record[] { - return Array.isArray(value) - ? value.filter((item): item is Record => isObjectRecord(item)) - : [] -} - -function readStringValue(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null -} - -function readNumberValue(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -function readVector3Tuple(value: unknown): Vector3Tuple | null { - if (Array.isArray(value) && value.length >= 3) { - const x = readNumberValue(value[0]) - const y = readNumberValue(value[1]) - const z = readNumberValue(value[2]) - return x !== null && y !== null && z !== null ? [x, y, z] : null - } - - if (isObjectRecord(value)) { - const x = readNumberValue(value.x) - const y = readNumberValue(value.y) - const z = readNumberValue(value.z) - return x !== null && y !== null && z !== null ? [x, y, z] : null - } - - return null -} - -function readPanelBounds(value: unknown): HolePanelBounds | null { - if (!isObjectRecord(value)) return null - const min = readVector3Tuple(value.min) - const max = readVector3Tuple(value.max) - return min && max ? { min, max } : null -} - -function getBoundsSize(bounds: HolePanelBounds): Vector3Tuple { - return [ - Math.abs(bounds.max[0] - bounds.min[0]), - Math.abs(bounds.max[1] - bounds.min[1]), - Math.abs(bounds.max[2] - bounds.min[2]), - ] -} - -function getMaxRelativeDimensionDiff(left: Vector3Tuple, right: Vector3Tuple): number { - return Math.max( - ...left.map((value, index) => { - const baseline = Math.max(Math.abs(right[index]), 1) - return Math.abs(1 - Math.abs(value) / baseline) - }), - ) -} - -function sortDimensionsForComparison(size: Vector3Tuple): Vector3Tuple { - return [...size].map((value) => Math.abs(value)).sort((left, right) => right - left) as Vector3Tuple -} - -function shouldRemapCadPanelBounds(bounds: HolePanelBounds, panelSize: Vector3Tuple | null): boolean { - if (!panelSize || Math.max(...panelSize.map((value) => Math.abs(value))) <= 0) { - return false - } - - const boundsSize = getBoundsSize(bounds) - const sortedBoundsSize = sortDimensionsForComparison(boundsSize) - const sortedPanelSize = sortDimensionsForComparison(panelSize) - return getMaxRelativeDimensionDiff(sortedBoundsSize, sortedPanelSize) > 0.25 -} - -function buildAssetVariantHoleOverlayState(variant: AssetVariantDTO | null): { - holes: HoleOverlayData[] - activeHolePanelIds: string[] - panelNodeNameByPanelId: Record - panelLabelByPanelId: Record - panelBoundsByPanelId: Record -} { - if (!variant || variant.assetType === 'HARDWARE') { - return { - holes: [], - activeHolePanelIds: [], - panelNodeNameByPanelId: {}, - panelLabelByPanelId: {}, - panelBoundsByPanelId: {}, - } - } - - const assemblyBasis = isObjectRecord(variant.assemblyBasis) ? variant.assemblyBasis : null - const businessData = isObjectRecord(variant.businessData) ? variant.businessData : null - const cadData = isObjectRecord(businessData?.cadData) ? businessData.cadData : null - const holeGroups = readRecordArray(assemblyBasis?.holeGroups ?? cadData?.holeGroups) - const panelNodeNameByPanelId: Record = {} - const panelLabelByPanelId: Record = {} - const panelBoundsByPanelId: Record = {} - - readRecordArray(cadData?.panels).forEach((panel, index) => { - const panelId = readStringValue(panel.id) ?? readStringValue(panel.panelId) - if (!panelId) return - - const nodeName = - readStringValue(panel.gltfNodeName) ?? - readStringValue(panel.nodeName) ?? - readStringValue(panel.name) ?? - panelId - panelNodeNameByPanelId[panelId] = nodeName - panelLabelByPanelId[panelId] = - readStringValue(panel.name) ?? - readStringValue(panel.label) ?? - readStringValue(panel.panelType) ?? - `板件 ${index + 1}` - - const bounds = readPanelBounds(panel.boundingBox) - if (bounds) { - const panelSize = readVector3Tuple(panel.size) - const needsRemap = shouldRemapCadPanelBounds(bounds, panelSize) - panelBoundsByPanelId[panelId] = needsRemap - ? { ...bounds, remapToModelBounds: true } - : bounds - } - }) - - const groupPanelIdById = new Map() - holeGroups.forEach((group) => { - const groupId = readStringValue(group.id) - const panelId = readStringValue(group.panelId) - if (groupId && panelId) { - groupPanelIdById.set(groupId, panelId) - } - }) - - const rawHoles = readRecordArray(assemblyBasis?.holes).length > 0 - ? readRecordArray(assemblyBasis?.holes) - : readRecordArray(cadData?.holes) - const activeHolePanelIds = new Set() - const holes = rawHoles - .map((hole, index) => { - const id = readStringValue(hole.id) ?? `asset-hole-${index + 1}` - const groupId = readStringValue(hole.groupId) ?? readStringValue(hole.holeGroupId) - const panelId = - readStringValue(hole.panelId) ?? - (groupId ? groupPanelIdById.get(groupId) ?? null : null) - const position = readVector3Tuple(hole.position) - if (!panelId || !position) return null - - const normal = readVector3Tuple(hole.normal) ?? [0, 0, 1] - const diameter = readNumberValue(hole.diameter) ?? 0 - const depth = readNumberValue(hole.depth) ?? 0 - const holeType = - readStringValue(hole.holeType) ?? - readStringValue(hole.type) ?? - readStringValue(hole.inferredUsage) ?? - null - - activeHolePanelIds.add(panelId) - if (!panelNodeNameByPanelId[panelId]) { - panelNodeNameByPanelId[panelId] = panelId - } - if (!panelLabelByPanelId[panelId]) { - panelLabelByPanelId[panelId] = panelId.replace(/^cad_/i, '') - } - - return { - id, - panelId, - position, - diameter, - depth, - normal, - holeType, - confidence: readNumberValue(hole.confidence) ?? 1, - ...(groupId ? { groupId } : {}), - } - }) - .filter((item): item is HoleOverlayData => item !== null) - - return { - holes, - activeHolePanelIds: Array.from(activeHolePanelIds), - panelNodeNameByPanelId, - panelLabelByPanelId, - panelBoundsByPanelId, - } -} - -// ---- 系列与分类筛选 ---- - -const ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY = 'asset-center:selected-series-id' -type AssetTypeFilter = AssetFamilyDTO['type'] - -function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean { - return family.type === 'PACKAGING' && getLevel1FromFamily(family.metadata ?? null) !== null -} - -function getVisibleFamiliesForTab( - families: AssetFamilyDTO[], - activeTab: AssetTypeFilter, -): AssetFamilyDTO[] { - return families.filter((family) => { - if (family.type !== activeTab) return false - if (activeTab !== 'PACKAGING') return true - return !isPackagingMaterialLibraryFamily(family) - }) -} - -function familyBelongsToSeries(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean { - if (!series) return false - if (family.assetSeriesId) { - return family.assetSeriesId === series.id - } - const compatibleSeries = family.compatibleSeries ?? [] - return compatibleSeries.some((item) => item.id === series.id || item.code === series.code) -} - -function familyVisibleInSeriesContext(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean { - if (isPackagingMaterialLibraryFamily(family)) return false - if (family.type === 'HARDWARE') return true - return familyBelongsToSeries(family, series) -} - const TYPE_TABS: { type: AssetTypeFilter; label: string; icon: React.ReactNode }[] = [ { type: 'BOARD', label: '板材', icon: }, { type: 'COMPONENT', label: '组件', icon: }, @@ -753,197 +151,6 @@ const TYPE_TABS: { type: AssetTypeFilter; label: string; icon: React.ReactNode } { type: 'PACKAGING', label: '包装', icon: }, ] -// ---- 空表单模板 ---- - -function buildVariantIdentityPrefix(familyCode: string): string { - const normalizedCode = familyCode.trim() - if (!normalizedCode) return '' - return normalizedCode.endsWith('-') ? normalizedCode : `${normalizedCode}-` -} - -function emptyVariantForm(assetFamilyId: string, identityPrefix = '') { - return { - assetFamilyId, - code: '', - name: identityPrefix, - sizeLabel: '', - substrateId: '', - finishId: '', - supplierName: '', - supplierCode: '', - versionNo: '', - barcode: '', - length: '', - width: '', - thickness: '', - modelUrl: '', - mountPointName: 'origin', - mountX: '0', - mountY: '0', - mountZ: '0', - lossFactor: '1', - inheritedFromVariantId: '', - includeLegConnectorRole: false, - } -} - -type VariantForm = ReturnType - -type ImportableAssetType = Exclude - -function isImportableAssetType(type: AssetFamilyDTO['type']): type is ImportableAssetType { - return type === 'BOARD' || type === 'COMPONENT' || type === 'HARDWARE' -} - -function emptyCadImportForm(assetType: ImportableAssetType, useExistingFamily: boolean) { - return { - assetType, - familyMode: useExistingFamily ? ('existing' as const) : ('create' as const), - familyId: '', - familyCode: '', - familyName: '', - familyDescription: '', - connectionRoles: '', - variantCode: '', - variantName: '', - sizeLabel: '', - substrateId: '', - finishId: '', - lossFactor: '1', - file: null as File | null, - } -} - -type CadImportForm = ReturnType - -type CadImportFieldKey = - | 'familyId' - | 'familyCode' - | 'familyName' - | 'connectionRoles' - | 'variantName' - | 'substrateId' - | 'file' - -type CadImportFieldErrors = Partial> - -function splitConnectionRoles(value: string): string[] { - return value - .split(/[,,]/) - .map((item) => item.trim()) - .filter(Boolean) -} - -function buildCadImportFieldErrors(params: { - form: CadImportForm - assetSeriesId?: string | null -}): CadImportFieldErrors { - const { form, assetSeriesId } = params - const errors: CadImportFieldErrors = {} - - if (form.familyMode === 'existing') { - if (!form.familyId) { - errors.familyId = '请选择目标资产族' - } - } else { - if (!assetSeriesId && !form.familyCode.trim()) { - errors.familyCode = '请填写资产族编码' - } - if (!form.familyName.trim()) { - errors.familyName = '请填写资产族名称' - } - } - - if (!form.variantName.trim()) { - errors.variantName = '请填写规格款名称' - } - if (form.assetType === 'BOARD') { - if (!form.substrateId) { - errors.substrateId = '板材必须选择基材' - } - } - - if (!form.file) { - errors.file = '请上传 CAD 导出的 ZIP 文件' - } - - return errors -} - -function buildCadImportChecklist(assetType: ImportableAssetType): string[] { - const notes = [ - '仅支持 CAD 插件 v2.2.14 及以上导出的 ZIP。', - '压缩包内需包含模型文件和 hardware.json,根级必须保留 holes 数组;没有孔位也要传 holes: []。', - ] - - if (assetType === 'BOARD') { - notes.push('如果 ZIP 里有多块板件,当前会按第一块板件尺寸导入规格款。') - } - - if (assetType === 'COMPONENT') { - notes.push('组件尺寸优先按模型包围盒回填,孔位语义会继续保留给后续自由装配。') - } - - if (assetType === 'HARDWARE') { - notes.push('五金会优先保留安装基准与规格签名,不走板件孔位归属。') - } - - return notes -} - -const ASSET_REVIEW_ROLES = new Set([ - 'SUPER_ADMIN', - 'ADMIN', - 'SUPERVISOR', - 'SUPERVISOR', -]) - -function canReviewAssets(role: string): boolean { - return ASSET_REVIEW_ROLES.has(role) -} - -const compactRowActionClassName = - 'inline-flex h-6 items-center rounded-[4px] px-1.5 text-[10px] font-apple-bold text-[#687477] transition hover:bg-black/[0.045] hover:text-[#263033] disabled:cursor-not-allowed disabled:opacity-45' - -const compactRowPrimaryActionClassName = - 'inline-flex h-6 items-center rounded-[4px] bg-[#edf1f1] px-1.5 text-[10px] font-apple-bold text-[#101719] transition hover:bg-[#e3e9e9] disabled:cursor-not-allowed disabled:opacity-45' - -const compactRowDangerActionClassName = - 'inline-flex h-6 items-center rounded-[4px] px-1.5 text-[10px] font-apple-bold text-red-600 transition hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-45' - -const compactRowNeutralStatusClassName = - 'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#eef2f2] px-1.5 text-[10px] font-apple-bold text-[#687477]' - -const compactRowSuccessStatusClassName = - 'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#e8eeee] px-1.5 text-[10px] font-apple-bold text-[#3f4a4d]' - -const compactRowWarningStatusClassName = - 'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-[#f0eeee] px-1.5 text-[10px] font-apple-bold text-[#6a6260]' - -const compactRowDangerStatusClassName = - 'inline-flex h-5 shrink-0 items-center rounded-[3px] bg-red-500/[0.08] px-1.5 text-[10px] font-apple-bold text-red-600' - -const compactRowIconActionClassName = - 'inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#7a878a] transition hover:bg-black/[0.045] hover:text-[#263033]' - -const variantInlineCreateButtonClassName = - 'flex h-9 min-w-0 items-center justify-center gap-2 rounded-[5px] border border-black/[0.08] bg-white px-3 text-[11px] font-black text-[#263033] shadow-[0_1px_2px_rgba(16,23,25,0.04)] transition hover:border-black/[0.14] hover:bg-[#f8faf9] hover:text-[#101719] focus-visible:ring-1 focus-visible:ring-[#2d3335]/15' - -const variantInlineImportButtonClassName = - 'flex h-9 min-w-0 items-center justify-center gap-2 rounded-[5px] border border-black/[0.08] bg-white px-3 text-[11px] font-black text-[#263033] shadow-[0_1px_2px_rgba(16,23,25,0.04)] transition hover:border-black/[0.14] hover:bg-[#f8faf9] hover:text-[#101719] focus-visible:ring-1 focus-visible:ring-[#2d3335]/15' - -type ReviewDialogState = { - targetType: 'family' | 'variant' - targetId: string - targetName: string -} - -type ReviewVisibilityFilter = 'ALL' | 'PENDING_REVIEW' | 'REJECTED' - -function matchesReviewVisibilityFilter(status: string, filter: ReviewVisibilityFilter) { - return filter === 'ALL' || status === filter -} - // ---- 主组件 ---- export default function AssetCenterClient({ @@ -954,7 +161,7 @@ export default function AssetCenterClient({ initialAssetSeries, finishes: initialFinishes, materialCategories: _initialMaterialCategories, - metrics, + metrics: _metrics, user, }: AssetCenterClientProps) { // Toast @@ -963,7 +170,7 @@ export default function AssetCenterClient({ // 数据状态 const [families, setFamilies] = useState(sortByName(initialFamilies)) const [variants, setVariants] = useState(() => normalizeAssetVariantsWithUv(initialVariants)) - const [substrates, setSubstrates] = useState(sortByName(initialSubstrates)) + const [substrates] = useState(sortByName(initialSubstrates)) const finishes = initialFinishes const [selectedVariantId, setSelectedVariantId] = useState(null) const [submitting, setSubmitting] = useState(false) @@ -1032,14 +239,13 @@ export default function AssetCenterClient({ similarityNotice: string | null } | null>(null) const [cadImportParsing, setCadImportParsing] = useState(false) - const [cadImportDerivedDimensions, setCadImportDerivedDimensions] = useState>({}) const [autoInferredFields, setAutoInferredFields] = useState>(new Set()) const [previewNodeNames, setPreviewNodeNames] = useState([]) const [selectedPreviewNodeName, setSelectedPreviewNodeName] = useState(null) const [previewNodeOffsetMap, setPreviewNodeOffsetMap] = useState({}) const [previewNodeRotationMap, setPreviewNodeRotationMap] = useState({}) - const [previewNodeTransformUndoStack, setPreviewNodeTransformUndoStack] = useState([]) - const [previewNodeTransformRedoStack, setPreviewNodeTransformRedoStack] = useState([]) + const [, setPreviewNodeTransformUndoStack] = useState([]) + const [, setPreviewNodeTransformRedoStack] = useState([]) const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate'>('translate') const [previewContextMenuState, setPreviewContextMenuState] = useState<{ visible: boolean @@ -1465,25 +671,6 @@ export default function AssetCenterClient({ [selectedCadSubstrate], ) - const cadImportEstimatedCostPrice = useMemo( - () => - calculateEstimatedCost({ - lengthMm: - typeof cadImportDerivedDimensions.length === 'number' - ? cadImportDerivedDimensions.length - : null, - widthMm: - typeof cadImportDerivedDimensions.width === 'number' - ? cadImportDerivedDimensions.width - : null, - sheetLengthMm: selectedCadSubstrate?.sheetLengthMm, - sheetWidthMm: selectedCadSubstrate?.sheetWidthMm, - suggestedPrice: selectedCadSubstrate?.suggestedPrice, - lossFactor: toOptionalNumber(cadImportForm.lossFactor), - }), - [cadImportDerivedDimensions, selectedCadSubstrate, cadImportForm.lossFactor], - ) - const cadImportConnectionRoles = useMemo( () => splitConnectionRoles(cadImportForm.connectionRoles), [cadImportForm.connectionRoles], @@ -1498,11 +685,6 @@ export default function AssetCenterClient({ [cadImportForm, selectedSeriesId], ) - const cadImportChecklist = useMemo( - () => buildCadImportChecklist(cadImportForm.assetType), - [cadImportForm.assetType], - ) - function getVariantsForFamily( familyId: string, filter: ReviewVisibilityFilter = 'ALL', @@ -1541,11 +723,11 @@ export default function AssetCenterClient({ ...nextForm, familyMode: 'existing', familyId: family.id, + variantName: buildVariantIdentityPrefix(family.code), }) setCadImportSubmitAttempted(false) setCadImportResult(null) setAutoInferredFields(new Set()) - setCadImportDerivedDimensions({}) setCadImportModalOpen(true) } @@ -1553,7 +735,6 @@ export default function AssetCenterClient({ setCadImportModalOpen(false) setCadImportSubmitAttempted(false) setCadImportResult(null) - setCadImportDerivedDimensions({}) } function prepareNextCadImport() { @@ -1565,7 +746,6 @@ export default function AssetCenterClient({ setCadImportSubmitAttempted(false) setCadImportResult(null) setAutoInferredFields(new Set()) - setCadImportDerivedDimensions({}) } function openSimilarityModal(params: { @@ -1646,18 +826,8 @@ export default function AssetCenterClient({ : current.variantName, } }) - setCadImportDerivedDimensions( - cadExportData.panels?.[0] - ? { - length: cadExportData.panels[0].size?.[0] ?? 0, - width: cadExportData.panels[0].size?.[1] ?? 0, - thickness: cadExportData.panels[0].size?.[2] ?? 0, - } - : {}, - ) } catch (error) { toast.warning(error instanceof Error ? error.message : '未能自动读取 ZIP 命名建议') - setCadImportDerivedDimensions({}) } finally { setCadImportParsing(false) } @@ -1907,13 +1077,13 @@ export default function AssetCenterClient({ setVariantModalOpen(true) } - function openDuplicateVariantModal( + const openDuplicateVariantModal = useCallback(( variant: AssetVariantDTO, draft?: { substrateId?: string | null uvConfig?: AssetVariantUvConfig | null }, - ) { + ) => { if (!canOperateDesignWorkspace) { showReadOnlyWarning() return @@ -1948,7 +1118,7 @@ export default function AssetCenterClient({ includeLegConnectorRole: false, }) setVariantModalOpen(true) - } + }, [canOperateDesignWorkspace, showReadOnlyWarning]) function openEditVariantModal(variant: AssetVariantDTO) { if (!canOperateDesignWorkspace) { @@ -2404,6 +1574,7 @@ export default function AssetCenterClient({ draftVariantUvConfig, refreshVariantThumbnailAfterUpdate, hasPendingVariantDraft, + openDuplicateVariantModal, selectedVariant, showReadOnlyWarning, toast, @@ -3243,235 +2414,36 @@ export default function AssetCenterClient({
@@ -3804,463 +2536,51 @@ export default function AssetCenterClient({ bodyMaxHeight={previewContextMenuBodyMaxHeight} > {activePreviewSubmenu === 'preview-substrate' ? ( -
-
-
-

- 选择基材 -

-
- - setPreviewSubstrateSearch(event.target.value)} - placeholder="搜索名称或编码" - className="h-7 w-full rounded-apple-md border border-apple-separator/80 bg-white/80 pl-7 pr-2 text-[12px] text-[#2d3335] outline-hidden transition placeholder:text-[#5a6062]/45 focus:border-black/[0.12] focus:bg-white" - /> -
-
- -
- {previewFilteredSubstrateItems.length > 0 ? ( - previewFilteredSubstrateItems.map((item) => ( - - )) - ) : ( -
- 没有匹配基材 -
- )} -
-
-
+ ) : null} {activePreviewSubmenu === 'preview-uv' ? ( -
-
-
-
-

- {activeVariantUvNodeLabel} - {!selectedFinishMaterialConfig ? ' · 未绑定基材花色' : ''} -

-

- 渲染参数 -

-
- - {variantUvConfigDirty ? '待保存' : '已同步'} - -
- -
-
- - 投影轴 - -
- {UV_PROJECTION_AXIS_OPTIONS.map((option) => { - const isSelected = variantUvEditorForm.textureProjectionAxis === option.value - return ( - - ) - })} -
-
- {[ - { - label: '整体重复', - ariaLabel: '整体重复', - value: variantUvEditorForm.textureRepeat, - placeholder: '1.0', - field: 'textureRepeat' as const, - }, - { - label: '旋转角度 °', - ariaLabel: '旋转角度 °', - value: variantUvEditorForm.textureRotation, - placeholder: '0°', - field: 'textureRotation' as const, - }, - { - label: '纹理尺寸 mm', - ariaLabel: '纹理尺寸 mm', - value: variantUvEditorForm.textureSizeMm, - placeholder: '600', - field: 'textureSizeMm' as const, - }, - { - label: 'U 压缩', - ariaLabel: 'U 压缩', - value: variantUvEditorForm.textureRepeatU, - placeholder: '1.0', - field: 'textureRepeatU' as const, - }, - { - label: 'V 压缩', - ariaLabel: 'V 压缩', - value: variantUvEditorForm.textureRepeatV, - placeholder: '1.0', - field: 'textureRepeatV' as const, - }, - ].map((item) => ( -
- - - handlePatchVariantUvEditorForm({ [item.field]: event.target.value }) - } - placeholder={item.placeholder} - className="h-6 w-[108px] rounded-apple-sm border border-apple-separator bg-white/80 px-2 text-right text-[12px] text-apple-label-primary outline-hidden transition placeholder:text-apple-label-tertiary focus:border-[#2d3335]" - /> -
- ))} - -
- -
- - -
- -
-
-
+ ) : null} )} - tab.type === cadImportForm.assetType)?.label ?? '资产'}`} - size="large" - footer={ - cadImportResult ? ( -
-
-
- - -
-
- ) : ( -
- - -
- ) - } - > -
- {cadImportResult ? ( -
-
-
-

导入完成

-

- 已创建规格款「{cadImportResult.variant.name}」。 -

-
- - 已导入 - -
- -
-
-

资产族

-

{cadImportResult.family.name}

-
-
-

规格款

-

{cadImportResult.variant.name}

-
-
-

板件

-

{cadImportResult.summary.panelCount} 块

-
-
-

孔位

-

{cadImportResult.summary.holeCount} 个

-
-
- - {cadImportResult.warnings.length > 0 ? ( -
-
- -

导入提醒

-
-
- {cadImportResult.warnings.map((warning, index) => ( -

{index + 1}. {warning}

- ))} -
-
- ) : null} - - {cadImportResult.similarCandidates.length > 0 ? ( -
- {renderSimilarCandidatesBlock({ - sourceLabel: '本次导入', - similarCandidates: cadImportResult.similarCandidates, - similarityNotice: cadImportResult.similarityNotice, - })} -
- ) : null} -
- ) : null} - - {!cadImportResult && selectedCadImportFamily ? ( -
-
-
-

目标资产族

-
-

- {selectedCadImportFamily.name} -

- - {selectedCadImportFamily.code} - -
-
-
-
- ) : null} - - {!cadImportResult && !selectedCadImportFamily && ( -
- 请先在右侧选择目标资产族,再从资产族内发起 CAD 导入。 -
- )} - - {!cadImportResult && ( -
-
-
-

- 规格款编码 -

-

- 系统自动生成 -

-
- setCadImportForm((current) => ({ ...current, variantName: e.target.value }))} - placeholder="例如 桌面板 1200 / 方管桌腿 / 十字连接件" - errorText={cadImportSubmitAttempted ? cadImportFieldErrors.variantName : undefined} - /> -
- -
- setCadImportForm((current) => ({ ...current, sizeLabel: e.target.value }))} - placeholder="例如 1200x600,不填则自动生成" - /> - setCadImportForm((current) => ({ ...current, lossFactor: e.target.value }))} - placeholder="例如 1.15" - /> -
- - {cadImportForm.assetType === 'BOARD' ? ( -
- { - void applyCadFileDefaults(e.target.files?.[0] ?? null) - }} - /> - -
-
- {autoInferredFields.size > 0 && !cadImportParsing && ( -

- 已从 ZIP 自动带出 {autoInferredFields.size} 个字段 -

- )} -
- {cadImportSubmitAttempted && cadImportFieldErrors.file && ( -

{cadImportFieldErrors.file}

- )} -
-
-
- )} -
- + form={cadImportForm} + onFormChange={setCadImportForm} + submitting={submitting} + onSubmit={handleImportCadAsset} + result={cadImportResult} + selectedFamily={selectedCadImportFamily} + substrates={substrates} + fieldErrors={cadImportFieldErrors} + submitAttempted={cadImportSubmitAttempted} + parsing={cadImportParsing} + autoInferredFields={autoInferredFields} + onFileChange={(file) => void applyCadFileDefaults(file)} + onPrepareNext={prepareNextCadImport} + assetTypeLabel={TYPE_TABS.find((tab) => tab.type === cadImportForm.assetType)?.label ?? '资产'} + renderSimilarCandidatesBlock={renderSimilarCandidatesBlock} + /> - {/* 规格款弹窗 —— 放在资产中心工作台外部,避免被父级 overflow/transform 裁切 */} - undefined : closeVariantModal} - title={variantModalMode === 'create' - ? (isDuplicateVariantMode ? '另存为新规格款' : '新建规格款') - : '编辑规格款'} - size="large" - footer={ -
- {variantModalMode === 'edit' && editingVariantId && ( -
- - -
- )} -
- - -
-
- } - > -
- {variantSaveProgress ? renderVariantSaveProgress(variantSaveProgress) : null} -
- setVariantForm((c) => ({ ...c, code: e.target.value }))} - placeholder="留空由系统自动生成" - /> - setVariantForm((c) => ({ ...c, name: e.target.value }))} - placeholder="例如 桌面板 1200 白橡" - /> -
-
- setVariantForm((c) => ({ ...c, supplierName: e.target.value }))} - placeholder="例如 汇材板业" - /> - setVariantForm((c) => ({ ...c, supplierCode: e.target.value }))} - placeholder="例如 HC" - /> - setVariantForm((c) => ({ ...c, versionNo: e.target.value }))} - placeholder="留空默认 V01" - /> - setVariantForm((c) => ({ ...c, barcode: e.target.value }))} - placeholder="留空自动生成" - /> -
-
- setVariantForm((c) => ({ ...c, sizeLabel: e.target.value }))} - placeholder="例如 1200x600" - disabled={isDuplicateVariantMode} - /> - applyInheritedVariant(e.target.value)} - disabled={isDuplicateVariantMode} - options={[ - { value: '', label: '不继承' }, - ...familyVariantOptions.map((v) => ({ value: v.id, label: v.name })), - ]} - /> -
- )} -
-

3D 模型

- {variantForm.modelUrl ? ( -
-

{variantForm.modelUrl}

- {!isDuplicateVariantMode ? ( - - ) : null} -
- ) : ( - { - const url = await uploadModelFile(file) - setVariantForm((c) => ({ ...c, modelUrl: url })) - }} - onValidationError={(err) => toast.error(err.message)} - maxSize={50} - /> - )} -
-
-

尺寸参数 (mm)

-
- setVariantForm((c) => ({ ...c, length: e.target.value }))} placeholder="1200" disabled={isDuplicateVariantMode} /> - setVariantForm((c) => ({ ...c, width: e.target.value }))} placeholder="600" disabled={isDuplicateVariantMode} /> - setVariantForm((c) => ({ ...c, thickness: e.target.value }))} placeholder="25" disabled={isDuplicateVariantMode} /> - setVariantForm((c) => ({ ...c, lossFactor: e.target.value }))} placeholder="例如 1.15" /> -
-
-
-

挂载点

-
- setVariantForm((c) => ({ ...c, mountPointName: e.target.value }))} /> - setVariantForm((c) => ({ ...c, mountX: e.target.value }))} /> - setVariantForm((c) => ({ ...c, mountY: e.target.value }))} /> - setVariantForm((c) => ({ ...c, mountZ: e.target.value }))} /> -
-
-
- - -
-
-
+ mode={variantModalMode} + isDuplicateMode={isDuplicateVariantMode} + form={variantForm} + onFormChange={setVariantForm} + substrates={substrates} + familyVariantOptions={familyVariantOptions} + variantEstimatedCostPrice={variantEstimatedCostPrice} + selectedVariantSubstrate={selectedVariantSubstrate} + variantSaveProgress={variantSaveProgress} + submitting={submitting} + editingVariantId={editingVariantId} + variants={variants} + onSave={handleSaveVariant} + onClose={closeVariantModal} + onDuplicate={openDuplicateVariantModal} + onDelete={(variant) => setDeleteTarget({ type: 'variant', id: variant.id, name: variant.name })} + onInherit={applyInheritedVariant} + onUploadModel={uploadModelFile} + onUploadError={(err) => toast.error(err.message)} + /> - {/* 资产族弹窗 */} - - {familyModalMode === 'edit' && ( - - )} -
- - -
-
- } - > -
-
-

资产类型

-
- {TYPE_TABS.map((tab) => { - const type = tab.type as AssetFamilyDTO['type'] - const active = familyForm.type === type - return ( - - ) - })} -
-
-
- setFamilyForm((c) => ({ ...c, code: e.target.value }))} - placeholder="留空由系统自动生成" - /> - setFamilyForm((c) => ({ ...c, name: e.target.value }))} - placeholder="例如 单板桌桌面板" - /> -
- {familyForm.type !== 'HARDWARE' ? ( -
-

归属系列

-
- {initialAssetSeries.map((series) => { - const checked = familyForm.assetSeriesId === series.id - return ( - - ) - })} -
-
- ) : null} -
- -
-

附加属性

- - 可选 - -
- -
-
-
-
-

- 连接角色 -

- -
- {familyRoleDefinitions.length > 0 ? ( -
- {familyRoleDefinitions.map((definition) => ( -
- { - setFamilyForm((current) => ({ - ...current, - selectedRoles: e.target.checked - ? normalizeConnectionRoles([...current.selectedRoles, definition.value]) - : current.selectedRoles.filter((role) => role !== definition.value), - })) - }} - label={definition.label} - /> - - {definition.builtIn ? '内置' : '自定义'} - - - -
- ))} -
- ) : ( -
- 暂无连接角色。 -
- )} -
-