This commit is contained in:
2026-09-16 17:15:18 +08:00
parent 854d484142
commit 392f5b7386
6 changed files with 252 additions and 454 deletions
+50 -232
View File
@@ -1,258 +1,76 @@
# 测试环境 SQL 执行说明 # 区域数据库空库初始化
## 数据库初始化与权限发布入口 ## 唯一入口与区域边界
- 国内区域全量入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境升级,不再按日期拆分结构发布脚本。 当前开发阶段只维护纯初始化,不提供存量升级、数据回填、清理或兼容入口。两个 SQL 文件沿用原文件名,名称中的“增量”不表示可向已有业务库重放。
- 通用权限点独立入口:`权限点初始化-MySQL8.sql`。国内与海外业务的 Admin/Client 权限主数据均在 `baiyue_iam` 统一管理。当发布内容只新增或更新 IAM 权限资源及默认角色授权,且 IAM 基础表、父菜单和系统角色已存在时,可只执行该文件。它不建表,不创建账号、客户、仓库或业务数据。
- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`。该文件已经内联 Lastleg 与 Warehouse Overseas 的完整空库基线,会自行创建并初始化 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 和独立的 `baiyue_event_bridge_overseas`,清库后不得再额外执行模块基线。IAM 安全事件 Inbox 和可恢复投影位于 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;AUTHORIZATION 同步冻结当前 `PLATFORM_ADMIN` 角色及 IAM 跨区前缀下的完整权限集合,保证空库重建后海外 Gateway 可立即鉴权。统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,其他既有开发账号不建立兼容表或长期补投入口。
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
- 该脚本汇总订单与客户、入库测量与计费及已实现国内货柜履约出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口;义乌、东莞、洛杉矶、纽约四个固定作业仓均初始化 `RECEIVING`,其中洛杉矶和纽约使用海外接柜入仓收货语义,FBA 目的地址不创建作业能力。
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
- IAM 跨区安全事件基线已内联到固定入口,包括主体权限版本、客户状态版本和可靠事件发送箱 3 张表;模块溯源脚本为 `iam/src/main/resources/db/security-event-outbox.sql`。IAM 新版本启动前必须先执行固定入口,避免安全事件 Relay 和授权快照查询因缺表失败。
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
后续追加增量必须满足: | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 136 |
1. 只新增或兼容升级,不清表、不删除业务数据。 - 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
2. 新字段、索引、约束必须先查询 `information_schema`,允许整份脚本重复执行。 - 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。
3. 系统权限或字典配置使用唯一键和 `ON DUPLICATE KEY UPDATE` 幂等写入。 - 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。
4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。
5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。
### 权限点独立入口边界 ## 执行前提与失败边界
- 新增权限时,同步维护 IAM 模块来源、`权限点初始化-MySQL8.sql`、对应区域的全量统一 SQL、前端按钮/路由、Controller `@PreAuthorize` 和 OpenAPI。 1. 获得目标环境、精确数据库范围的当轮授权。初始化授权不等于清库授权;两个 SQL 本身不删除数据库、表或业务数据。
- 权限点入口只用于既有 IAM 基线上的权限新增或元数据/默认授权更新。涉及建表、字段、索引、角色创建或其他业务数据时,仍执行全量统一 SQL。 2. MySQL 8.0+,目标区域 schema 必须不存在或为空。已有同名表时报错,不使用 `CREATE TABLE IF NOT EXISTS` 绕过旧结构。
- 独立入口必须可重复执行,执行前校验稳定权限 ID/权限码没有被其他语义占用,执行后只在授权完整时返回 `permission_initialization_status = PASS`。 3. 执行用户需要目标区域建库、建表、索引、引用、触发器及种子插入权限,不需要另一地区的访问权。
- 权限写入后不修改 Redis 中已签发的会话快照;受影响用户必须退出并重新登录。权限撤销不通过该 SQL 直接处理,应走 IAM 角色授权命令以同步撤销会话。 4. 客户端须支持 `DELIMITER`。完整执行文件,关闭“遇错继续”,禁止 `mysql --force`。不要选中片段执行,不要前后串行执行模块 SQL。
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
## Finance 首期人工财务基线 成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/136`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。 ## 平台管理员密码
- 首期只支持 CNY,包含平台主体、客户默认 30 天账期、账单及明细、人工收款及账单分配、单票/整柜人工成本、体积分摊结果、Firstleg 费用来源事件、幂等记录和不可变审计日志。
- 不包含钱包、发票、供应商在线应付、银行自动对账、复杂核销、汇率和月结。
- 统一脚本末尾会强校验 13 张 Finance 表、客户隔离/CNY/人工凭证关键字段,以及费用来源、Inbox、账单版本快照、收款、成本和写操作幂等唯一约束;任一缺失不会返回 `PASS`。
## 历史订单与客户回归脚本 国内只初始化一个 `platform_admin` 及对应 IAM 用户、成员和角色关系,不创建测试客户或客户账号。密码不在源码内置。
- 历史文件:`2026-07-31订单与客户回归测试环境增量迁移.sql`,仅供追溯,不再作为统一入口。 在**同一连接**中,先由安全外部配置设置会话变量 `@platform_admin_password_hash`,再执行国内文件。值须为实际初始密码生成的 bcrypt 哈希(60 字符);不要传明文、示例哈希或把真实值存进仓库。`password_change_required=1`,初次登录必须改密。
- 适用数据库:MySQL 8.0。
- 执行账号需要对 `baiyue_iam`、`baiyue_warehouse`、`baiyue_order`、`baiyue_firstleg` 具备建表、改表、建索引、创建临时存储过程和数据写入权限。
- 脚本不会创建数据库,不会清理或删除业务数据,也不会写入演示租户、演示用户、演示仓库、演示往来单位或快递公司样本。
- 脚本可重复执行。MySQL 的 DDL 会隐式提交,因此不能把整份跨库脚本视为可整体回滚的事务。
- 该历史脚本曾内联 IAM 客户档案字段迁移和 `platform:customer:view` 权限迁移,当前不得再单独执行。
## IAM 来源与执行顺序 变量缺失或格式错误会在 Auth 账号插入时被非空约束拒绝;Auth 表可能已创建,属于失败的半初始化,应停止。海外文件不需要密码变量。
固定入口中的 IAM 段按以下模块迁移依赖顺序组织,并通过元数据判断或 `ON DUPLICATE KEY UPDATE` 保持可重复执行: 本地验收入口读取当前进程环境变量 `INIT_PLATFORM_ADMIN_PASSWORD_HASH`,经标准输入传入同一 MySQL 会话,不写入命令行、暂存 SQL 或日志。数据库连接认证独立由外部登录配置或 `MYSQL_PWD` 提供,不要与平台账号密码混淆。
1. IAM 基线表必须已存在(`iam.sql`)。 ## 审核保留的系统种子
2. 权限父菜单和既有客户列表/审核权限基线(`phase1-admin-rbac.sql`、`rbac-role-management-v2.sql`)。
3. `phase1-platform-master-data-and-permission-management.sql` 中的 `platform:customer:view`、既有角色继承及管理员授权。
4. `phase1-customer-profile.sql` 中的十个 `iam_tenant` 客户档案字段。
5. `phase1-onboarding.sql` 中的 `PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段。
固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。 | 所属库 | 保留内容 | 不包含 |
|---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、346 个权限、521 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
| Lastleg | 国内固定管理员对应的 USER/AUTHORIZATION version=0 安全投影,当前角色及完整 106 项海外权限 | 客户、订单、库存、发运门禁事实或新账号 |
测试环境结构发布只执行全量固定入口;纯权限点发布可改用权限点独立入口。历史脚本和模块脚本不应在任一发布入口执行后再次串行执行。 海外管理员投影只是国内已定义身份的初始化快照,不是独立账号或权限中心。后续变更仍由 IAM 事件同步,不提供历史投影回填。
## 执行方式 扣货写权限只默认授予平台管理员,与 IAM 扣货权限合同一致;旧脚本重放时通过范围授权额外授予订单运营的那一条关联不进入新基线。
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 示例 `TP-001`、`TP-002`、`YW-TP-002`、`DG-TP-002`、A1/A0 库位、`EXP-TEST`、`CAR-001` 不再初始化。验收资源通过业务入口配置;既有只复用托盘和库位的造数脚本须先满足各自前置条件,不能默认清库后已存在。
执行前先重新连接目标数据库,开启客户端 keepalive,并关闭“遇错继续执行”;出现连接断开、语法错误或 `SQLSTATE 45000` 后不得从失败位置续跑。由于 DDL 会隐式提交,存量环境可能已经部分更新;修复失败原因后应使用同一固定入口从第一行完整重跑,依靠脚本的元数据判断和幂等写入收敛结构。若出现 MySQL `4031`,先处理客户端连接保活或由数据库管理员调整 `wait_timeout` / `interactive_timeout`,再重新连接执行。 ## 维护规则
执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 - 最终字段、类型、默认值、索引、外键、CHECK、注释直接维护在 CREATE TABLE;64 个国内业务触发器保留最终定义。不追加 ALTER、迁移过程、动态 DDL、UPDATE 回填或 DELETE 清理段。
- 系统种子直接维护最终 INSERT,不保留退役 ID 清理或 ON DUPLICATE KEY UPDATE 升级。权限变更联查 IAM 来源、Controller、OpenAPI 与调用端。
- 原 `权限点初始化-MySQL8.sql` 独立补丁已撤销,订单申报权限和默认授权已纳入国内 IAM 基线,不另设存量权限发布入口。
- 模块 `src/main/resources/db/` 用作结构来源和开发追溯,不是区域部署前置;不再向历史迁移文件追加发布步骤。修改当前字段时同步模块有效建表定义及其合同。
- 不在 SQL 中写入真实客户数据、环境地址、固定密码或 Token。
## 国内货柜履约分柜、预装、拣货与装柜当前基线 ## 本地验证
- 模块表结构来源:`firstleg/src/main/resources/db/phase3-split-preload-schema.sql`、`firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql`。 离线合同不连接数据库:
- 国内货柜履约平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。
- 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。
- 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少国内货柜履约操作表而失败。
- 统一脚本会创建智能装柜任务 `fl_loading_pick_task`,并把历史入库汇总任务按字段签名无损迁移到 `fl_inbound_loading_pick_task`;未知混合结构会直接终止。`WAIT_SPLIT` 是入库测量与计费有效终点,脚本不会改写主表或状态历史。
- 下载中心固定使用 `5103040-5103041`,库位权限迁移到 `5103060-5103064`;入库装柜权限迁移到 `5102121-5102129`,迁移会按 `permission_code` 保留角色授权,目标 ID 被其他语义占用时直接终止。
部署前可只读盘点: ```powershell
node --test scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs scripts/inbound-fee-unified-sql-contract.test.mjs scripts/inbound-hold-unified-sql-contract.test.mjs
```sql .\scripts\local-db-full-acceptance.ps1 -DryRun
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN (
'fl_preload_plan', 'fl_split_operation', 'fl_loading_evidence',
'fl_loading_operation', 'fl_loading_pick_task'
)
ORDER BY table_name;
SELECT permission_code
FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload',
'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit',
'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete',
'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit',
'platform:firstleg:inbound:picking:view'
)
ORDER BY permission_code;
``` ```
## 公共导出中心存量升级 获得精确本地库清理授权并配置认证、管理员哈希后,可用 `local-db-full-acceptance.ps1 -Port <已授权端口> -ConfirmResetProjectSchemas -Rounds 2`。它删除列出的 12 个项目 schema,不得在有待保留数据的实例运行;详见 `scripts/README.md`。该入口不会自动启动隔离实例。
固定入口已包含 File 当前 6 张表,并通过 `information_schema` 为旧版 `file_export_task` 幂等补齐 8 个调度、重试和租约字段,不再允许通过 `DROP TABLE` 重建。执行完成后可额外核验: 本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。
```sql 码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。
SELECT COUNT(*) = 3 AS export_tables_ready
FROM information_schema.tables
WHERE table_schema = 'baiyue_file'
AND table_name IN ('file_export_task', 'file_export_outbox', 'file_export_task_guard');
SELECT COUNT(*) = 1 AS creation_guard_ready
FROM baiyue_file.file_export_task_guard
WHERE guard_key = 'GLOBAL_CREATE';
SELECT COUNT(*) = 8 AS scheduler_columns_ready
FROM information_schema.columns
WHERE table_schema = 'baiyue_file' AND table_name = 'file_export_task'
AND column_name IN ('dispatch_version', 'attempt_count', 'max_attempts', 'next_retry_at',
'lease_owner', 'lease_until', 'last_heartbeat_at', 'execution_deadline_at');
```
任一结果不是 1,禁止启动 File Service;只能继续追加兼容性增量修复,不得删除存量导出任务。
## 入库测量与计费第三方测量增量
- 模块脚本:`firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql`。
- 历史入库测量与计费脚本:`2026-08-04-入库新增表字段-MySQL8一键执行.sql` 已冻结,仅供追溯。
- 当前固定入口 `头程数据库统一增量-MySQL8.sql` 已内联该增量以及订单船司字段、入库测量与计费 28 项 IAM 权限和最新仓储资源租户字段。
- 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。
- 部署 Firstleg 新版本前必须完整执行固定入口,并确认末尾总体验证为 `PASS`。
## 稳定箱号与单箱当前测量
- 固定入口已包含 `fl_inbound_carton_label`,首次打印时按订单货物明细冻结每个实际箱的 `box_code` 和箱下标;重复打印、部分补打及设备测量均复用该映射。
- `fl_inbound_measurement_item` 已补齐 `box_code`、`machine_no`、`device_measured_at`、`updated_at`,并通过 `uk_measure_box_code (tenant_id, box_code)` 保证同一客户下每个箱号只有一条当前测量记录。
- 部署 Firstleg 新版本前必须完整执行固定入口。末尾会强校验映射表、四个测量字段及三个唯一约束,任一缺失均不会返回 `PASS`。
## 当前主体字段语义核验
- 业务单租户阶段,业务表中的 `tenant_id > 0` 是客户 ID 和客户隔离键,不表示上级租户;费用项目是 `tenant_id = 0` 的平台公共主数据,入库费用明细仍以 `tenant_id > 0` 隔离客户并引用公共 `fee_item_id`。
- `fl_third_party_call_log.tenant_id = 0` 仅表示第三方主体未知或尚未解析的哨兵值,不是平台客户记录或平台操作作用域,绝不能据此授权、查询或写入客户业务数据。上线后可只读统计:
```sql
SELECT COUNT(*) AS unresolved_third_party_calls
FROM baiyue_firstleg.fl_third_party_call_log
WHERE tenant_id = 0;
```
- 平台公共仓库、地址、服务能力、往来单位 `wh_partner`、快递公司、托盘、库位、预占和占用固定归平台作用域 `tenant_id=0`;Firstleg 的 `warehouse_resource_tenant_id` 同步固定为 `0`。
- 客户订单、客户文件和客户费用规则仍使用 `tenant_id>0` 做客户隔离;往来单位不是客户私有数据,统一脚本会把 `wh_partner` 和 `wh_partner_type` 的作用域归零。
## 平台公共资源归零迁移与核验
测试环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不要再串行执行模块脚本。执行前可只读盘点公共资源旧归属:
```sql
SELECT 'wh_warehouse' AS resource, COUNT(*) AS total, COALESCE(SUM(tenant_id <> 0), 0) AS non_platform
FROM baiyue_warehouse.wh_warehouse
UNION ALL SELECT 'wh_warehouse_address', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_warehouse_address
UNION ALL SELECT 'wh_service_capability', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_service_capability
UNION ALL SELECT 'wh_express_company', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_express_company
UNION ALL SELECT 'wh_pallet', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_pallet
UNION ALL SELECT 'wh_location', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location
UNION ALL SELECT 'wh_location_reservation', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location_reservation
UNION ALL SELECT 'wh_location_occupancy', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location_occupancy
UNION ALL SELECT 'fl_inbound_pallet_allocation.resource_scope', COUNT(*),
COALESCE(SUM(warehouse_resource_tenant_id <> 0), 0)
FROM baiyue_firstleg.fl_inbound_pallet_allocation;
```
脚本会先把明确公共资源迁到 `0`,按父仓库回填地址和能力,再迁移平台往来单位、托盘、库位、预占、占用及 Firstleg 资源归属快照。末尾 `PASS` 前会强校验公共资源(含往来单位)全部归零、父子一致,以及客户费用规则不存在 `tenant_id=0`。执行后可再只读核验:
```sql
SELECT
(SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner
WHERE tenant_id <> 0 OR data_scope_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner_type
WHERE tenant_id <> 0 OR data_scope_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_express_company WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_location WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_reservation WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_occupancy WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_firstleg.fl_inbound_pallet_allocation
WHERE warehouse_resource_tenant_id <> 0) AS public_scope_violations,
(SELECT COUNT(*) FROM baiyue_warehouse.wh_overseas_storage_fee_rule WHERE tenant_id = 0)
AS customer_scope_violations;
SELECT
(SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address a
JOIN baiyue_warehouse.wh_warehouse w ON w.id = a.warehouse_id
WHERE a.tenant_id <> w.tenant_id)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability c
JOIN baiyue_warehouse.wh_warehouse w ON w.id = c.warehouse_id
WHERE c.tenant_id <> w.tenant_id)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet p
JOIN baiyue_warehouse.wh_warehouse w ON w.id = p.warehouse_id
WHERE p.tenant_id <> w.tenant_id)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_location l
JOIN baiyue_warehouse.wh_warehouse w ON w.id = l.warehouse_id
WHERE l.tenant_id <> w.tenant_id) AS parent_child_scope_mismatches;
```
上述三个结果 `public_scope_violations`、`customer_scope_violations`、`parent_child_scope_mismatches` 必须都为 `0`。
## 船司 FBA 价格公共作用域
- 当前测试环境 `fl_carrier_fba_rate` 为空表,不执行存量价格迁移。
- 初始化表结构将船司 FBA 价格定义为平台公共价格,Firstleg 创建、查询和计费匹配固定使用 `tenant_id = 0`;客户计费规则仍按客户 `tenant_id > 0` 隔离。
- 初始化或重建测试库后,再启动或重启 `firstleg-service`。
## 海外仓阶梯费用增量
- `2026-08-05` 增量已追加到固定入口末尾,不创建新的日期部署脚本。
- Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。
- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。
- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。
## 海外仓权限目录
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。
- 海外仓业务目录保留海外出库、派送列表入口;海外仓出库与派送写操作和客户派送权限由后续海外仓出库与派送区块直接收敛,不使用旧码兼容映射。
## 海外排车、出库与派送权限
- 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。
- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态/资料管理、异常订单列表/创建/处理和客户回访列表/编辑十六项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。
- 派送三类资料上传、替换、解绑和保存统一使用 `platform:overseas-warehouse:delivery:documents`,读取和下载仍使用 `platform:overseas-warehouse:delivery:list`;默认授予 `PLATFORM_ADMIN`、`PLATFORM_CUSTOM_ADMIN`、`WAREHOUSE_OPERATOR`。
- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。
- `WAREHOUSE_OPERATOR` 获得十六项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
## 海外排车与出库结构升级
- 派送三类资料当前基线包含 `ll_delivery_order.bol_no/documents_json` 与 `ll_delivery_document_revision`;海外固定入口和 Lastleg 模块基线同步维护这些定义。仅保存装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)三个类别,摘要按类别计数。资料历史不可变,文件对象由 File 按真实客户与派送单绑定;不按 BOL 文本复用文件,不生成旧类别迁移或清理脚本。
- 海外仓出库与派送数据库部署只执行 `海外区域统一增量-MySQL8.sql`;无论空库还是存量测试库都不得在此前单独执行 Lastleg 或 Warehouse Overseas 模块基线,完整执行固定入口并确认最末 `migration_status = PASS`。
- 该海外固定入口会在任何海外仓出库与派送 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有海外仓业务库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 海外仓出库与派送权限目录,不创建或修改 `baiyue_lastleg` 业务表。
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。
## 码头车队邮件
国内统一入口包含 `fl_terminal_drayage_mail`,用于冻结收件地址、两类邮件正文、投递状态和失败信息;创建后才可发布相关 Firstleg 功能。发信配置默认关闭,配置说明见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。邮件功能涉及 Admin、Firstleg 和 Warehouse 内部联系资料接口,无新增权限码。
@@ -78,7 +78,7 @@ CREATE TABLE sys_audit_outbox (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱';
-- 系统账号:仅平台管理员,密码从会话变量传入。 -- 系统账号:仅平台管理员,密码从会话变量传入。
INSERT INTO auth_account (id, username, phone, password_hash, display_name, account_status, failed_login_count, locked_until, token_version, password_change_required, default_tenant_id, operator_tenant_id, default_membership_id, default_app_code, deleted, last_login_at, created_at, updated_at) VALUES (1000001,'platform_admin',NULL,IF(@platform_admin_password_hash REGEXP '^[$]2[aby][$][0-9]{2}[$][./A-Za-z0-9]{53}$', @platform_admin_password_hash, NULL),'平台系统管理员','ACTIVE',0,NULL,1,1,0,0,3000001,'PLATFORM',0,NULL,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); INSERT INTO auth_account (id, username, phone, password_hash, display_name, account_status, failed_login_count, locked_until, token_version, password_change_required, default_tenant_id, operator_tenant_id, default_membership_id, default_app_code, deleted, last_login_at, created_at, updated_at) VALUES (1000001,'platform_admin',NULL,IF(CAST(@platform_admin_password_hash AS CHAR CHARACTER SET utf8mb4) REGEXP '^[$]2[aby][$](0[4-9]|[12][0-9]|3[01])[$][./A-Za-z0-9]{53}$', @platform_admin_password_hash, NULL),'平台系统管理员','ACTIVE',0,NULL,1,1,0,0,3000001,'PLATFORM',0,NULL,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3));
-- 服务 schema:baiyue_iam -- 服务 schema:baiyue_iam
CREATE DATABASE IF NOT EXISTS baiyue_iam DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; CREATE DATABASE IF NOT EXISTS baiyue_iam DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
@@ -6324,7 +6324,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type,
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0);
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0);
-- 系统基线 iam_role_permission:522 行。 -- 系统基线 iam_role_permission:521 行。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3));
@@ -6660,7 +6660,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102027,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102027,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102030,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102030,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102031,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102031,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102032,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102041,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102041,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102042,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102042,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102043,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102043,0,CURRENT_TIMESTAMP(3));
@@ -1,203 +1,117 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs'
const sql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') const cn = readInitialization()
const overseasSql = readFileSync(new URL('../../deploy/sql/海外区域统一增量-MySQL8.sql', import.meta.url), 'utf8') const overseas = readInitialization('OVERSEAS')
const lastlegBaseline = readFileSync(new URL('../../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8') const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse',
const lastlegPermissionSeed = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8') 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge', 'baiyue_finance']
const phase5DeliveryPermissionSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase5-delivery-permissions.sql', import.meta.url), 'utf8') const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse']
const overseasWarehousePermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-warehouse/permissions.ts', import.meta.url), 'utf8')
const overseasBusinessPermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-business/permissions.ts', import.meta.url), 'utf8')
const overseasMasterDataPermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-master-data/permissions.ts', import.meta.url), 'utf8')
const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-admin-rbac.sql', import.meta.url), 'utf8')
const oceanBookingSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8')
const truckAppointmentSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8')
test('统一初始化 SQL 只保留平台管理员且不创建固定客户', () => { test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => {
assert.match(sql, /最小登录基线只保留平台系统管理员/) for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 136]]) {
assert.equal((sql.match(/'platform_admin'/g) || []).length, 1) assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
assert.doesNotMatch(sql, /'merchant_admin'|'IT_TEST'|'IT 测试客户'/) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
assert.doesNotMatch(iamSeed, /'IT_TEST'|'IT 测试客户'|\(10001,/) const body = sql.replace(/^--.*$/gm, '')
const accountSeed = sql.match(/INSERT INTO auth_account \([\s\S]*?deleted = 0;/)?.[0] for (const schema of other) assert.doesNotMatch(body, new RegExp('\\b' + schema + '\\b'))
assert.ok(accountSeed, 'missing auth account seed') assert.equal(tableDefinitions(sql).size, count)
assert.doesNotMatch(accountSeed, /ON DUPLICATE KEY UPDATE[\s\S]*?password_hash\s*=/, for (const key of tableDefinitions(sql).keys()) assert.ok(allowed.includes(key.split('.')[0]))
're-running the unified SQL must not reset an existing account password')
})
test('统一初始化直接提供tenant0商户角色与权限模板', () => {
assert.match(sql, /4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'/)
assert.match(sql, /5300000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:enterprise'/)
assert.match(sql, /5301024, 0, 'MERCHANT', 'BUTTON',[\s\S]*?'merchant:order:quick-create'/)
assert.match(sql, /5305000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:finance:statement'/)
assert.match(sql, /r\.id=4000100[\s\S]*?客户全局管理员 Finance 模板授权不完整/)
})
test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => {
for (const marker of [
"'TP-001'", "'TP-002'", "'A1 普通库位'", "'A0 共享溢出位'",
"'CAR-001'", "'CARRIER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'",
]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`)
assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/)
assert.match(sql, /193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'洛杉矶仓海外接柜入仓收货能力'/)
assert.match(sql, /193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'纽约仓海外接柜入仓收货能力'/)
assert.match(sql, /固定平台作业仓收货能力写入校验失败/)
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?deleted = 0;/)?.[0]
assert.ok(palletSeed, 'missing pallet initialization seed')
assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/)
})
test('国内统一初始化支持真实空库且不越界创建海外业务库', () => {
assert.doesNotMatch(sql, /CREATE DATABASE IF NOT EXISTS baiyue_lastleg|USE baiyue_lastleg;/)
assert.match(sql, /IF EXISTS \(SELECT 1 FROM information_schema\.tables WHERE table_schema=DATABASE\(\)[\s\S]*?table_name='fl_ocean_booking'\) THEN[\s\S]*?UPDATE fl_container_booking container_booking[\s\S]*?JOIN fl_ocean_booking ocean_booking/)
assert.ok(
sql.indexOf("table_name='fl_ocean_booking') THEN") < sql.indexOf('CREATE TABLE IF NOT EXISTS fl_ocean_booking ('),
'empty-database backfill must be guarded before the ocean-booking baseline is created',
)
})
test('国内统一初始化在注释门禁前创建订单申报快照表', () => {
for (const table of ['fl_order_declaration_snapshot', 'fl_order_declaration_event']) {
const createIndex = sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table} (`)
const commentIndex = sql.indexOf(`CALL queue_table_comment('baiyue_firstleg', '${table}'`)
assert.ok(createIndex >= 0, `missing domestic baseline table: ${table}`)
assert.ok(commentIndex >= 0, `missing domestic table comment gate: ${table}`)
assert.ok(createIndex < commentIndex, `${table} must exist before the table-comment gate runs`)
} }
}) })
test('国内统一增量在 Firstleg 补列前重建已由 Warehouse 段删除的过程', () => { test('纯初始化禁止迁移、数据回填、清理、重复建表和提前成功', () => {
const warehouseDrop = sql.indexOf('DROP PROCEDURE IF EXISTS add_column_if_missing;', for (const sql of [cn, overseas]) {
sql.indexOf('CALL assert_platform_public_resource_scope();')) const body = sql.replace(/^--.*$/gm, '')
const firstlegCall = sql.indexOf("CALL add_column_if_missing('fl_price_book_version', 'voided_by'") assert.doesNotMatch(body, /\bALTER\s+TABLE|\bDROP\s+(?:DATABASE|TABLE)|\bTRUNCATE\b|\bRENAME\s+TABLE/i)
const firstlegCreate = sql.lastIndexOf('CREATE PROCEDURE add_column_if_missing(', firstlegCall) assert.doesNotMatch(body, /\bCREATE\s+(?:TEMPORARY\s+TABLE|PROCEDURE)|\bPREPARE\b|\bEXECUTE\b|\bON DUPLICATE KEY UPDATE|\bINSERT IGNORE/i)
const firstlegDrop = sql.indexOf('DROP PROCEDURE IF EXISTS add_column_if_missing;', firstlegCall) // Runtime trigger bodies legitimately update the current loading aggregate.
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
assert.ok(warehouseDrop >= 0, 'Warehouse migration must clean up its helper procedure') assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
assert.ok(firstlegCreate > warehouseDrop, 'Firstleg must recreate the helper after the Warehouse cleanup') assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
assert.ok(firstlegCreate < firstlegCall, 'Firstleg helper must exist before the price-version ALTER calls') assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|136) AS initialized_tables;$/)
assert.ok(firstlegDrop > firstlegCall, 'Firstleg must clean up the helper after its ALTER calls') }
}) })
test('海外统一初始化内联空库基线且不依赖模块 SQL', () => { test('平台管理员由外部bcrypt引导,强制首登改密且不创建客户', () => {
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG/) const accounts = seedRows(cn, 'auth_account')
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/) assert.equal(accounts.length, 1)
assert.match(overseasSql, /CREATE DATABASE IF NOT EXISTS baiyue_lastleg/) assert.equal(accounts[0].username, 'platform_admin')
assert.match(overseasSql, /CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse/) assert.equal(accounts[0].password_change_required, '1')
assert.match(overseasSql, /CREATE TABLE IF NOT EXISTS ll_order \(/) assert.match(accounts[0].password_hash, /@platform_admin_password_hash/)
assert.match(overseasSql, /CREATE TABLE IF NOT EXISTS wh_location \(/) assert.match(accounts[0].password_hash, /REGEXP/)
assert.ok( assert.match(accounts[0].password_hash, /, NULL\)$/)
overseasSql.indexOf('CREATE TABLE IF NOT EXISTS ll_order (') < assert.doesNotMatch(cn, /'\$2[aby]\$[0-9]{2}\$[./A-Za-z0-9]{53}'/)
overseasSql.indexOf('CREATE TABLE IF NOT EXISTS ll_order_source_pallet ('), assert.deepEqual(seedRows(cn, 'iam_tenant').map(r => r.id), ['0'])
'll_order must exist before the source-pallet foreign key is created', assert.deepEqual(seedRows(cn, 'iam_user').map(r => r.auth_user_id), ['1000001'])
) assert.doesNotMatch(cn, /'merchant_admin'|'IT_TEST'|'EXP-TEST'|'CAR-001'|'TP-001'|'YW-TP-002'/)
assert.ok(
overseasSql.indexOf('CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse') <
overseasSql.indexOf('CREATE TABLE IF NOT EXISTS wh_location ('),
'the overseas warehouse schema must be selected before its baseline tables are created',
)
}) })
test('海外空库基线为固定平台管理员提供完整version0授权投影', () => { test('必要系统种子完整保留,但不写入示例资源与业务事实', () => {
const authorityBlock = lastlegBaseline.match( for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 346, iam_role_permission: 521,
/SET @platform_admin_overseas_authorities = JSON_ARRAY\(([\s\S]*?)\n\);/, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4,
)?.[1] fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4,
assert.ok(authorityBlock, 'missing platform_admin overseas authority snapshot') file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name)
const authorities = [...authorityBlock.matchAll(/'([^']+)'/g)].map((match) => match[1]) for (const name of ['wh_pallet', 'wh_location', 'wh_partner', 'wh_partner_type', 'wh_express_company',
const deferred = new Set([ 'ord_order', 'fl_inbound_order', 'fin_fee_source']) assert.equal(seedRows(cn, name).length, 0, name)
'platform:overseas-warehouse:carton:list', assert.deepEqual([...new Set([...overseas.matchAll(/^INSERT INTO (\w+)/gm)].map(m => m[1]))], ['ll_security_projection'])
'platform:overseas-warehouse:carton:create', assert.ok(seedRows(cn, 'wh_warehouse').every(r => r.tenant_id === '0'))
'platform:overseas-warehouse:carton:edit', assert.ok(seedRows(cn, 'wh_service_capability').every(r => r.service_type === 'RECEIVING' || r.capability_code === 'RECEIVING' || Object.values(r).includes('RECEIVING')))
'platform:overseas-warehouse:product:list', })
'platform:overseas-warehouse:product:create',
'platform:overseas-warehouse:product:edit', test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完整', () => {
]) const roles = seedRows(cn, 'iam_role'), permissions = seedRows(cn, 'iam_permission')
const roleIds = new Set(roles.map(r => r.id)), permissionIds = new Set(permissions.map(r => r.id))
assert.equal(new Set(permissions.map(r => r.permission_code)).size, permissions.length)
for (const row of seedRows(cn, 'iam_role_permission')) {
assert.ok(roleIds.has(row.role_id), 'missing role ' + row.role_id)
assert.ok(permissionIds.has(row.permission_id), 'missing permission ' + row.permission_id)
}
for (const row of permissions) if (row.parent_id && row.parent_id !== '0') assert.ok(permissionIds.has(row.parent_id), 'missing parent ' + row.parent_id)
const merchant = roleAuthorities(cn, 'MERCHANT_OWNER')
for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement',
'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code)
const admin = roleAuthorities(cn, 'PLATFORM_ADMIN')
for (const code of ['platform:order:declaration:edit', 'platform:firstleg:inbound:hold',
'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-business:purchase:list',
'platform:overseas-master-data:quote:list', 'platform:finance:fee-source:review']) assert.ok(admin.has(code), code)
})
test('海外固定身份投影完全匹配国内管理员的当前海外权限', () => {
const rows = seedRows(overseas, 'll_security_projection')
assert.equal(rows.length, 2)
const authorization = rows.find(r => r.projection_type === 'AUTHORIZATION' || r.subject_type === 'AUTHORIZATION' || Object.values(r).includes('AUTHORIZATION'))
assert.ok(authorization)
const authorities = JSON.parse(authorization.authorities_json)
const expected = [...roleAuthorities(cn, 'PLATFORM_ADMIN')].filter(code =>
/^(platform:finance:|platform:overseas-business:|platform:overseas-master-data:|platform:overseas-warehouse:)/.test(code))
assert.equal(authorities.length, 106) assert.equal(authorities.length, 106)
assert.ok(overseasSql.includes(`JSON_LENGTH(authorities_json) <> ${authorities.length}`), assert.equal(new Set(authorities).size, authorities.length)
'fixed-admin SQL assertion must match the complete authority snapshot') assert.deepEqual([...authorities].sort(), expected.sort())
assert.equal(new Set(authorities).size, authorities.length, 'baseline authorities must not contain duplicates') assert.deepEqual(JSON.parse(authorization.roles_json), ['PLATFORM_ADMIN'])
assert.equal(authorities.filter((item) => item.startsWith('platform:finance:')).length, 34) for (const row of rows) assert.ok(Object.values(row).includes('1000001'))
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-business:')).length, 11)
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-master-data:')).length, 15)
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 46)
for (const authority of authorities) {
assert.ok(sql.includes(`'${authority}'`), `authority is absent from the domestic IAM baseline: ${authority}`)
}
for (const source of [overseasWarehousePermissions, overseasBusinessPermissions, overseasMasterDataPermissions]) {
const declared = [...source.matchAll(/'((?:platform:overseas-warehouse|platform:overseas-business|platform:overseas-master-data):[^']+)'/g)]
.map((match) => match[1]).filter((authority) => !deferred.has(authority))
for (const authority of declared) {
assert.ok(
[lastlegPermissionSeed, phase5DeliveryPermissionSeed].some((seed) => seed.includes(`'${authority}'`)),
`Admin permission is absent from the IAM source seed: ${authority}`,
)
assert.ok(authorities.includes(authority), `Admin permission is absent from the fixed-admin overseas snapshot: ${authority}`)
}
}
for (const authority of deferred) {
assert.ok(!lastlegPermissionSeed.includes(`'${authority}'`), `deferred permission entered the IAM source seed: ${authority}`)
assert.ok(!authorities.includes(authority), `deferred permission entered the fixed-admin snapshot: ${authority}`)
}
for (const required of [
'platform:overseas-warehouse:last-mile-order:list',
'platform:overseas-warehouse:inbound-order:list',
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-business:purchase:list',
'platform:overseas-master-data:quote:list',
'platform:finance:fee-source:review',
]) assert.ok(authorities.includes(required), `missing fixed-admin overseas authority: ${required}`)
assert.match(lastlegBaseline, /SET @platform_admin_overseas_roles = JSON_ARRAY\('PLATFORM_ADMIN'\)/)
assert.doesNotMatch(lastlegBaseline, /\('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY\(\),JSON_ARRAY\(\)/)
assert.doesNotMatch(overseasSql, /\('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY\(\),JSON_ARRAY\(\)/)
const inline = overseasSql.match(
/-- BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG\r?\n([\s\S]*?)\r?\n\r?\n-- END INLINE EMPTY-DATABASE BASELINE: LASTLEG/,
)?.[1]
assert.ok(inline, 'missing inline Lastleg baseline')
assert.equal(inline.replace(/\r\n/g, '\n').trim(), lastlegBaseline.replace(/\r\n/g, '\n').trim())
}) })
test('当前海外基线不从废弃申请回填历史尾程订单', () => { test('跨区域放错位置的最终字段归正确区域且保留财务精度', () => {
for (const source of [lastlegBaseline, overseasSql]) { const fee = table(overseas, 'baiyue_lastleg', 'll_finance_fee_source')
assert.doesNotMatch(source, /INSERT IGNORE INTO ll_order\s*\([\s\S]*?SELECT a\.id,a\.discard_no/) for (const name of ['original_currency', 'original_amount', 'exchange_rate', 'exchange_rate_month',
} 'chargeable_quantity', 'quantity_unit', 'unit_price', 'period_start', 'period_end']) assert.match(fee, new RegExp('\\b' + name + '\\b'))
assert.match(fee, /original_amount decimal\(20,6\)/i)
assert.match(fee, /exchange_rate decimal\(20,6\)/i)
const returns = table(overseas, 'baiyue_lastleg', 'll_return_order')
assert.match(returns, /lastleg_order_id bigint/i)
assert.match(returns, /lastleg_order_no varchar\(32\)/i)
assert.match(table(cn, 'baiyue_finance', 'fin_fee_source'), /idx_fin_fee_source_wallet_hold \(wallet_hold_id\)/)
}) })
test('国内统一初始化补齐入库费用人工调整基线字段', () => { test('最终业务触发器与循环外键均存在,不留下发布过程', () => {
assert.match(sql, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/) assert.equal((cn.match(/^CREATE TRIGGER /gm) ?? []).length, 64)
assert.match(sql, /SET baseline_chargeable_quantity = chargeable_quantity[\s\S]*?WHERE baseline_chargeable_quantity IS NULL/) assert.doesNotMatch(cn, /CREATE DEFINER=/)
assert.match(sql, /tolerance_percent DECIMAL\(5,2\) NOT NULL DEFAULT 10\.00/) for (const name of ['trg_container_booking_before_update', 'trg_loading_pick_task_before_update',
assert.match(sql, /unified_assert_column\('baiyue_firstleg', 'fl_inbound_fee', 'baseline_chargeable_quantity'\)/) 'trg_order_fulfillment_split_insert', 'trg_inbound_split_item_before_delete']) assert.match(cn, new RegExp('CREATE TRIGGER ' + name + '\\b'))
assert.match(sql, /unified_assert_column\('baiyue_firstleg', 'fl_inbound_fee', 'tolerance_percent'\)/) assert.match(table(cn, 'baiyue_firstleg', 'fl_inbound_measurement_item'), /REFERENCES fl_inbound_split_item/)
}) assert.match(table(cn, 'baiyue_firstleg', 'fl_inbound_split_item'), /REFERENCES fl_inbound_measurement_item/)
const disable = cn.indexOf('SET SESSION foreign_key_checks = 0;')
test('三期权限迁移删除退役权限并只保留 canonical 权限', () => { const enable = cn.indexOf('SET SESSION foreign_key_checks = 1;')
for (const source of [sql, oceanBookingSchema]) { assert.ok(disable >= 0 && enable > disable)
assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102141,5102143,5102144,5102145\)/) assert.ok(!cn.slice(disable, enable).includes('INSERT INTO'))
assert.match(source, /5102147[\s\S]*?platform:firstleg:ocean-booking:edit/)
assert.match(source, /5102148[\s\S]*?platform:firstleg:ocean-booking:delete/)
assert.doesNotMatch(source, /platform:firstleg:ocean-booking:(view|cancel|change)/)
assert.ok(
source.indexOf('DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145)') <
source.indexOf('(5102147,0,\'PLATFORM\',\'BUTTON\',5102140,\'platform:firstleg:ocean-booking:edit\''),
'legacy ocean-booking edit permission must be removed before assigning the code to 5102147',
)
}
for (const source of [sql, truckAppointmentSchema]) {
assert.match(source, /DELETE FROM iam_permission WHERE id=5102151/)
assert.doesNotMatch(source, /platform:firstleg:truck-appointment:view[',]/)
}
assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102140 AND 5102149/)
assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102150 AND 5102154/)
})
test('海外仓物理托盘初始化与模块基线一致', () => {
const baseline = readFileSync(new URL('../../warehouse-overseas-service/src/main/resources/db/warehouse.sql', import.meta.url), 'utf8')
const inline = overseasSql.match(/-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE\r?\n([\s\S]*?)\r?\n\r?\n-- END INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/)?.[1]
assert.ok(inline)
assert.equal(inline.slice(inline.indexOf('-- This script is run')).replace(/\r\n/g, '\n').trim(), baseline.replace(/\r\n/g, '\n').trim())
}) })
@@ -1,22 +1,22 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { readInitialization, table } from './sql-baseline-contract-helpers.mjs'
const unified = readFileSync(new URL('../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
const firstleg = readFileSync(new URL('../firstleg/src/main/resources/db/phase2-inbound-schema.sql', import.meta.url), 'utf8') const firstleg = readFileSync(new URL('../firstleg/src/main/resources/db/phase2-inbound-schema.sql', import.meta.url), 'utf8')
const fee = table(readInitialization(), 'baiyue_firstleg', 'fl_inbound_fee')
test('统一增量与 Firstleg 入库费用计费基准和容差字段一致', () => { test('统一初始化与模块入库费用均直接定义计费基准和容差', () => {
for (const source of [unified, firstleg]) { for (const source of [fee, firstleg]) {
assert.match(source, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/) assert.match(source, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/i)
assert.match(source, /tolerance_percent DECIMAL\(5,2\) NOT NULL DEFAULT 10\.00/) assert.match(source, /tolerance_percent DECIMAL\(5,2\) NOT NULL DEFAULT '?10\.00'?/i)
assert.match(source, /SET baseline_chargeable_quantity = chargeable_quantity\s+WHERE baseline_chargeable_quantity IS NULL/)
assert.match(source, /MODIFY COLUMN baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/)
} }
assert.doesNotMatch(readInitialization(), /SET baseline_chargeable_quantity = chargeable_quantity|MODIFY COLUMN baseline_chargeable_quantity/)
}) })
test('统一增量最终检查阻止缺少入库费用计费字段的部署通过', () => { test('最终建表保留入库费用唯一身份和基准非空约束', () => {
const finalAssertion = unified.slice(unified.indexOf('CREATE PROCEDURE assert_unified_final_schema()')) assert.match(fee, /PRIMARY KEY \(id\)/)
assert.match(finalAssertion, /column_name='baseline_chargeable_quantity'[\s\S]*numeric_precision=18 AND numeric_scale=6/) assert.match(fee, /UNIQUE KEY/)
assert.match(finalAssertion, /column_name='tolerance_percent'[\s\S]*numeric_precision=5 AND numeric_scale=2/) assert.match(fee, /baseline_chargeable_quantity decimal\(18,6\) NOT NULL/i)
assert.match(finalAssertion, /统一迁移失败:入库费用基准计费量或容差字段不完整/) assert.match(fee, /tolerance_percent decimal\(5,2\) NOT NULL/i)
}) })
@@ -1,33 +1,34 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { readInitialization, table, seedRows, roleAuthorities } from './sql-baseline-contract-helpers.mjs'
const unified = readFileSync(new URL('../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') const unified = readInitialization()
const firstleg = readFileSync(new URL('../firstleg/src/main/resources/db/phase2-inbound-schema.sql', import.meta.url), 'utf8') const firstleg = readFileSync(new URL('../firstleg/src/main/resources/db/phase2-inbound-schema.sql', import.meta.url), 'utf8')
const iam = readFileSync(new URL('../iam/src/main/resources/db/inbound-hold-permission.sql', import.meta.url), 'utf8') const iam = readFileSync(new URL('../iam/src/main/resources/db/inbound-hold-permission.sql', import.meta.url), 'utf8')
test('统一增量与 Firstleg 扣货快照和不可变历史结构一致', () => { test('统一初始化与模块扣货快照和不可变历史结构一致', () => {
for (const source of [unified, firstleg]) { const order = table(unified, 'baiyue_firstleg', 'fl_inbound_order')
assert.match(source, /current_hold_reason VARCHAR\(1000\) NULL COMMENT '当前扣货理由快照'/) const history = table(unified, 'baiyue_firstleg', 'fl_inbound_hold_record')
assert.match(source, /CREATE TABLE IF NOT EXISTS fl_inbound_hold_record/) for (const source of [order + history, firstleg]) {
assert.match(source, /hold_action VARCHAR\(16\) NOT NULL COMMENT '动作:HOLD或RELEASE'/) assert.match(source, /current_hold_reason VARCHAR\(1000\)[^\n]*COMMENT '当前扣货理由快照'/i)
assert.match(source, /reason VARCHAR\(1000\) NOT NULL/) assert.match(source, /hold_action VARCHAR\(16\)[^\n]*NOT NULL/i)
assert.match(source, /evidence_file_id BIGINT UNSIGNED NULL/) assert.match(source, /reason VARCHAR\(1000\)[^\n]*NOT NULL/i)
assert.match(source, /KEY idx_hold_inbound_time \(tenant_id, inbound_id, operated_at\)/) assert.match(source, /evidence_file_id BIGINT UNSIGNED (?:DEFAULT )?NULL/i)
assert.match(source, /KEY idx_hold_inbound_time \(tenant_id,\s*inbound_id,\s*operated_at\)/i)
} }
assert.match(unified, /MODIFY COLUMN current_hold_reason VARCHAR\(1000\) NULL/) assert.doesNotMatch(unified, /MODIFY COLUMN current_hold_reason/)
}) })
test('统一增量与 IAM 模块冻结同一独立扣货权限且只默认授予平台管理员', () => { test('扣货权限保持独立且仅默认授予平台管理员', () => {
for (const source of [unified, iam]) { assert.match(iam, /\(5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold'/)
assert.match(source, /\(5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold'/) const permission = seedRows(unified, 'iam_permission').find(r => r.permission_code === 'platform:firstleg:inbound:hold')
assert.match(source, /role\.role_code='PLATFORM_ADMIN'/) assert.equal(permission.id, '5102032')
assert.match(source, /permission\.permission_code='platform:firstleg:inbound:hold'/) assert.equal(permission.parent_id, '5102030')
assert.match(source, /入库单扣货权限初始化失败:权限ID、编码或类型冲突/) assert.equal(permission.permission_type, 'BUTTON')
assert.match(source, /入库单扣货权限初始化失败:权限资源不完整/) assert.deepEqual(seedRows(unified, 'iam_role_permission').filter(r => r.permission_id === permission.id).map(r => r.role_id), ['4000001'])
assert.match(source, /入库单扣货权限初始化失败:平台超级管理员授权不完整/) assert.ok(roleAuthorities(unified, 'PLATFORM_ADMIN').has(permission.permission_code))
for (const role of ['ORDER_OPERATOR', 'WAREHOUSE_OPERATOR', 'WAREHOUSE_TALLY']) {
assert.ok(!roleAuthorities(unified, role).has(permission.permission_code), role)
} }
const migration = unified.slice(unified.indexOf('CREATE PROCEDURE migrate_inbound_hold_permission()'),
unified.indexOf('DROP PROCEDURE IF EXISTS assert_wallet_event_schema'))
assert.doesNotMatch(migration, /role_code='(?:ORDER_OPERATOR|WAREHOUSE_OPERATOR|WAREHOUSE_TALLY|PLATFORM_CUSTOM_ADMIN)'/)
}) })
+66
View File
@@ -0,0 +1,66 @@
// Offline SQL-test helpers. No database access or command-line entry point.
import { readFileSync } from 'node:fs'
export function readInitialization(region = 'CN') {
const name = region === 'CN' ? '头程数据库统一增量-MySQL8.sql' : '海外区域统一增量-MySQL8.sql'
return readFileSync(new URL(`../deploy/sql/${name}`, import.meta.url), 'utf8')
}
export function tableDefinitions(sql) {
const result = new Map()
let schema
for (const match of sql.matchAll(/^USE (\w+);|^CREATE TABLE (\w+) \([\s\S]*?^\) ENGINE=[^\n]+;/gm)) {
if (match[1]) schema = match[1]
else {
const key = `${schema}.${match[2]}`
if (result.has(key)) throw new Error(`Duplicate table definition: ${key}`)
result.set(key, match[0])
}
}
return result
}
export function table(sql, schema, name) {
const value = tableDefinitions(sql).get(`${schema}.${name}`)
if (!value) throw new Error(`Missing table: ${schema}.${name}`)
return value
}
function splitValues(value) {
const items = []
let start = 0, depth = 0, quoted = false
for (let i = 0; i < value.length; i++) {
const c = value[i]
if (quoted && c === '\\') { i++; continue }
if (c === "'") {
if (quoted && value[i + 1] === "'") { i++; continue }
quoted = !quoted
}
if (quoted) continue
if (c === '(') depth++
if (c === ')') depth--
if (c === ',' && depth === 0) { items.push(value.slice(start, i).trim()); start = i + 1 }
}
if (quoted || depth) throw new Error('Unbalanced SQL seed value')
items.push(value.slice(start).trim())
return items.map(item => item === 'NULL' ? null : item.startsWith("'")
? item.slice(1, -1).replace(/''/g, "'").replace(/\\([\\'"nrt0])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t', 0: '\0' }[c] ?? c))
: item)
}
export function seedRows(sql, name) {
const pattern = new RegExp(`^INSERT INTO ${name} \\(([^\\n]+?)\\) VALUES \\(([^\\n]+)\\);$`, 'gm')
return [...sql.matchAll(pattern)].map(match => {
const keys = match[1].split(',').map(key => key.trim())
const values = splitValues(match[2])
if (keys.length !== values.length) throw new Error(`Invalid seed columns: ${name}`)
return Object.fromEntries(keys.map((key, i) => [key, values[i]]))
})
}
export function roleAuthorities(sql, roleCode) {
const role = seedRows(sql, 'iam_role').find(row => row.role_code === roleCode)
if (!role) throw new Error(`Missing role: ${roleCode}`)
const granted = new Set(seedRows(sql, 'iam_role_permission').filter(row => row.role_id === role.id).map(row => row.permission_id))
return new Set(seedRows(sql, 'iam_permission').filter(row => granted.has(row.id)).map(row => row.permission_code))
}