This commit is contained in:
2026-09-20 09:17:47 +08:00
parent aa2953172b
commit d5c4059942
17 changed files with 682 additions and 21 deletions
+4 -4
View File
@@ -2,7 +2,7 @@
## 唯一入口与区域边界
当前开发阶段只维护纯初始化,不提供存量升级、数据回填、清理或兼容入口。两个 SQL 文件沿用原文件名,名称中的“增量”不表示可向已有业务库重放。
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
@@ -54,7 +54,7 @@
## 维护规则
- 最终字段、类型、默认值、索引、外键、CHECK、注释直接维护在 CREATE TABLE;64 个国内业务触发器保留最终定义。不追加 ALTER、迁移过程、动态 DDL、UPDATE 回填或 DELETE 清理段。
- 空库基线的最终字段、类型、默认值、索引、外键、CHECK、注释直接维护在 CREATE TABLE;64 个国内业务触发器保留最终定义。统一初始化文件不追加 ALTER、迁移过程、动态 DDL、UPDATE 回填或 DELETE 清理段;已有库的受控增量只能放入对应 schema 的 `migrations/` 文件,并保持不删数据。
- 系统种子直接维护最终 INSERT,不保留退役 ID 清理或 ON DUPLICATE KEY UPDATE 升级。权限变更联查 IAM 来源、Controller、OpenAPI 与调用端。
- 原 `权限点初始化-MySQL8.sql` 独立补丁已撤销,订单申报权限和默认授权已纳入国内 IAM 基线,不另设存量权限发布入口。
- 模块 `src/main/resources/db/` 用作结构来源和开发追溯,不是区域部署前置;不再向历史迁移文件追加发布步骤。修改当前字段时同步模块有效建表定义及其合同。
@@ -62,7 +62,7 @@
## 后续迁移工具(已开始实施)
机制启用前,以上两份统一 SQL 仍是空库初始化的唯一入口;本节工具不会修改已有数据库,也不会把当前可丢弃的开发/测试存量转换成迁移链。
机制启用前,以上两份统一 SQL 仍是空库初始化入口;本节工具不会把当前可丢弃的开发/测试存量转换成迁移链。当前已登记一份生产尾程结构补齐迁移 `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`,由 `scripts/prod-database-schema-acceptance.mjs` 默认只读核对,只有显式确认参数才执行;它只新增字段、放宽生成后才确定字段的可空性并创建缺失表,不执行 DML。
迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}/<schema>/`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。
@@ -82,7 +82,7 @@ node deploy/sql/scripts/db-migrations.mjs plan --services auth,iam --regions CN
node deploy/sql/scripts/db-migrations.mjs run --manifest release-manifest.json --dry-run
```
`new` 使用独占创建避免并发覆盖;成功历史或已冻结制品不能由普通命令改写。`plan` 只打包选中服务及评审确认的 schema,不会因为同一提交存在其他区域或服务 SQL 就自动扩大范围。`run` 的真实执行还未接入测试数据库和 Jenkins,当前未授权任何部署或 SQL 操作。
`new` 使用独占创建避免并发覆盖;成功历史或已冻结制品不能由普通命令改写。`plan` 只打包选中服务及评审确认的 schema,不会因为同一提交存在其他区域或服务 SQL 就自动扩大范围。`run` 的真实 Flyway 执行仍未接入测试数据库和 Jenkins;生产增量由专用验收脚本在当前轮明确授权后执行,不把统一初始化文件用于生产。
## 本地验证
@@ -0,0 +1,147 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 对齐生产尾程库中已存在表的增量字段,并补齐仓储转运测量照片表。
-- Safety: 仅新增可空/带默认值字段、放宽既有字段的 NOT NULL 约束、创建缺失表;不删除字段、索引、数据或表。
-- Preconditions: 已核对生产库缺失字段清单;执行连接必须指向 baiyue_lastleg。
-- Postconditions: 生产库与 deploy/sql/海外区域统一增量-MySQL8.sql 的尾程字段集合一致。
SET NAMES utf8mb4;
-- ll_delivery_order: 私人仓目的地快照字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'destination_type'), 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type varchar(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_address'), 'ALTER TABLE ll_delivery_order ADD COLUMN delivery_address varchar(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'postal_code'), 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code varchar(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'contact_name'), 'ALTER TABLE ll_delivery_order ADD COLUMN contact_name varchar(128) NULL COMMENT ''私人仓联系人'' AFTER postal_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'contact_phone'), 'ALTER TABLE ll_delivery_order ADD COLUMN contact_phone varchar(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF((SELECT is_nullable FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'dispatch_order_id') = 'NO', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id bigint NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF((SELECT is_nullable FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'dispatch_no') = 'NO', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_no varchar(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF((SELECT is_nullable FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'destination_warehouse_code') = 'NO', 'ALTER TABLE ll_delivery_order MODIFY COLUMN destination_warehouse_code varchar(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ll_dispatch_item: 私人仓目的地与计量快照字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_item' AND column_name = 'destination_type'), 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type varchar(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_item' AND column_name = 'delivery_address'), 'ALTER TABLE ll_dispatch_item ADD COLUMN delivery_address varchar(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_item' AND column_name = 'postal_code'), 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code varchar(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_item' AND column_name = 'contact_name'), 'ALTER TABLE ll_dispatch_item ADD COLUMN contact_name varchar(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_item' AND column_name = 'contact_phone'), 'ALTER TABLE ll_dispatch_item ADD COLUMN contact_phone varchar(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_item' AND column_name = 'gross_weight_kg'), 'ALTER TABLE ll_dispatch_item ADD COLUMN gross_weight_kg decimal(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_item' AND column_name = 'volume_weight_kg'), 'ALTER TABLE ll_dispatch_item ADD COLUMN volume_weight_kg decimal(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ll_inbound_plan_line / ll_inbound_plan_pallet: 私人仓目的地快照字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_line' AND column_name = 'delivery_address'), 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address varchar(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_warehouse_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_line' AND column_name = 'postal_code'), 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code varchar(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_line' AND column_name = 'contact_name'), 'ALTER TABLE ll_inbound_plan_line ADD COLUMN contact_name varchar(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_line' AND column_name = 'contact_phone'), 'ALTER TABLE ll_inbound_plan_line ADD COLUMN contact_phone varchar(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_pallet' AND column_name = 'destination_type'), 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type varchar(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER destination_warehouse_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_pallet' AND column_name = 'delivery_address'), 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN delivery_address varchar(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_pallet' AND column_name = 'postal_code'), 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code varchar(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_pallet' AND column_name = 'contact_name'), 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN contact_name varchar(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_inbound_plan_pallet' AND column_name = 'contact_phone'), 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN contact_phone varchar(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ll_order: PDA 实际入库箱数。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_order' AND column_name = 'received_package_count'), 'ALTER TABLE ll_order ADD COLUMN received_package_count int NULL COMMENT ''PDA实际入库箱数'' AFTER package_count', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ll_outbound_order / ll_outbound_pallet_item: 出库计量快照字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_order' AND column_name = 'gross_weight_kg'), 'ALTER TABLE ll_outbound_order ADD COLUMN gross_weight_kg decimal(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER pallet_count', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_order' AND column_name = 'volume_weight_kg'), 'ALTER TABLE ll_outbound_order ADD COLUMN volume_weight_kg decimal(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_order' AND column_name = 'volume_cbm'), 'ALTER TABLE ll_outbound_order ADD COLUMN volume_cbm decimal(18,4) NOT NULL DEFAULT 0 COMMENT ''体积(立方米)'' AFTER volume_weight_kg', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pallet_item' AND column_name = 'gross_weight_kg'), 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN gross_weight_kg decimal(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pallet_item' AND column_name = 'volume_weight_kg'), 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN volume_weight_kg decimal(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pallet_item' AND column_name = 'volume_cbm'), 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN volume_cbm decimal(18,4) NOT NULL DEFAULT 0 COMMENT ''体积(立方米)'' AFTER volume_weight_kg', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ll_warehouse_transfer_order: 目的地、审核及绑定幂等字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'destination_type'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type varchar(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'destination_warehouse_id'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_warehouse_id varchar(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'destination_warehouse_code'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_warehouse_code varchar(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'destination_warehouse_name'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_warehouse_name varchar(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'private_destination_address'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_address varchar(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'private_destination_postal_code'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code varchar(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'private_destination_contact_name'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_contact_name varchar(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'private_destination_contact_phone'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_contact_phone varchar(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'pickup_contact_name'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name varchar(128) NULL COMMENT ''提货联系人'' AFTER delivery_address', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'pickup_contact_phone'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone varchar(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'review_decision'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision varchar(32) NULL COMMENT ''审核决定'' AFTER status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'review_remark'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_remark varchar(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'reviewed_by'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN reviewed_by varchar(128) NULL COMMENT ''审核人ID'' AFTER review_remark', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'reviewed_at'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN reviewed_at datetime(3) NULL COMMENT ''审核时间'' AFTER reviewed_by', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'destination_binding_idempotency_key'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_binding_idempotency_key varchar(255) NULL COMMENT ''目的地绑定幂等键'' AFTER customer_confirmed_at', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_order' AND column_name = 'destination_binding_request_hash'), 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_binding_request_hash char(64) NULL COMMENT ''目的地绑定请求哈希'' AFTER destination_binding_idempotency_key', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ll_warehouse_transfer_pallet: PDA 草稿/生成/绑定生命周期字段,并放宽生成后才确定的资源字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_pallet' AND column_name = 'draft_idempotency_key'), 'ALTER TABLE ll_warehouse_transfer_pallet ADD COLUMN draft_idempotency_key varchar(255) NULL COMMENT ''草稿保存幂等键'' AFTER status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_pallet' AND column_name = 'draft_request_hash'), 'ALTER TABLE ll_warehouse_transfer_pallet ADD COLUMN draft_request_hash char(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_pallet' AND column_name = 'generate_idempotency_key'), 'ALTER TABLE ll_warehouse_transfer_pallet ADD COLUMN generate_idempotency_key varchar(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_pallet' AND column_name = 'bind_idempotency_key'), 'ALTER TABLE ll_warehouse_transfer_pallet ADD COLUMN bind_idempotency_key varchar(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_pallet' AND column_name = 'bound_at'), 'ALTER TABLE ll_warehouse_transfer_pallet ADD COLUMN bound_at datetime(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF((SELECT is_nullable FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_pallet' AND column_name = 'warehouse_pallet_id') = 'NO', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id bigint NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no varchar(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id bigint NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id bigint NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at datetime(3) NULL COMMENT ''整单打托完成时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新表不存在时直接创建完整定义;IF NOT EXISTS 使重复执行安全。
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
id bigint NOT NULL COMMENT '测量照片引用ID',
transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
file_id bigint NOT NULL COMMENT '文件服务文件ID',
file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照',
content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照',
size_bytes bigint NOT NULL DEFAULT 0 COMMENT '文件字节数快照',
uploaded_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人',
uploaded_at datetime(3) NOT NULL COMMENT '上传时间',
submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id),
KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用';
@@ -138,7 +138,10 @@ public class DeliveryDocumentService {
.eq(LastlegOrder::getCustomerId, delivery.getCustomerId())
.eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()));
for (LastlegOrder order : completedOrders) {
order.setSubOrderStatus("DELIVERY:DELIVERED");
// Preserve the outbound leg when receipt completion closes delivery.
// Sub-order status is a composite fact; replacing it with DELIVERY alone
// makes a successfully departed outbound appear unfinished or disappear.
order.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED");
order.setUpdatedAt(now);
lastlegOrders.updateById(order);
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.dispatch.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
@@ -37,6 +38,7 @@ import java.util.stream.Collectors;
public class DispatchPdaWorkflowService {
@Resource private DispatchOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPalletItems;
@Resource private DispatchItemMapper items;
@Resource private DispatchPdaTaskMapper tasks;
@Resource private DispatchPdaPickMapper picks;
@@ -227,6 +229,17 @@ public class DispatchPdaWorkflowService {
long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum();
LocalDateTime now = LocalDateTime.now();
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
if (outboundPalletItems != null) {
for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) {
boolean selected = dispatchItems.stream().anyMatch(item -> Objects.equals(item.getWarehousePalletId(), outboundItem.getWarehousePalletId())
&& Objects.equals(item.getInboundCode(), outboundItem.getInboundCode()));
if (selected) {
outboundItem.setPickedPackageCount(outboundItem.getPackageCount());
outboundItem.setStatus("PICKED");
outboundPalletItems.updateById(outboundItem);
}
}
}
for (DispatchItem item : dispatchItems) {
if (item.getOrderId() != null) warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now);
if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) {
@@ -94,6 +94,9 @@ public class DispatchPickOrderAttributionService {
if (before == null || orders.transferPickedToShipped(customerId, entry.getKey(), entry.getValue(), outboundNo) != 1) {
throw conflict("Tail-leg order picked packages are inconsistent");
}
if (orders.markDispatchOutbound(customerId, entry.getKey()) != 1) {
throw conflict("Tail-leg outbound sub-order status could not be synchronized");
}
long shipped = value(before.getShippedPackageCount()) + entry.getValue();
String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED";
LastlegOrderStatusHistory row = new LastlegOrderStatusHistory();
@@ -230,6 +230,10 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
@Update("UPDATE ll_order SET sub_order_status='OUTBOUND:OUTBOUNDED;DELIVERY:WAIT_DELIVERY',updated_at=NOW() " +
"WHERE customer_id=#{customerId} AND id=#{id}")
int markDispatchOutbound(@Param("customerId") String customerId, @Param("id") Long id);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag}," +
"pallet_merge_batch_id=CASE WHEN #{flag} IS NULL THEN NULL ELSE pallet_merge_batch_id END," +
"updated_at=#{updatedAt} " +
@@ -142,7 +142,7 @@ class DeliveryDocumentServiceTest {
assertThat(order.getStatus()).isEqualTo("DELIVERED");
assertThat(order.getSignedAt()).isNotNull();
assertThat(related.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
assertThat(related.getSubOrderStatus()).isEqualTo("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED");
verify(lastlegOrders).updateById(related);
verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), any());
verify(customerVisits).ensureForCompletedDelivery(eq(order), any());
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
@@ -46,6 +48,7 @@ import static org.mockito.Mockito.when;
class DispatchPdaWorkflowServiceTest {
@Mock DispatchOrderMapper orders;
@Mock OutboundOrderMapper outboundOrders;
@Mock OutboundPalletItemMapper outboundPalletItems;
@Mock DispatchItemMapper items;
@Mock DispatchPdaTaskMapper tasks;
@Mock DispatchPdaPickMapper picks;
@@ -63,6 +66,7 @@ class DispatchPdaWorkflowServiceTest {
objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "picks", picks);
@@ -114,6 +118,7 @@ class DispatchPdaWorkflowServiceTest {
order.setVehiclePlate("CA-100");
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
DispatchItem item = item(21L, "100", 6);
item.setInboundCode("IN-1");
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(tasks.findByDispatch("100", 1L)).thenReturn(task);
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
@@ -233,9 +238,14 @@ class DispatchPdaWorkflowServiceTest {
order.setVehiclePlate("CA-100");
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
DispatchItem item = item(21L, "100", 6);
item.setInboundCode("IN-1");
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
OutboundOrder outbound = new OutboundOrder();
outbound.setId(1L);
outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND");
OutboundPalletItem outboundItem = new OutboundPalletItem();
outboundItem.setWarehousePalletId(41L); outboundItem.setInboundCode("IN-1");
outboundItem.setPackageCount(6); outboundItem.setPickedPackageCount(0); outboundItem.setStatus("WAIT_PICK");
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
@@ -243,6 +253,7 @@ class DispatchPdaWorkflowServiceTest {
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
when(outboundPalletItems.findByOutbound("100", 1L)).thenReturn(List.of(outboundItem));
when(palletLocks.selectList(any())).thenReturn(List.of());
DispatchPdaResponses.Departed result = service.depart(
@@ -254,6 +265,9 @@ class DispatchPdaWorkflowServiceTest {
verify(orders).updateById(order);
assertEquals("DEPARTED", order.getStatus());
assertEquals("OUTBOUNDED", outbound.getStatus());
assertEquals(6, outboundItem.getPickedPackageCount());
assertEquals("PICKED", outboundItem.getStatus());
verify(outboundPalletItems).updateById(outboundItem);
}
@Test
@@ -70,6 +70,7 @@ class DispatchPickOrderAttributionServiceTest {
order.setShippedPackageCount(2); order.setCurrentStatus("INBOUND_COMPLETED");
when(orders.lockById("t1", 101L)).thenReturn(order);
when(orders.transferPickedToShipped("t1", 101L, 4L, "OUT1")).thenReturn(1);
when(orders.markDispatchOutbound("t1", 101L)).thenReturn(1);
List<DispatchPickOrderAttributionService.OrderPackages> result = service.depart(
"t1", "OUT1", "D1", List.of(pick), LocalDateTime.of(2026, 8, 17, 15, 0));
+1 -1
View File
@@ -10,7 +10,7 @@
| 类别 | 正式入口 | 管理边界 |
|---|---|---|
| 生产单票发布验收 | `prod-release-acceptance.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、客户由外部生产配置精确限定;离线计划、基础只读前置及显式确认的单票创建/提交/审核和本阶段续跑,全链执行未开放;不调用测试造数模块,不创建账号/主档或删除数据;配置和待实现范围见 README |
| 生产单票发布验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、前置同时核对东莞;订单创建/提交/审核与尾程签收/国内回传分阶段续跑,检查点独立;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
+11 -3
View File
@@ -4,6 +4,13 @@
`db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。
生产尾程数据库字段核对与增量补齐使用 `prod-database-schema-acceptance.mjs` 和 `deploy/sql/migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`。脚本读取进程环境中的 `CHONGCHONG_ENV`、`CHONGCHONG_DB_HOST`、`CHONGCHONG_DB_PORT`、`CHONGCHONG_DB_USERNAME`、`CHONGCHONG_DB_PASSWORD`,默认只读比较统一海外 SQL 的 `ll_*` 表与生产 `information_schema`;仅在显式同时传入 `--apply --confirm-production-ddl` 时执行增量迁移。迁移只新增缺失字段、放宽生成后才确定字段的 `NOT NULL`、创建缺失的测量照片表,不执行 DML、删除或重建。运行前须确认外部生产 env 文件和数据库目标;脚本不会把凭据写入报告或仓库。
```powershell
node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-acceptance.mjs
node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-acceptance.mjs --apply --confirm-production-ddl
```
`mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜出库续跑入口:参数为 `source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`,凭据由显式 `node --env-file` 注入。FBA 分支实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;一件代发分支只走专用候选、两托四箱出库、PDA 幂等拣货和完成回查,并明确记录派送未覆盖,不返回全流程成功退出码。finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它不代表普通海外仓目的地转换或所有分支签收验收通过。
全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。
@@ -12,7 +19,7 @@
## 验收环境变量文件
### 生产单票发布验收(独立入口,建设中)
### 生产单票发布验收(独立入口)
称重计算内部模块 `prod-release-measurement-plan.mjs` 统一采用每箱含纸箱、不含托盘 7.5 kg、40×30×30 cm(0.036 m³)。`order-stage` 在载荷绑定及报价前自动按箱数统一申报箱规、货重及总体积;不推算商品净重,已填商品净重若超过含箱货重则阻断。改口径后的已有检查点载荷不一致会停止,不能以换批次方式盲目重跑。
@@ -20,14 +27,15 @@
离线验证:`node --test scripts/prod-release-measurement-plan.test.mjs`,覆盖小数容量边界、皮重、箱数/重量守恒、错误仓库和托盘白名单;不证明真实称重写入已实现。
`prod-release-acceptance.mjs` 目标固定为同一票从义乌 `CN-YIWU-01` 到洛杉矶 `US-LAX-01`,客户身份由外部配置精确限定,不调用开发/测试全量造数模块。已实现离线计划、基础前置检查及单票下单提交审核阶段(支持本阶段检查点续跑);**测量至洛杉矶签收尚未实现,当前不能一键全链验收**。前置通过仅返回 `BASIC_PREFLIGHT_PASSED`,订单阶段通过返回 `ORDER_APPROVED`,始终保留 `fullFlowVerified=false`。实现仅经离线测试,不代表生产 HTTP 已验证。
`prod-release-acceptance.mjs` 目标固定为同一票从义乌 `CN-YIWU-01` 到洛杉矶 `US-LAX-01`,并在前置检查中同时核对东莞 `CN-DONGGUAN-01`;客户身份由外部配置精确限定,不调用开发/测试全量造数模块。订单阶段负责单票创建、提交和审核;`prod-release-delivery-stage.mjs` 负责既有本批次的派送中、签收文件、派送完成和国内签收事件回查,两个阶段分别使用独立检查点。签收脚本不会借用历史单据或删除数据。
- `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。
- `node --env-file="$env:USERPROFILE\.codex\env\chongchong-global\prod-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次生产 HTTP 授权后运行;登录既有四个账号并查询客户、仓库、PDA 绑定及国内资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。
- `node --env-file="$env:USERPROFILE\.codex\env\chongchong-global\prod-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次生产 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。
- 必填 `CHONGCHONG_ENV=prod`;`CHONGCHONG_ADMIN_API_BASE_URL`、`CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_API_BASE_URL` 分别为同一 HTTPS 前端代理的 `/api`、`/overseas-api`、`/api`。
- 客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。
- 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。
- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-production-order [--resume]`:须另获当次生产写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
- 尾程签收入口 `delivery-stage <尾程事实输入.json> <独立签收检查点.json> --confirm-production-delivery`:须另获当次生产写入授权。脚本只接受本批次已核对的排车、出库、派送和尾程订单身份;按状态版本推进 `DELIVERING`,上传验收用签收文件并保存资料,回查 `DELIVERED`、国内入仓 `DELIVERED` 和头程订单 `COMPLETED`。上传或状态写入结果不确定时停止;托盘明细或尾程子状态未同步会记录为阻断项。
- `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境、客户、操作者、批次和载荷摘要,不保存密码/Token/完整申报。创建与提交仅在显式续跑时复用原幂等键;审核无幂等键,响应丢失后只回查,若仍不能确认已审核则停止并要求人工核对,不自动再次审核。崩溃残留 `.lock` 时先确认无运行进程、检查业务状态,再人工解除锁;不得删除业务检查点。
- 订单输入字段:`batch`(8~48 位字母数字/连字符)、`origin: {id, code: "CN-YIWU-01"}`、`destination: {id, code, type: "FBA"}`、`payload`(`order` 服务 `OrderRequests.SaveRequest` 完整请求)。客户、环境和操作者由外部 env 强制绑定;`payload.customerName` 必须匹配配置,`customerRemark` 必须为 `RELEASE-ACCEPTANCE:<batch>`。显式填 `fulfillmentType=STANDARD`、`transportMode=SEA`、`pickupMethod=SELF_EXPRESS`、`containerMode=LCL`,`cargoes` 一行 1~2 箱;已上传的产品图片 ID 和商品申报字段由本次验收资料提供,箱规/含箱总重/总体积由上述固定口径统一计算,不生成资质或物流签收证明。箱数×每箱数量必须等于总件数,箱规×箱数必须等于总体积。
- 待实现:供应商及海外资源门禁、测量至签收写入、后续阶段归属核验、跨区等待和检查点、所有阶段真实 HTTP 验证。全链 `execute` / `resume` 命令仍拒绝;订单续跑仅使用上述 `order-stage ... --resume`。不得改用测试全量脚本来代替。
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..')
const EXPECTED_SQL = path.join(REPO_ROOT, 'deploy/sql/海外区域统一增量-MySQL8.sql')
const MIGRATION_SQL = path.join(REPO_ROOT, 'deploy/sql/migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql')
const MYSQL_CANDIDATES = [
process.env.MYSQL_BIN,
'C:\\Program Files\\MySQL\\MySQL Server 8.4\\bin\\mysql.exe',
'mysql',
].filter(Boolean)
function parseArgs(argv) {
const args = { apply: false, confirmProductionDdl: false }
for (let index = 0; index < argv.length; index += 1) {
const item = argv[index]
if (item === '--apply') args.apply = true
else if (item === '--confirm-production-ddl') args.confirmProductionDdl = true
else if (item === '--help' || item === '-h') args.help = true
else throw new Error(`未知参数:${item}`)
}
return args
}
function printUsage() {
console.log('用法:node prod-database-schema-acceptance.mjs [--apply --confirm-production-ddl]')
console.log('默认只读核对;--apply 仅执行仓储中的增量迁移,不执行 DML。')
}
function loadConfig() {
const required = ['CHONGCHONG_ENV', 'CHONGCHONG_DB_HOST', 'CHONGCHONG_DB_PORT', 'CHONGCHONG_DB_USERNAME', 'CHONGCHONG_DB_PASSWORD']
const missing = required.filter((key) => !process.env[key])
if (missing.length) throw new Error(`缺少数据库环境变量:${missing.join(', ')}`)
if (process.env.CHONGCHONG_ENV !== 'prod') throw new Error('生产数据库脚本要求 CHONGCHONG_ENV=prod')
return Object.fromEntries(required.map((key) => [key, process.env[key]]))
}
function expectedSchema() {
const sql = readFileSync(EXPECTED_SQL, 'utf8')
const tables = new Map()
const tablePattern = /CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(ll_[a-z0-9_]+)\s*\(([\s\S]*?)\n\) ENGINE=/gi
for (const match of sql.matchAll(tablePattern)) {
const columns = new Set()
for (const line of match[2].split(/\r?\n/)) {
const field = line.match(/^\s+`?([a-z][a-z0-9_]*)`?\s+/i)
if (field && !/^(primary|unique|key|constraint|check|foreign)$/i.test(field[1])) columns.add(field[1])
}
tables.set(match[1], columns)
}
return tables
}
function mysql(config, sql) {
let lastError = ''
for (const executable of MYSQL_CANDIDATES) {
const result = spawnSync(executable, [
'--protocol=TCP', '-h', config.CHONGCHONG_DB_HOST, '-P', config.CHONGCHONG_DB_PORT,
'-u', config.CHONGCHONG_DB_USERNAME, '--database=baiyue_lastleg',
'--batch', '--skip-column-names', '--default-character-set=utf8mb4',
], { input: sql, encoding: 'utf8', env: { ...process.env, MYSQL_PWD: config.CHONGCHONG_DB_PASSWORD } })
if (result.error?.code === 'ENOENT') {
lastError = result.error.message
continue
}
if (result.status !== 0) throw new Error((result.stderr || result.stdout || 'mysql 执行失败').trim())
return result.stdout
}
throw new Error(`找不到 mysql 客户端:${lastError}`)
}
function readActualSchema(config) {
const output = mysql(config, "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'baiyue_lastleg' ORDER BY table_name, ordinal_position;")
const tables = new Map()
for (const line of output.trim().split(/\r?\n/).filter(Boolean)) {
const [table, column] = line.split('\t')
if (!table?.startsWith('ll_') || !column) continue
if (!tables.has(table)) tables.set(table, new Set())
tables.get(table).add(column)
}
return tables
}
function diff(expected, actual) {
const missingTables = [...expected.keys()].filter((table) => !actual.has(table))
const extraTables = [...actual.keys()].filter((table) => !expected.has(table))
const missingColumns = []
const extraColumns = []
for (const [table, columns] of expected) {
const current = actual.get(table) ?? new Set()
for (const column of columns) if (!current.has(column)) missingColumns.push(`${table}.${column}`)
for (const column of current) if (!columns.has(column)) extraColumns.push(`${table}.${column}`)
}
return { missingTables, extraTables, missingColumns, extraColumns }
}
function printDiff(result) {
console.log(`期望表 ${result.expectedTables},实际表 ${result.actualTables}`)
console.log(`缺失表 ${result.missingTables.length},缺失字段 ${result.missingColumns.length},额外字段 ${result.extraColumns.length}`)
if (result.missingTables.length) console.log(`缺失表:${result.missingTables.join(', ')}`)
if (result.extraTables.length) console.log(`额外表:${result.extraTables.join(', ')}`)
if (result.missingColumns.length) console.log(`缺失字段:${result.missingColumns.join(', ')}`)
if (result.extraColumns.length) console.log(`额外字段:${result.extraColumns.join(', ')}`)
}
function audit(config) {
const expected = expectedSchema()
const actual = readActualSchema(config)
const result = { ...diff(expected, actual), expectedTables: expected.size, actualTables: actual.size }
printDiff(result)
return result
}
function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help) return printUsage()
const config = loadConfig()
let result = audit(config)
if (args.apply) {
if (!args.confirmProductionDdl) throw new Error('--apply 必须同时提供 --confirm-production-ddl')
console.log('执行生产增量迁移:仅 ADD COLUMN / MODIFY COLUMN 放宽可空性 / CREATE TABLE,不执行 DML。')
mysql(config, readFileSync(MIGRATION_SQL, 'utf8'))
result = audit(config)
}
const passed = result.missingTables.length === 0 && result.missingColumns.length === 0
if (!passed) {
console.error('PROD_SCHEMA_ACCEPTANCE_FAIL')
process.exitCode = 1
} else {
console.log('PROD_SCHEMA_ACCEPTANCE_PASS')
}
}
try {
main()
} catch (error) {
console.error(`PROD_SCHEMA_ACCEPTANCE_ERROR: ${error.message}`)
process.exitCode = 1
}
+32 -6
View File
@@ -11,9 +11,15 @@ export const STAGES = Object.freeze([
'LAX_DISPATCH', 'LAX_PDA_OUTBOUND', 'DELIVERY_RECEIPT', 'CN_STATUS_RECONCILIATION',
])
// These are the two domestic warehouses that are currently in the production
// master data. The release smoke route still uses Yiwu as its fixed origin,
// but the preflight must prove that both domestic operating sites are active
// and visible to the domestic PDA account.
export const DOMESTIC_WAREHOUSE_CODES = Object.freeze(['CN-YIWU-01', 'CN-DONGGUAN-01'])
export function plan() {
return {
scope: { orders: 1, origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true },
scope: { orders: 1, origins: [...DOMESTIC_WAREHOUSE_CODES], origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true },
status: 'FULL_FLOW_NOT_IMPLEMENTED', fullFlowVerified: false,
stages: STAGES.map(name => ({ name, status: ['CUSTOMER_ORDER', 'ORDER_REVIEW'].includes(name)
? 'IMPLEMENTED_NOT_HTTP_VERIFIED' : 'NOT_IMPLEMENTED' })),
@@ -129,15 +135,23 @@ export async function preflight(settings, http, receiveContext = () => {}) {
assertCustomer(await http.request('cn', 'GET', `/platform/v1/iam/tenants/${settings.customer.id}`, admin), settings.customer)
assertCustomer(await http.request('cn', 'GET', '/merchant/v1/iam/tenant/profile', logins.MERCHANT.accessToken), settings.customer)
const warehouses = {}
for (const [type, code] of [['DOMESTIC', 'CN-YIWU-01'], ['OVERSEAS', 'US-LAX-01']]) {
const all = await listAll(http, `/platform/v1/warehouse-admin?page={page}&size=100&warehouseType=${type}&status=ACTIVE`, admin)
for (const code of DOMESTIC_WAREHOUSE_CODES) {
const all = await listAll(http, `/platform/v1/warehouse-admin?page={page}&size=100&warehouseType=DOMESTIC&status=ACTIVE`, admin)
const matches = all.filter(w => w.warehouseCode === code && w.status === 'ACTIVE')
if (matches.length !== 1) throw new Error(`${code} 必须唯一且启用`)
warehouses[code] = id(matches[0].id)
}
for (const code of ['US-LAX-01']) {
const all = await listAll(http, `/platform/v1/warehouse-admin?page={page}&size=100&warehouseType=OVERSEAS&status=ACTIVE`, admin)
const matches = all.filter(w => w.warehouseCode === code && w.status === 'ACTIVE')
if (matches.length !== 1) throw new Error(`${code} 必须唯一且启用`)
warehouses[code] = id(matches[0].id)
}
const cnWarehouses = await http.request('cn', 'GET', '/platform/v1/first-leg/pda-loading/warehouses', logins.CN_PDA.accessToken)
if (!Array.isArray(cnWarehouses) || !cnWarehouses.some(w => w.warehouseCode === 'CN-YIWU-01'
&& id(w.id) === warehouses['CN-YIWU-01'])) throw new Error('国内 PDA 不可访问义乌仓')
if (!Array.isArray(cnWarehouses) || DOMESTIC_WAREHOUSE_CODES.some(code =>
!cnWarehouses.some(w => w.warehouseCode === code && id(w.id) === warehouses[code]))) {
throw new Error('国内 PDA 不可访问义乌或东莞仓')
}
const overseasToken = logins.OVERSEAS_LAX_PDA.overseasAccessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少海外 Token')
const binding = await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', overseasToken)
@@ -153,7 +167,7 @@ export async function preflight(settings, http, receiveContext = () => {}) {
}
receiveContext({ logins, warehouses, reusablePallets })
return { ...plan(), status: 'BASIC_PREFLIGHT_PASSED', requests: http.requests,
checks: ['客户身份及箱规状态', '四个账号登录', '义乌及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'],
checks: ['客户身份及箱规状态', '四个账号登录', '义乌、东莞及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'],
resourceCounts: counts,
pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '测量至签收的写入执行器和检查点', '所有业务阶段真实 HTTP 验证'],
}
@@ -213,6 +227,18 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher
export async function main(argv = process.argv.slice(2), env = process.env) {
const [mode = 'plan', ...rest] = argv
if (mode === 'delivery-stage') {
if (rest.length !== 3 || rest[2] !== '--confirm-production-delivery') {
throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-production-delivery')
}
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件')
const { executeDelivery } = await import('./prod-release-delivery-stage.mjs')
const result = await executeDelivery(config(env), JSON.parse(await readFile(inputPath, 'utf8')), journalPath)
console.log(JSON.stringify(result, null, 2))
if (result.blockers.length) process.exitCode = 1
return
}
if (mode === 'measurement-plan') {
if (rest.length !== 1 || !/^[12]$/.test(rest[0])) throw new Error('measurement-plan 需要箱数 1 或 2')
const allowedCodes = requireValue(env, 'CHONGCHONG_ACCEPTANCE_PALLET_CODES').split(',').map(v => v.trim())
+6 -3
View File
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main } from './prod-release-acceptance.mjs'
import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main, DOMESTIC_WAREHOUSE_CODES } from './prod-release-acceptance.mjs'
function environment() {
const env = { CHONGCHONG_ENV: 'prod',
@@ -22,6 +22,7 @@ test('single order scope does not claim implemented or full coverage', () => {
const result = plan()
assert.equal(result.scope.orders, 1)
assert.equal(result.scope.sameOrder, true)
assert.deepEqual(result.scope.origins, [...DOMESTIC_WAREHOUSE_CODES])
assert.equal(result.scope.receiving, 'US-LAX-01')
assert.equal(result.fullFlowVerified, false)
assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 2)
@@ -87,10 +88,12 @@ function fakeHttp(bindingId = '3') {
if (method === 'POST') return { accessToken: 'fixture-token', overseasAccessToken: 'fixture-overseas-token' }
if (path.includes('/iam/')) return profile
if (path.includes('/warehouse-profile')) return { warehouseId: bindingId, warehouseCode: 'US-LAX-01' }
if (path.endsWith('/pda-loading/warehouses')) return [{ id: '2', warehouseCode: 'CN-YIWU-01' }]
if (path.endsWith('/pda-loading/warehouses')) return [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }]
if (path.includes('/warehouse-admin?')) {
const domestic = path.includes('warehouseType=DOMESTIC')
return { total: '1', records: [{ id: domestic ? '2' : '3', warehouseCode: domestic ? 'CN-YIWU-01' : 'US-LAX-01', status: 'ACTIVE' }] }
return domestic
? { total: '2', records: [{ id: '2', warehouseCode: 'CN-YIWU-01', status: 'ACTIVE' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01', status: 'ACTIVE' }] }
: { total: '1', records: [{ id: '3', warehouseCode: 'US-LAX-01', status: 'ACTIVE' }] }
}
return { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] }
} }
+179
View File
@@ -0,0 +1,179 @@
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
import { dirname } from 'node:path'
import { fingerprint } from './prod-release-order-stage.mjs'
const check = (ok, message) => { if (!ok) throw new Error(message) }
const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value)
const segment = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value)
const number = value => Number.isSafeInteger(Number(value)) ? Number(value) : NaN
export function validateDeliveryInput(input) {
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
for (const key of ['orderId', 'tailOrderId', 'warehouseId']) check(longId(input[key]), `无效 ${key}`)
for (const key of ['orderNo', 'inboundCode', 'dispatchNo', 'outboundNo', 'deliveryNo']) check(segment(input[key]), `无效 ${key}`)
check([1, 2].includes(input.packageCount), '只允许 1~2 箱单票验收')
}
// Separate journal: never overwrite the order-stage checkpoint or imply earlier stages were automated.
export async function withDeliveryJournal(path, binding, action) {
await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600)
try {
let state
try { state = JSON.parse(await readFile(path, 'utf8')) }
catch (error) {
if (error.code !== 'ENOENT') throw new Error('签收检查点损坏,禁止重新上传')
state = { version: 1, binding, steps: {} }
}
check(state.version === 1 && state.binding === binding && state.steps, '签收检查点与环境、账号或输入不符')
const save = async patch => {
const next = { ...state, ...patch }
const file = await open(`${path}.next`, 'wx', 0o600)
try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() }
await rename(`${path}.next`, path)
Object.assign(state, next)
}
return await action(state, save)
} finally { await lock.close(); await unlink(`${path}.lock`) }
}
export function verifyOutbound(input, dispatch, detail, tail) {
check(dispatch?.dispatchNo === input.dispatchNo && dispatch.overseasWarehouseId === input.warehouseId
&& dispatch.status === 'DEPARTED' && number(dispatch.packageCount) === input.packageCount
&& number(dispatch.shippedPackageCount) === input.packageCount
&& number(dispatch.pickedPackageCount) === input.packageCount
&& number(dispatch.remainingPackageCount) === 0, '排车身份、发车状态或数量不符')
check(dispatch.items?.length > 0 && dispatch.items.every(item => item.inboundCode === input.inboundCode), '排车包含其他票')
check(detail?.outbound?.outboundNo === input.outboundNo && detail.outbound.dispatchNo === input.dispatchNo
&& detail.outbound.overseasWarehouseId === input.warehouseId && detail.outbound.status === 'OUTBOUNDED'
&& number(detail.outbound.packageCount) === input.packageCount, '出库单身份或状态不符')
check(detail.deliveries?.length === 1 && detail.deliveries[0].deliveryNo === input.deliveryNo, '不是本票唯一派送单')
check(tail?.order?.id === input.tailOrderId && tail.order.sourceOrderNo === input.orderNo
&& tail.order.inboundCode === input.inboundCode && tail.order.overseasWarehouseId === input.warehouseId, '尾程与国内订单来源不符')
const blockers = []
if (!detail.pallets?.length || detail.pallets.some(item => item.inboundCode !== input.inboundCode
|| number(item.pickedPackageCount) !== number(item.packageCount) || item.status === 'WAIT_PICK')) {
blockers.push('OUTBOUND_PALLET_PICK_STATE_STALE')
}
if (String(tail.order.subOrderStatus).includes('OUTBOUND:WAIT_OUTBOUND')) blockers.push('TAIL_OUTBOUND_SUB_STATUS_STALE')
return blockers
}
export async function runDeliveryStage({ input, customerId, state, save, request, upload, wait = ms => new Promise(r => setTimeout(r, ms)) }) {
validateDeliveryInput(input)
const root = '/platform/v1/last-leg'
const deliveryPath = `${root}/delivery-orders/${input.deliveryNo}`
const scoped = path => `${path}?customerId=${customerId}`
const readDelivery = async () => {
const detail = await request('overseas', 'GET', scoped(deliveryPath))
check(detail?.order?.customerId === customerId && detail.order.deliveryNo === input.deliveryNo
&& detail.order.dispatchNo === input.dispatchNo && detail.order.outboundNo === input.outboundNo
&& detail.order.remark === `RELEASE-ACCEPTANCE:${input.batch}`, '派送单身份或验收标记不符')
return detail
}
const dispatch = await request('overseas', 'GET', `${root}/dispatches/${input.dispatchNo}`)
const outbound = await request('overseas', 'GET', `${root}/outbound-orders/${input.outboundNo}`)
const tail = await request('overseas', 'GET', scoped(`${root}/orders/${input.tailOrderId}`))
check(tail?.order?.customerId === customerId, '尾程客户不符')
const blockers = verifyOutbound(input, dispatch, outbound, tail)
let delivery = await readDelivery()
if (delivery.order.status === 'WAIT_DELIVERY') {
check(!state.startIntent, '配送中操作结果不确定,禁止重发;请核对服务端状态')
await save({ startIntent: true })
await request('overseas', 'PUT', scoped(`${deliveryPath}/status`), {
status: 'DELIVERING', expectedVersion: number(delivery.order.version), remark: `RELEASE-ACCEPTANCE:${input.batch}`,
})
delivery = await readDelivery()
}
check(['DELIVERING', 'DELIVERED'].includes(delivery.order.status), '派送状态不能继续签收')
const documentsPath = scoped(`${deliveryPath}/documents`)
let documents = await request('overseas', 'GET', documentsPath)
if (delivery.order.status !== 'DELIVERED') {
if (!state.fileId) {
check(!state.uploadIntent, '签收上传结果不确定;保留检查点,禁止重复上传')
check(!documents.categories?.some(c => c.category === 'RECEIPT' && c.files?.length), '存在未登记的签收文件,需核对')
await save({ uploadIntent: true })
const file = await upload(scoped(`${deliveryPath}/documents/RECEIPT/files`),
`${input.batch}-receipt.txt`, `RELEASE ACCEPTANCE ONLY - NOT A REAL PROOF OF DELIVERY\n${input.batch}\n${input.deliveryNo}\n`)
check(longId(file?.fileId), '上传未返回精确文件 ID')
await save({ fileId: file.fileId })
}
if (!state.saveBody) {
check(Number.isSafeInteger(number(documents.version)) && number(documents.version) >= 0, '签收资料版本缺失')
const existing = (documents.categories || []).filter(c => c.category !== 'RECEIPT' && c.files?.length)
.map(c => ({ category: c.category, fileIds: c.files.map(f => f.fileId) }))
await save({ saveBody: { expectedVersion: number(documents.version),
idempotencyKey: `prod-receipt-${fingerprint({ batch: input.batch, delivery: input.deliveryNo }).slice(0, 40)}`,
bolNo: documents.bolNo || null, documents: [...existing, { category: 'RECEIPT', fileIds: [state.fileId] }] } })
}
await request('overseas', 'PUT', documentsPath, state.saveBody)
delivery = await readDelivery()
documents = await request('overseas', 'GET', documentsPath)
}
check(delivery.order.status === 'DELIVERED' && delivery.operations?.some(o => o.operationNode === 'RECEIPT_AUTO_COMPLETE'), '签收未自动完成派送')
check(documents.categories?.some(c => c.category === 'RECEIPT' && c.status === 'UPLOADED'
&& c.files?.some(f => f.fileId === state.fileId)), '签收文件没有绑定本次上传结果')
let inbound, order
for (let attempt = 0; attempt < 30; attempt++) {
const page = await request('cn', 'GET', `/platform/inbounds?tenantId=${customerId}&inboundCode=${input.inboundCode}&page=1&size=20`)
inbound = page?.records?.find(row => row.inboundCode === input.inboundCode && row.orderId === input.orderId)
order = await request('cn', 'GET', `/platform/v1/orders/${input.orderId}?tenantId=${customerId}`)
if (inbound?.processStatus === 'DELIVERED' && order?.summary?.mainStatus === 'COMPLETED') break
if (attempt < 29) await wait(1000)
}
const closure = inbound?.processStatus === 'DELIVERED' && order?.summary?.mainStatus === 'COMPLETED'
if (!closure) blockers.push('CN_DELIVERY_EVENT_NOT_RECONCILED')
const finalOutbound = await request('overseas', 'GET', `${root}/outbound-orders/${input.outboundNo}`)
const finalTail = await request('overseas', 'GET', scoped(`${root}/orders/${input.tailOrderId}`))
const finalBlockers = verifyOutbound(input, dispatch, finalOutbound, finalTail)
const result = {
status: closure && !finalBlockers.length ? 'DELIVERY_STAGE_PASSED' : 'DELIVERY_STAGE_WITH_BLOCKERS',
batch: input.batch, orderNo: input.orderNo, inboundCode: input.inboundCode,
dispatchNo: input.dispatchNo, outboundNo: input.outboundNo, deliveryNo: input.deliveryNo,
deliveryStatus: delivery.order.status, domesticInboundStatus: inbound?.processStatus || null,
domesticOrderStatus: order?.summary?.mainStatus || null, deliveryClosureVerified: closure,
fullFlowVerified: false, blockers: [...new Set([...finalBlockers, ...blockers.filter(b => b === 'CN_DELIVERY_EVENT_NOT_RECONCILED')])],
packageCount: input.packageCount, receipt: 'SYNTHETIC_ACCEPTANCE_FILE',
checkedAt: new Date().toISOString(),
}
await save({ result })
return result
}
export async function executeDelivery(settings, input, journalPath, fetcher = fetch) {
validateDeliveryInput(input)
const binding = fingerprint({ input, cn: settings.cn, overseas: settings.overseas,
customer: settings.customer, admin: settings.credentials.ADMIN.loginId })
return withDeliveryJournal(journalPath, binding, async (state, save) => {
const { client } = await import('./prod-release-acceptance.mjs')
const login = await client(settings, fetcher).request('cn', 'POST', '/auth/login', null, settings.credentials.ADMIN)
check(login?.accessToken && login?.overseasAccessToken, '缺少国内或海外令牌')
const root = '/platform/v1/last-leg'
const delivery = `${root}/delivery-orders/${input.deliveryNo}`
const scoped = path => `${path}?customerId=${settings.customer.id}`
const reads = new Set([`${root}/dispatches/${input.dispatchNo}`, `${root}/outbound-orders/${input.outboundNo}`,
scoped(`${root}/orders/${input.tailOrderId}`), scoped(delivery), scoped(`${delivery}/documents`)])
const writes = new Set([scoped(`${delivery}/status`), scoped(`${delivery}/documents`)])
const cnReads = new Set([`/platform/inbounds?tenantId=${settings.customer.id}&inboundCode=${input.inboundCode}&page=1&size=20`,
`/platform/v1/orders/${input.orderId}?tenantId=${settings.customer.id}`])
const http = client(settings, fetcher, (method, path, region) => region === 'cn'
? method === 'GET' && cnReads.has(path)
: region === 'overseas' && (method === 'GET' && reads.has(path) || method === 'PUT' && writes.has(path)))
return runDeliveryStage({ input, customerId: settings.customer.id, state, save,
request: (region, method, path, body) => http.request(region, method, path,
region === 'cn' ? login.accessToken : login.overseasAccessToken, body),
upload: async (path, name, text) => {
check(path === scoped(`${delivery}/documents/RECEIPT/files`), '上传路径不符')
const form = new FormData(); form.append('file', new Blob([text], { type: 'text/plain' }), name)
let response
try { response = await fetcher(settings.overseas + path, { method: 'POST', redirect: 'error',
signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.overseasAccessToken}` }, body: form }) }
catch { throw new Error('签收上传连接失败或超时;未自动重试') }
const data = await response.json()
const safe = value => /^[\w-]{1,100}$/.test(String(value)) ? value : 'unavailable'
check(response.ok && data?.code === '200', `签收上传 HTTP=${response.status}; code=${safe(data?.code)}; requestId=${safe(data?.requestId)}; traceId=${safe(data?.traceId)}`)
return data.data
},
})
})
}
@@ -0,0 +1,114 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs'
const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201',
warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 }
function fixture() {
const dispatch = { dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'DEPARTED', packageCount: 1,
shippedPackageCount: 1, pickedPackageCount: 1, remainingPackageCount: 0, items: [{ inboundCode: 'IN-101' }] }
const outbound = { outbound: { outboundNo: 'OB-101', dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'OUTBOUNDED', packageCount: 1 },
deliveries: [{ deliveryNo: 'DL-101' }], pallets: [{ inboundCode: 'IN-101', packageCount: 1, pickedPackageCount: 1, status: 'PICKED' }] }
const tail = { order: { id: '201', customerId: '501', sourceOrderNo: 'FL-101', inboundCode: 'IN-101', overseasWarehouseId: '301', subOrderStatus: 'OUTBOUND:OUTBOUNDED' } }
return { dispatch, outbound, tail }
}
function harness(state = { steps: {} }) {
const facts = fixture(), writes = []
let status = 'WAIT_DELIVERY', files = [], uploads = 0
const request = async (region, method, path, body) => {
if (method !== 'GET') writes.push({ path, body })
if (path.endsWith('/status?customerId=501')) { status = 'DELIVERING'; return {} }
if (path.includes('/documents?')) {
if (method === 'PUT') { files = [{ fileId: '701' }]; status = 'DELIVERED' }
return { version: 0, categories: [{ category: 'RECEIPT', status: files.length ? 'UPLOADED' : 'MISSING', files }] }
}
if (path.includes('/dispatches/')) return facts.dispatch
if (path.includes('/outbound-orders/')) return facts.outbound
if (path.includes('/last-leg/orders/')) return facts.tail
if (path.includes('/delivery-orders/')) return { order: { customerId: '501', deliveryNo: 'DL-101', dispatchNo: 'DS-101', outboundNo: 'OB-101',
remark: 'RELEASE-ACCEPTANCE:acceptance-1234', status, version: 0 }, operations: status === 'DELIVERED' ? [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] : [] }
if (region === 'cn' && path.includes('/platform/inbounds?')) return { records: [{ inboundCode: 'IN-101', orderId: '101', processStatus: 'DELIVERED' }] }
if (region === 'cn') return { summary: { mainStatus: 'COMPLETED' } }
throw new Error('unexpected request')
}
const options = { input, customerId: '501', state, save: async patch => Object.assign(state, patch), request,
upload: async (_path, _name, content) => { assert.match(content, /NOT A REAL PROOF/); uploads++; return { fileId: '701' } }, wait: async () => {} }
return { options, facts, writes, uploads: () => uploads }
}
test('reject path injection, imprecise ids and excessive scope', () => {
validateDeliveryInput(input)
for (const patch of [{ dispatchNo: '../wrong' }, { orderId: 101 }, { packageCount: 100 }]) assert.throws(() => validateDeliveryInput({ ...input, ...patch }))
})
test('receipt closes delivery and both domestic states; completed rerun has no writes', async () => {
const h = harness()
const first = await runDeliveryStage(h.options)
assert.equal(first.deliveryClosureVerified, true)
assert.equal(first.fullFlowVerified, false)
assert.equal(first.status, 'DELIVERY_STAGE_PASSED')
const count = h.writes.length
await runDeliveryStage(h.options)
assert.equal(h.writes.length, count)
assert.equal(h.uploads(), 1)
})
test('stale pallet detail blocks release despite successful dispatch and receipt', async () => {
const h = harness()
h.facts.outbound.pallets[0].pickedPackageCount = 0
h.facts.tail.order.subOrderStatus = 'OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY'
const result = await runDeliveryStage(h.options)
assert.equal(result.status, 'DELIVERY_STAGE_WITH_BLOCKERS')
assert.equal(result.deliveryClosureVerified, true)
assert.deepEqual(result.blockers, ['OUTBOUND_PALLET_PICK_STATE_STALE', 'TAIL_OUTBOUND_SUB_STATUS_STALE'])
})
test('wrong source order or additional inbound blocks before mutations', async () => {
for (const mutate of [h => { h.facts.tail.order.sourceOrderNo = 'OTHER' }, h => { h.facts.dispatch.items.push({ inboundCode: 'OTHER' }) }]) {
const h = harness(); mutate(h)
await assert.rejects(runDeliveryStage(h.options))
assert.equal(h.writes.length, 0)
assert.equal(h.uploads(), 0)
}
})
test('uncertain upload and uncertain status mutation are never resent', async () => {
const h = harness({ uploadIntent: true })
await assert.rejects(runDeliveryStage(h.options), /禁止重复上传/)
assert.equal(h.uploads(), 0)
const h2 = harness({ startIntent: true })
await assert.rejects(runDeliveryStage(h2.options), /禁止重发/)
assert.equal(h2.writes.length, 0)
})
test('lost document save response reuses persisted payload and key', async () => {
const h = harness()
const request = h.options.request
let lost = false
h.options.request = async (...args) => {
const value = await request(...args)
if (!lost && args[1] === 'PUT' && args[2].includes('/documents?')) { lost = true; throw new Error('timeout') }
return value
}
await assert.rejects(runDeliveryStage(h.options), /timeout/)
const body = structuredClone(h.options.state.saveBody)
const result = await runDeliveryStage(h.options)
assert.equal(result.deliveryClosureVerified, true)
assert.deepEqual(h.options.state.saveBody, body)
assert.equal(h.uploads(), 1)
})
test('missing domestic event remains a blocker', async () => {
const h = harness(), original = h.options.request
h.options.request = (...args) => args[0] === 'cn' ? Promise.resolve({ records: [], summary: { mainStatus: 'IN_TRANSIT' } }) : original(...args)
const result = await runDeliveryStage(h.options)
assert.equal(result.deliveryClosureVerified, false)
assert.ok(result.blockers.includes('CN_DELIVERY_EVENT_NOT_RECONCILED'))
})
test('journal rejects changed binding and concurrent processes', async () => {
const dir = await mkdtemp(join(tmpdir(), 'prod-delivery-test-')), path = join(dir, 'journal.json')
try {
await withDeliveryJournal(path, 'first', async (_state, save) => {
await save({ fileId: '701' })
await assert.rejects(withDeliveryJournal(path, 'first', async () => {}), /EEXIST/)
})
await assert.rejects(withDeliveryJournal(path, 'changed', async () => {}), /不符/)
} finally { await rm(dir, { recursive: true, force: true }) }
})
+4 -2
View File
@@ -166,10 +166,12 @@ for (const quoteMismatch of [false, true]) {
data = { id: '1', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' }
} else if (endpoint.includes('/warehouse-admin?')) {
const domestic = endpoint.includes('warehouseType=DOMESTIC')
data = { total: '1', records: [{ id: domestic ? '2' : '6', warehouseCode: domestic ? 'CN-YIWU-01' : 'US-LAX-01', status: 'ACTIVE' }] }
data = domestic
? { total: '2', records: [{ id: '2', warehouseCode: 'CN-YIWU-01', status: 'ACTIVE' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01', status: 'ACTIVE' }] }
: { total: '1', records: [{ id: '6', warehouseCode: 'US-LAX-01', status: 'ACTIVE' }] }
} else if (endpoint.endsWith('/pda-loading/warehouses')) {
assert.equal(options.headers.Authorization, 'Bearer CN_PDA')
data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }]
data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }]
} else if (endpoint.endsWith('/warehouse-profile')) {
assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA-overseas')
data = { warehouseId: '6', warehouseCode: 'US-LAX-01' }