Merge pull request 'Release internal fleet capacity pool to gray' (#22) from test into main

This commit was merged in pull request #22.
This commit is contained in:
2026-10-08 08:11:51 +08:00
63 changed files with 4740 additions and 50 deletions
+7 -3
View File
@@ -1,5 +1,9 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
`migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
## 唯一入口与区域边界 ## 唯一入口与区域边界
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
@@ -7,7 +11,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 154 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 157 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -29,7 +33,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/154`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/157`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码 ## 平台管理员密码
@@ -45,7 +49,7 @@
| 所属库 | 保留内容 | 不包含 | | 所属库 | 保留内容 | 不包含 |
|---|---|---| |---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、412 个权限、593 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | | Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、417 个权限、598 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -0,0 +1,15 @@
-- Region: CN
-- Schema: baiyue_iam
-- Purpose: 内部拼车需求池五项独立权限
-- Preconditions: 权限编号5107410至5107414及对应code未使用
-- Postconditions: 仅默认平台管理员获得新权限;原客户权限不变
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','拼车需求池','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107410,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107411,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:manage','管理拼车发布和确认承运',NULL,'POST',1041,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107411,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107412,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-create','登记拼车意向',NULL,'POST',1042,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107412,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107413,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-edit','修改本人拼车意向',NULL,'PUT',1043,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107413,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107414,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-cancel','取消本人拼车意向',NULL,'POST',1044,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107414,0,CURRENT_TIMESTAMP(3));
@@ -0,0 +1,94 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 内部拼车需求池、持久幂等及本土订单唯一履约桥
-- Preconditions: 已有车次与外接预订基线;新表和新增字段均不存在
-- Postconditions: 新增三表与六字段;不删除、回填或重置业务数据
CREATE TABLE ll_fleet_capacity_offer (
id BIGINT NOT NULL COMMENT '内部主键',
offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号',
trip_id BIGINT NOT NULL COMMENT '唯一所属车次',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭',
cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间',
contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人',
contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话',
remark VARCHAR(1000) NULL COMMENT '公开承运说明',
published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_no(offer_no),
UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id),
KEY idx_fleet_capacity_offer_status(status,cutoff_at,id),
CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存';
CREATE TABLE ll_fleet_capacity_offer_command (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域',
operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者',
action VARCHAR(32) NOT NULL COMMENT '业务命令类型',
scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域',
idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要',
result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放',
created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key),
CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果';
ALTER TABLE ll_fleet_external_booking
ADD COLUMN offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
ADD COLUMN sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
ADD COLUMN business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
ADD COLUMN delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
ADD COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间',
ADD COLUMN expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空',
ADD KEY idx_fleet_booking_offer(offer_id,status,id),
ADD KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
ADD CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
ADD CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0);
CREATE TABLE ll_fleet_pool_order_link (
id BIGINT NOT NULL COMMENT '内部主键',
booking_id BIGINT NOT NULL COMMENT '唯一外接预订',
booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号',
customer_id VARCHAR(128) NOT NULL COMMENT '商业客户隔离范围',
business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型',
business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联',
business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号',
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库',
destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键',
delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓',
delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明',
incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空',
lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号',
fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成',
stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间',
actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空',
actual_package_count INT NULL COMMENT '实际箱数,未知为空',
actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本',
creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要',
created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_pool_order_booking(booking_id),
UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no),
UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id),
UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no),
UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id),
KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no),
KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no),
CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id),
CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')),
CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费';
@@ -0,0 +1,7 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 拼车履约客户关联字段与现有订单域排序规则一致
-- Preconditions: 外接预订和商业履约桥已存在,客户字段为VARCHAR(128)且非空
-- Postconditions: 两个客户关联字段使用utf8mb4_unicode_ci;不修改客户标识或业务状态
ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属';
ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围';
@@ -0,0 +1,10 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 拼车商业单未提供的柜型和打盘需求保持未知
-- Preconditions: 既有两类商业订单表存在,普通订单仍由服务端执行原字段校验
-- Postconditions: 三列允许NULL且默认NULL;不回填或覆盖既有订单事实
ALTER TABLE ll_local_looseboard_order
MODIFY COLUMN pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
MODIFY COLUMN container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜';
ALTER TABLE ll_us_truck_order
MODIFY COLUMN container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜';
@@ -6773,6 +6773,17 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107303,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:status','启停司机',NULL,'PATCH',933,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107303,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:status','启停司机',NULL,'PATCH',933,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','拼车需求池','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107410,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107411,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:manage','管理拼车发布和确认承运',NULL,'POST',1041,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107411,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107412,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-create','登记拼车意向',NULL,'POST',1042,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107412,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107413,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-edit','修改本人拼车意向',NULL,'PUT',1043,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107413,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107414,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-cancel','取消本人拼车意向',NULL,'POST',1044,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107414,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
+92 -5
View File
@@ -1341,13 +1341,13 @@ CREATE TABLE ll_local_looseboard_order (
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID', lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载', transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '是否需要打盘:YES或NO', pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址', origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号', destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
package_count int NOT NULL DEFAULT '0' COMMENT '货物件数', package_count int NOT NULL DEFAULT '0' COMMENT '货物件数',
total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)', total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)',
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
@@ -2522,7 +2522,7 @@ CREATE TABLE ll_us_truck_order (
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址', origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址', destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数', pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数',
package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效', package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效',
received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数', received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数',
@@ -2766,12 +2766,51 @@ CREATE TABLE ll_fleet_trip (
CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at) CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次';
CREATE TABLE ll_fleet_capacity_offer (
id BIGINT NOT NULL COMMENT '内部主键',
offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号',
trip_id BIGINT NOT NULL COMMENT '唯一所属车次',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭',
cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间',
contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人',
contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话',
remark VARCHAR(1000) NULL COMMENT '公开承运说明',
published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_no(offer_no),
UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id),
KEY idx_fleet_capacity_offer_status(status,cutoff_at,id),
CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存';
CREATE TABLE ll_fleet_capacity_offer_command (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域',
operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者',
action VARCHAR(32) NOT NULL COMMENT '业务命令类型',
scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域',
idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要',
result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放',
created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key),
CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果';
CREATE TABLE ll_fleet_external_booking ( CREATE TABLE ll_fleet_external_booking (
id BIGINT NOT NULL COMMENT '内部主键', id BIGINT NOT NULL COMMENT '内部主键',
booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号', booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号',
trip_id BIGINT NOT NULL COMMENT '所属车次', trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
customer_id VARCHAR(128) NOT NULL COMMENT '委托客户及付款归属', customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属',
customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照', customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照',
customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照', customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照',
status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消',
@@ -2795,11 +2834,21 @@ CREATE TABLE ll_fleet_external_booking (
receipt_hash CHAR(64) NULL COMMENT '实收确认摘要', receipt_hash CHAR(64) NULL COMMENT '实收确认摘要',
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash CHAR(64) NULL COMMENT '关联发运摘要', binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间',
expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本',
created_by VARCHAR(64) NOT NULL COMMENT '登记人', created_by VARCHAR(64) NOT NULL COMMENT '登记人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人', updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
KEY idx_fleet_booking_offer(offer_id,status,id),
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
PRIMARY KEY(id), PRIMARY KEY(id),
UNIQUE KEY uk_fleet_booking_no(booking_no), UNIQUE KEY uk_fleet_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
@@ -2810,6 +2859,44 @@ CREATE TABLE ll_fleet_external_booking (
CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED')) CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实';
CREATE TABLE ll_fleet_pool_order_link (
id BIGINT NOT NULL COMMENT '内部主键',
booking_id BIGINT NOT NULL COMMENT '唯一外接预订',
booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围',
business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型',
business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联',
business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号',
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库',
destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键',
delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓',
delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明',
incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空',
lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号',
fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成',
stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间',
actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空',
actual_package_count INT NULL COMMENT '实际箱数,未知为空',
actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本',
creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要',
created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_pool_order_booking(booking_id),
UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no),
UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id),
UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no),
UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id),
KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no),
KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no),
CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id),
CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')),
CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费';
CREATE TABLE ll_fleet_resource_assignment ( CREATE TABLE ll_fleet_resource_assignment (
resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER', resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER',
resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用', resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用',
@@ -3453,4 +3540,4 @@ CREATE TABLE event_delivery_attempt (
KEY idx_delivery_attempt_outcome (outcome,started_at) KEY idx_delivery_attempt_outcome (outcome,started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 154 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 157 AS initialized_tables;
@@ -0,0 +1,13 @@
-- 内部拼车需求池权限;仅平台管理员默认授权,销售角色由正式IAM接口分配。
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','拼车需求池','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107410,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107411,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:manage','管理拼车发布和确认承运',NULL,'POST',1041,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107411,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107412,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-create','登记拼车意向',NULL,'POST',1042,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107412,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107413,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-edit','修改本人拼车意向',NULL,'PUT',1043,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107413,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5107414,0,'PLATFORM','BUTTON',5107410,'platform:fleet:capacity-offer:inquiry-cancel','取消本人拼车意向',NULL,'POST',1044,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5107414,0,CURRENT_TIMESTAMP(3));
+9 -7
View File
@@ -2,18 +2,20 @@
## 职责与边界 ## 职责与边界
- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;各业务切片不得新增 `*MapperConfiguration` 或维护包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内门禁见 `MapperRegistrationArchitectureTest.java`。 - Mapper 必须标注 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止局部 `*MapperConfiguration` 和包白名单。验证:`scripts/mapper-scan-contract.mjs`、`MapperRegistrationArchitectureTest.java`。
- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert-container` 后才按柜内每票生成独立计划并推进订单。 - 消费头程装柜完成事件并保存 Inbox,按实装入仓单建立待分配尾程订单、冻结明细和初始历史;不自动建计划。平台调用 `/api/platform/v1/last-leg/inbound-plans/convert-container` 后才按柜内每票生成计划并推进订单。
- 负责尾程订单列表、详情、导出、完整入库计划、暂扣/解除、人工节点和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 负责尾程订单查询/导出、入库计划、暂扣/解除、人工节点、目的仓调整、托盘分配与 PDA 入仓;卸柜完成通过本地 Outbox 向头程回传报空事实。
- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 - 不跨库查询头程或回调补建单字段;快照缺失须拒绝消费并保留诊断。
## 快速索引 ## 快速索引
事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/lastleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许卸柜、派送、暂扣、资金指令与费用事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 拼车需求池:`fleet/trip/FleetCapacityOfferService.java`、`fleet/business/FleetPoolOrderBridge.java`;流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。
本表只保留定位入口;先沿目标代码的实际引用追踪。状态、权限、资源归属或接口边界受影响且本文件不足以判断时,再用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段;纯文案、局部重构或已知文件定位不自动增加详细索引。不得全文加载无关业务约束。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
退货预报、签收、库存、合箱与退货出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程来源托盘快照:`inboundplan/`。这两类入口的补充段落也在详细索引中,按对应短词查询。 先沿目标引用追踪;状态、权限、资源归属或接口边界仍不清楚时,用 `rg -n -F '关键词' docs/business-index.md` 定向读取。纯文案、局部重构及已知入口不扩读无关约束。
退货预报/签收/库存/合箱/出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程托盘快照:`inboundplan/`。细则按对应词查业务索引。
| 功能 | 首个入口 | | 功能 | 首个入口 |
|---|---| |---|---|
@@ -26,6 +26,7 @@ import java.util.*;
@Service @Service
public class DeliveryDocumentService { public class DeliveryDocumentService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private DeliveryOrderMapper orders; @Resource private DeliveryOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundOrderMapper outboundOrders;
@Resource private DeliveryDocumentRevisionMapper revisions; @Resource private DeliveryDocumentRevisionMapper revisions;
@@ -114,6 +115,7 @@ public class DeliveryDocumentService {
orders.updateById(order); orders.updateById(order);
if (autoCompleted) { if (autoCompleted) {
var completion = completeRelatedOrders(order, operatorId, now); var completion = completeRelatedOrders(order, operatorId, now);
fleetPool.signed(order, completion.sourcePackages(), operatorId, now);
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant()); deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant());
customerVisits.ensureForCompletedDelivery(order, now); customerVisits.ensureForCompletedDelivery(order, now);
} }
@@ -158,12 +160,16 @@ public class DeliveryDocumentService {
(int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed);
} }
private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private record Completion(List<LastlegOrder> orders, Map<String, SourceDeliveryCompletionPolicy.Result> proofs) { } private record Completion(List<LastlegOrder> orders, Map<String, SourceDeliveryCompletionPolicy.Result> proofs,
Map<Long,Long> sourcePackages) { }
private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) { private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) {
var related = new TreeMap<Long, LastlegOrder>(); var related = new TreeMap<Long, LastlegOrder>();
Map<Long,Long> sourcePackages = new TreeMap<>();
if (delivery.getDispatchOrderId() != null) { if (delivery.getDispatchOrderId() != null) {
var ids = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() var actualPicks = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream()
.filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())) .filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())).toList();
for (var pick : actualPicks) sourcePackages.merge(pick.orderId(), pick.packages(), Math::addExact);
var ids = actualPicks.stream()
.map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList(); .map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList();
if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) { if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) {
if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致"); if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致");
@@ -198,7 +204,7 @@ public class DeliveryDocumentService {
? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED");
tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail); tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail);
} }
return new Completion(List.copyOf(current),proofs); return new Completion(List.copyOf(current),proofs,Map.copyOf(sourcePackages));
} }
private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); } private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); }
private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) { private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) {
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
@Service @Service
public class DispatchPdaWorkflowService { public class DispatchPdaWorkflowService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private FleetDispatchBindingGuard fleetBindings; @Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private DispatchOrderMapper orders; @Resource private DispatchOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundOrderMapper outboundOrders;
@@ -275,6 +276,10 @@ public class DispatchPdaWorkflowService {
order.setUpdatedAt(now); order.setUpdatedAt(now);
orders.updateById(order); orders.updateById(order);
releasePalletLocks(customerId, order.getId(), now); releasePalletLocks(customerId, order.getId(), now);
Map<Long,Integer> sourcePackages = new TreeMap<>();
for (var item : dispatchItems) if (item.getOrderId() != null)
sourcePackages.merge(item.getOrderId(), item.getPackageCount(), Math::addExact);
fleetPool.departed(customerId, order.getDispatchNo(), sourcePackages, operatorId, now);
DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(), DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(),
order.getStatus(), shipped, false); order.getStatus(), shipped, false);
save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result); save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.business;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** Internal query row. Never returned directly to clients. */
@Data
public class FleetPoolArrangementRow extends FleetPoolOrderLink {
private String tripNo;
private LocalDateTime plannedDepartureAt;
private String originWarehouseName;
private String destinationLabel;
private BigDecimal agreedFreightAmount;
private String currency;
private String deliveryNo;
private Integer deliveryPackages;
private Integer signedPackages;
private Integer returnedPackages;
private Integer deliveryReturnedPackages;
private String deliveryStatus;
}
@@ -0,0 +1,225 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
@Service
public class FleetPoolOrderBridge {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final LastlegOrderNoGenerator numbers;
private final ObjectMapper json;
private final DeliverySourceEvidenceMapper deliveryEvidence;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
}
@Transactional(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in);
String hash=hash(in); var prior=links.lockBooking(in.bookingId());
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId());
row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()));
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now);
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportMode("SHARED_TRUCK");order.setTransportType(transport);
// A pallet count/space is not a palletizing service request or a container specification.
order.setPalletRequired(null);order.setContainerType(null);
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount());
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order.
} else {
var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportType(transport);order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());
order.setContainerType(null); // Pool inquiry does not collect a container specification.
order.setPalletCount(in.expectedPalletCount());order.setPackageCount(in.packageCount());order.setReceivedPalletCount(0);order.setAllocatedPalletCount(0);
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
trucks.insert(order); // No simulated ll_us_truck_order_pallet rows.
}
links.insert(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
return ref(row);
}
@Transactional(propagation=Propagation.MANDATORY)
public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId,
String warehouseId,String destinationKey,String operator) {
var row=links.lockBookingNo(bookingNo);
if(row==null){if(links.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
owned(row,customerId);
if(!Objects.equals(row.getWarehouseId(),warehouseId)||!Objects.equals(row.getDestinationKey(),destinationKey))throw conflict("来货仓库或目的地与池来源商业委托不一致");
if(row.getIncomingNo()!=null){if(!row.getIncomingNo().equals(incomingNo)||!Objects.equals(row.getLastlegOrderId(),lastlegOrderId))throw conflict("商业委托已绑定其他来货");return;}
if(!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("商业委托当前不能登记来货");
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());links.updateById(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源");
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order);
}
}
@Transactional(propagation=Propagation.MANDATORY)
public void receiving(String customerId,String incomingNo,String operator) {
var row=links.lockIncoming(customerId,incomingNo);if(row==null)return;
if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void acceptedReceipt(long bookingId,FleetBookingIncomingGateway.Evidence evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId);
if(evidence==null||!Objects.equals(row.getIncomingNo(),evidence.incomingNo())
||!Objects.equals(row.getWarehouseId(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey())
||!"READY_FOR_DISPATCH".equals(evidence.status())||evidence.pallets().isEmpty()||evidence.receivedPackages()<1||palletSpaces<1)throw conflict("商业单实收回传缺少唯一完整来源");
var unique=new HashSet<String>();int packages=0;
for(var pallet:evidence.pallets()){if(pallet.warehousePalletId()==null||!unique.add(pallet.warehousePalletId())||pallet.packages()<1)throw conflict("实收托盘重复或货量无效");packages=Math.addExact(packages,pallet.packages());}
if(packages!=evidence.receivedPackages())throw conflict("实收总箱数不守恒");
if("WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())) {
if(!Objects.equals(row.getActualPackageCount(),packages)||!Objects.equals(row.getActualPalletCount(),unique.size())||!Objects.equals(row.getActualPalletSpaces(),palletSpaces))throw conflict("已确认实收内容变化");return;
}
if(!Set.of("WAITING_INBOUND","RECEIVING").contains(row.getFulfillmentStage()))throw conflict("商业单当前不允许重复核量");
row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces);
move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
if("PENDING_DISPATCH".equals(row.getFulfillmentStage())&&Objects.equals(row.getDispatchNo(),dispatchNo))return;
if(!"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())||dispatchNo==null||dispatchNo.isBlank())throw conflict("商业单仅实收核量后可关联发运");
row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
if(row.getDispatchNo()==null&&"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage()))return;
if(!Objects.equals(row.getDispatchNo(),dispatchNo)||!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单当前不能解除发运关联");
row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) {
var row=requiredBooking(bookingId);owned(row,customerId);
if("CANCELLED".equals(row.getFulfillmentStage()))return;
if(row.getIncomingNo()!=null||!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("池来源已登记来货,首期不支持取消或跨车改配");
move(row,"CANCELLED",operator,reason,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void departed(String customerId,String dispatchNo,Map<Long,Integer> sourcePackages,String operator,LocalDateTime at) {
for(var row:links.lockDispatch(customerId,dispatchNo)) {
if(row.getActualPackageCount()==null||!Objects.equals(sourcePackages.get(row.getLastlegOrderId()),row.getActualPackageCount()))throw conflict("商业单实际出库来源或完整货量不匹配");
if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue;
if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运");
move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at);
}
}
@Transactional(propagation=Propagation.MANDATORY)
public void signed(DeliveryOrder delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.getDispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.getCustomerId(),delivery.getDispatchNo())) {
if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue;
if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整");
if("COMPLETED".equals(row.getFulfillmentStage()))continue;
if(!"IN_TRANSIT".equals(row.getFulfillmentStage()))throw conflict("商业单须实际出库后才能签收完成");
if("DELIVERED".equals(delivery.getStatus())&&delivery.getSignedAt()!=null&&wholeSourceSigned(row))
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.getDeliveryNo(),at);
}
}
private boolean wholeSourceSigned(FleetPoolOrderLink row) {
var dispatchIds=deliveryEvidence.dispatchIds(row.getCustomerId(),row.getLastlegOrderId());
if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false;
var deliveries=new ArrayList<SourceDeliveryCompletionPolicy.Delivery>();
var picks=new ArrayList<SourceDeliveryCompletionPolicy.Pick>();
var returns=new ArrayList<SourceDeliveryCompletionPolicy.Returned>();
for(Long dispatchId:dispatchIds) {
if(dispatchId==null||dispatchId<=0)return false;
var d=deliveryEvidence.deliveries(row.getCustomerId(),dispatchId);
var p=deliveryEvidence.picks(row.getCustomerId(),dispatchId);
var r=deliveryEvidence.returns(row.getCustomerId(),dispatchId);
if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false;
deliveries.addAll(d);picks.addAll(p);returns.addAll(r);
}
return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(row.getIncomingNo(),
row.getLastlegOrderId(),row.getActualPackageCount()),deliveries,picks,returns,true).fullySigned();
}
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
links.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
}
static void validate(ConfirmedBooking in) {
if(in==null||in.bookingId()<=0||in.businessType()==null||!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(in.businessType())
||blank(in.bookingNo())||blank(in.customerId())||"0".equals(in.customerId())||blank(in.warehouseId())||blank(in.warehouseName())
||blank(in.destinationKey())||(!in.destinationKey().startsWith("FBA:")&&!in.destinationKey().startsWith("PRIVATE:"))
||blank(in.destinationLabel())||in.plannedDepartureAt()==null||in.packageCount()==null||in.packageCount()<1
||blank(in.contactName())||blank(in.contactPhone())||in.currency()==null||!Set.of("USD","CNY").contains(in.currency())
||in.deliveryMode()==null||!Set.of("SELF_DELIVERY","MANUAL_PICKUP_TO_WAREHOUSE").contains(in.deliveryMode())
||in.weightKg()==null||in.weightKg().signum()<=0||in.volumeCbm()==null||in.volumeCbm().signum()<=0
||("US_TRUCK".equals(in.businessType())&&(in.expectedPalletCount()==null||in.expectedPalletCount()<1)))
throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份");
}
private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) {
String from=row.getFulfillmentStage();if(stage.equals(from))return;
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);links.updateById(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);loose.updateById(order);
} else {
var order=trucks.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);
if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount());
if(row.getActualPalletCount()!=null)order.setAllocatedPalletCount(row.getDispatchNo()==null?0:row.getActualPalletCount());
trucks.updateById(order);
}
history(row,from,stage,operator,reason,at);
}
private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) {
String key="FLEET_POOL:"+row.getBookingNo()+":"+to;
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var h=new LocalLooseboardHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);looseHistory.insert(h);
} else {
var h=new UsTruckOrderStatusHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);truckHistory.insert(h);
}
}
private String hash(Object input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(input).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private FleetPoolOrderLink requiredBooking(long bookingId){var row=links.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
private static void touch(FleetPoolOrderLink row,LocalDateTime at){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedAt(at);}
private static BusinessOrderRef ref(FleetPoolOrderLink row){return new BusinessOrderRef(row.getBusinessType(),row.getBusinessOrderNo());}
private static void owned(FleetPoolOrderLink row,String customer){if(!Objects.equals(row.getCustomerId(),customer))throw conflict("商业委托客户归属不一致");}
private static boolean blank(String value){return value==null||value.isBlank();}
private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_fleet_pool_order_link")
public class FleetPoolOrderLink {
@TableId(type=IdType.INPUT) private Long id;
private Long bookingId;
private String bookingNo;
private String customerId;
private String businessType;
private Long businessOrderId;
private String businessOrderNo;
private String warehouseId;
private String destinationKey;
private String deliveryMode;
private String deliveryInstructions;
private String incomingNo;
private Long lastlegOrderId;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String dispatchNo;
private String fulfillmentStage;
private LocalDateTime stageOccurredAt;
private Integer actualPalletCount;
private Integer actualPackageCount;
private Integer actualPalletSpaces;
private Long recordVersion;
private String creationRequestHash;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.Collection;
import java.util.List;
@Mapper
public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_id=#{id} FOR UPDATE")
FleetPoolOrderLink lockBooking(long id);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_no=#{no} FOR UPDATE")
FleetPoolOrderLink lockBookingNo(String no);
@Select("SELECT COUNT(*) FROM ll_fleet_external_booking WHERE booking_no=#{no} AND offer_id IS NOT NULL")
int poolBooking(String no);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no} FOR UPDATE")
FleetPoolOrderLink lockIncoming(@Param("customerId") String customerId,@Param("no") String no);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND dispatch_no=#{no} ORDER BY id FOR UPDATE")
List<FleetPoolOrderLink> lockDispatch(@Param("customerId") String customerId,@Param("no") String no);
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id}")
int linked(@Param("customerId") String customerId,@Param("type") String type,@Param("id") long id);
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no}")
int linkedIncoming(@Param("customerId") String customerId,@Param("no") String no);
@Select({"<script>SELECT * FROM ll_fleet_pool_order_link WHERE booking_id IN",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>"})
List<FleetPoolOrderLink> bookings(@Param("ids") Collection<Long> ids);
@Select({"<script>SELECT l.*,b.trip_no,t.planned_departure_at,t.warehouse_name AS origin_warehouse_name,",
"b.destination_label,b.agreed_amount AS agreed_freight_amount,JSON_UNQUOTE(JSON_EXTRACT(b.cargo_json,'$.currency')) AS currency,",
"d.delivery_no,d.package_count AS delivery_packages,d.signed_packages,d.status AS delivery_status,",
"(SELECT COALESCE(SUM(x.delta_packages),0) FROM ll_inventory_ledger x JOIN ll_exception_order e ON e.customer_id=x.customer_id AND e.exception_no=x.business_order_no",
"WHERE e.customer_id=l.customer_id AND e.delivery_order_id=d.id AND e.last_mile_order_id=l.lastleg_order_id AND x.business_type='RETURN_STOCK_IN' AND x.delta_packages>0",
"AND x.outbound_order_no=d.outbound_no AND EXISTS (SELECT 1 FROM ll_exception_return_item i WHERE i.customer_id=e.customer_id AND i.exception_order_id=e.id",
"AND x.source_event_id=CONCAT('RETURN_SOURCE:',i.outbound_pallet_item_id,':',i.inventory_position_id))) AS returned_packages,",
"(SELECT COALESCE(SUM(x.delta_packages),0) FROM ll_inventory_ledger x JOIN ll_exception_order e ON e.customer_id=x.customer_id AND e.exception_no=x.business_order_no",
"WHERE e.customer_id=l.customer_id AND e.delivery_order_id=d.id AND x.business_type='RETURN_STOCK_IN' AND x.delta_packages>0",
"AND x.outbound_order_no=d.outbound_no AND EXISTS (SELECT 1 FROM ll_exception_return_item i WHERE i.customer_id=e.customer_id AND i.exception_order_id=e.id",
"AND x.source_event_id=CONCAT('RETURN_SOURCE:',i.outbound_pallet_item_id,':',i.inventory_position_id))) AS delivery_returned_packages",
"FROM ll_fleet_pool_order_link l JOIN ll_fleet_external_booking b ON b.id=l.booking_id AND b.customer_id=l.customer_id",
"JOIN ll_fleet_trip t ON t.id=b.trip_id LEFT JOIN ll_order o ON o.id=l.lastleg_order_id AND o.customer_id=l.customer_id",
"LEFT JOIN ll_delivery_order d ON d.delivery_no=o.delivery_order_no AND d.customer_id=l.customer_id",
"WHERE <foreach collection='keys' item='k' open='(' separator=' OR ' close=')'>",
"(l.customer_id=#{k.customerId} AND l.business_type=#{k.businessType} AND l.business_order_id=#{k.businessOrderId})",
"</foreach></script>"})
List<FleetPoolArrangementRow> arrangements(@Param("keys") Collection<FleetPoolOrderModels.OrderKey> keys);
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.business;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Instant;
public final class FleetPoolOrderModels {
private FleetPoolOrderModels() { }
public record ConfirmedBooking(long bookingId, String bookingNo, String customerId, String businessType,
String warehouseId, String warehouseName, String destinationKey, String destinationLabel,
LocalDateTime plannedDepartureAt, String deliveryMode, String deliveryInstructions,
LocalDateTime estimatedArrivalAt, String cargoName, Integer packageCount, Integer expectedPalletCount,
BigDecimal weightKg, BigDecimal volumeCbm, String currency, String contactName, String contactPhone,
String remark) { }
public record BusinessOrderRef(String businessType, String businessOrderNo) { }
public record OrderKey(String customerId, String businessType, long businessOrderId) { }
public record Arrangement(String sourceType, String businessType, String businessOrderNo, String bookingNo,
String tripNo, Instant plannedDepartureAt, String originWarehouseName, String destinationLabel,
String deliveryMode, String deliveryInstructions, String fulfillmentStage, Instant stageOccurredAt,
BigDecimal agreedFreightAmount, String currency, String incomingNo, String dispatchNo, String deliveryNo,
Integer signedPackages, Integer unsignedPackages, Integer returnedPackages, String exceptionSummary,
String readOnlyReason) { }
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.fleet.business;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Only callers' already-authorized order keys are accepted; one batch query for a whole page. */
@Service
public class FleetPoolOrderQueries {
private final FleetPoolOrderMapper links;
public FleetPoolOrderQueries(FleetPoolOrderMapper links){this.links=links;}
public Map<OrderKey,Arrangement> arrangements(Collection<OrderKey> keys) {
if(keys==null||keys.isEmpty())return Map.of();
var allowed=Set.copyOf(keys);var result=new HashMap<OrderKey,Arrangement>();
for(var row:links.arrangements(allowed)) {
var key=new OrderKey(row.getCustomerId(),row.getBusinessType(),row.getBusinessOrderId());
if(!allowed.contains(key))throw new IllegalStateException("商业摘要客户范围不匹配");
if(result.put(key,project(row))!=null)throw new IllegalStateException("商业委托存在多个履约投影");
}
return result;
}
public Arrangement arrangement(OrderKey key){return arrangements(List.of(key)).get(key);}
public void requireStandalone(String customer,String type,long id) {
if(links.linked(customer,type,id)>0)throw conflict("此订单由拼车承运实际履约推进,请查看承运安排并从来货、排车或签收入口操作");
}
public void requireIndependentIncoming(String customer,String no) {
if(links.linkedIncoming(customer,no)>0)throw conflict("此来货属于已确认拼车承运,首期不支持独立取消、拒绝或变更承运来源");
}
static Arrangement project(FleetPoolArrangementRow row) {
Integer actual=row.getActualPackageCount(),signed=null,returned=row.getReturnedPackages();
if("COMPLETED".equals(row.getFulfillmentStage()))signed=actual;
else if(row.getSignedPackages()==null)signed=0;
else if("DELIVERED".equals(row.getDeliveryStatus())&&actual!=null&&returned!=null
&&row.getDeliveryPackages()!=null&&row.getDeliveryReturnedPackages()!=null
&&row.getSignedPackages()+row.getDeliveryReturnedPackages()==row.getDeliveryPackages()) signed=Math.max(0,actual-returned);
Integer unsigned=actual==null||signed==null?null:Math.max(0,actual-signed);
String exception=returned!=null&&returned>0?"存在实际退回货物,请核对派送及退回记录":
Set.of("RETURN_PENDING","PARTIALLY_RETURNED","RETURNED","EXCEPTION").contains(Objects.requireNonNullElse(row.getDeliveryStatus(),""))?"派送存在异常,尚未证明全部签收":
"DELIVERED".equals(row.getDeliveryStatus())&&!"COMPLETED".equals(row.getFulfillmentStage())?"实际来源签收尚未核齐,请核对拣货和退回记录":null;
return new Arrangement("FLEET_CAPACITY_OFFER",row.getBusinessType(),row.getBusinessOrderNo(),row.getBookingNo(),
row.getTripNo(),row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),row.getOriginWarehouseName(),row.getDestinationLabel(),
row.getDeliveryMode(),row.getDeliveryInstructions(),row.getFulfillmentStage(),row.getStageOccurredAt()==null?null:row.getStageOccurredAt().atZone(java.time.ZoneId.systemDefault()).toInstant(),
row.getAgreedFreightAmount(),row.getCurrency(),row.getIncomingNo(),row.getDispatchNo(),row.getDeliveryNo(),
signed,unsigned,returned,exception,"此订单由拼车承运实际履约推进,请从来货、排车和签收入口操作");
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -14,6 +14,12 @@ public class FleetBookingEntity {
private String customerId; private String customerId;
private String customerCode; private String customerCode;
private String customerName; private String customerName;
private Long offerId;
private String salesOwnerUserId;
private String businessType;
private String deliveryMode;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private Integer expectedPalletCount;
private String status; private String status;
private String cargoJson; private String cargoJson;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String quoteSourceJson; @TableField(updateStrategy=FieldStrategy.ALWAYS) private String quoteSourceJson;
@@ -6,6 +6,8 @@ import java.util.List;
@Mapper @Mapper
public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> { public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> {
@Select("SELECT * FROM ll_fleet_external_booking WHERE booking_no=#{number}")
FleetBookingEntity byNo(@Param("number") String number);
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'") @Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'")
FleetBookingEntity incoming(@Param("incomingNo") String incomingNo); FleetBookingEntity incoming(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}") @Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}")
@@ -10,7 +10,7 @@ import java.util.List;
public final class FleetBookingModels { public final class FleetBookingModels {
private FleetBookingModels() { } private FleetBookingModels() { }
public record Cargo(@NotBlank @Size(max=160) String destinationKey, @Size(max=255) String cargoName, public record Cargo(@NotBlank @Size(max=160) String destinationKey, @Size(max=255) String cargoName,
@Min(1) int packageCount, @Min(1) Integer palletSpaces, @DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal weightKg, @Min(1) Integer packageCount, @Min(1) Integer palletSpaces, @DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal weightKg,
@DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal volumeCbm, @DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal volumeCbm,
@Pattern(regexp="PALLET|ORDER") String priceBasis, @DecimalMin("0.00") @Digits(integer=10, fraction=2) BigDecimal unitPrice, @Pattern(regexp="PALLET|ORDER") String priceBasis, @DecimalMin("0.00") @Digits(integer=10, fraction=2) BigDecimal unitPrice,
@Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { } @Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { }
@@ -39,7 +39,7 @@ public final class FleetBookingModels {
public record AcceptedReceipt(FleetBookingIncomingGateway.Evidence source,List<PalletSpaces> pallets,String operatorId,Instant confirmedAt) { } public record AcceptedReceipt(FleetBookingIncomingGateway.Evidence source,List<PalletSpaces> pallets,String operatorId,Instant confirmedAt) { }
public record Response(String bookingNo, String tripNo, String customerId, String customerCode, String customerName, public record Response(String bookingNo, String tripNo, String customerId, String customerCode, String customerName,
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt, String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt,
FleetQuoteCandidateModels.SourceSnapshot quoteSource) { } FleetQuoteCandidateModels.SourceSnapshot quoteSource,String offerNo,Integer expectedPalletCount) { }
public record Result(long tripRecordVersion, Response booking, boolean replayed) { } public record Result(long tripRecordVersion, Response booking, boolean replayed) { }
public record ListResult(long tripRecordVersion, List<Response> records) { } public record ListResult(long tripRecordVersion, List<Response> records) { }
} }
@@ -16,13 +16,16 @@ import java.util.*;
public class FleetBookingReservations { public class FleetBookingReservations {
private final FleetBookingMapper bookings; private final FleetBookingMapper bookings;
private final ObjectMapper json; private final ObjectMapper json;
@jakarta.annotation.Resource private FleetCapacityOfferMapper offers;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders;
public FleetBookingReservations(FleetBookingMapper bookings, ObjectMapper json) { this.bookings=bookings; this.json=json; } public FleetBookingReservations(FleetBookingMapper bookings, ObjectMapper json) { this.bookings=bookings; this.json=json; }
public boolean hasConfirmed(long tripId) { return bookings.list(tripId).stream().anyMatch(row -> unbound(row)); } public boolean hasConfirmed(long tripId) { return bookings.list(tripId).stream().anyMatch(row -> unbound(row)); }
public List<FleetBookingModels.Response> responses(long tripId) { return bookings.list(tripId).stream().map(this::response).toList(); } public List<FleetBookingModels.Response> responses(long tripId) { return bookings.list(tripId).stream().map(this::response).toList(); }
public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); } public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); }
public FleetBookingModels.Response response(FleetBookingEntity row) { public FleetBookingModels.Response response(FleetBookingEntity row) {
return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(), return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(),
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row)); row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row),
row.getOfferId()==null?null:offers.selectById(row.getOfferId()).getOfferNo(),row.getExpectedPalletCount());
} }
public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);} public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);}
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own, public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
@@ -61,10 +64,12 @@ public class FleetBookingReservations {
} }
if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物"); if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物");
row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator); row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator);
if(row.getOfferId()!=null)poolOrders.boundDispatch(row.getId(),row.getCustomerId(),dispatch.dispatchNo(),operator);
} }
} }
public void unbindIncoming(FleetTripEntity trip,String dispatchNo,String operator){ public void unbindIncoming(FleetTripEntity trip,String dispatchNo,String operator){
for(var row:bookings.list(trip.getId()))if("BOUND".equals(row.getStatus())&&Objects.equals(row.getDispatchNo(),dispatchNo)){ for(var row:bookings.list(trip.getId()))if("BOUND".equals(row.getStatus())&&Objects.equals(row.getDispatchNo(),dispatchNo)){
if(row.getOfferId()!=null)poolOrders.unboundDispatch(row.getId(),row.getCustomerId(),dispatchNo,operator);
row.setStatus("RECEIVED");row.setDispatchNo(null);touch(row,operator); row.setStatus("RECEIVED");row.setDispatchNo(null);touch(row,operator);
} }
} }
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService; import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService;
@@ -33,6 +34,7 @@ public class FleetBookingService {
private final FleetBookingIncomingGateway incoming; private final FleetBookingIncomingGateway incoming;
private final FleetBookingQuoteContext quoteContext; private final FleetBookingQuoteContext quoteContext;
private final FleetQuoteCandidateService quotes; private final FleetQuoteCandidateService quotes;
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){ FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
@@ -61,17 +63,31 @@ public class FleetBookingService {
} }
@Transactional @Transactional
public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){ public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
rejectPoolLegacy(bookingNo);
return updatePool(tripNo,bookingNo,operator,request);
}
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetBookingModels.Result updatePool(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion()); var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion());
if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留"); if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留");
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false); apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
} }
@Transactional @Transactional
public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){ public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request); rejectPoolLegacy(bookingNo);
return confirmPool(tripNo,bookingNo,operator,request);
}
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetBookingModels.Result confirmPool(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
var trip=trip(tripNo,true);
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);
var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getConfirmKey(),request.idempotencyKey())){if(!hash.equals(row.getConfirmHash()))throw conflict("承运确认请求内容不一致");return result(trip,row,true);} if(Objects.equals(row.getConfirmKey(),request.idempotencyKey())){if(!hash.equals(row.getConfirmHash()))throw conflict("承运确认请求内容不一致");return result(trip,row,true);}
if(vehicleBlock!=null)throw conflict(vehicleBlock);
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"DRAFT".equals(row.getStatus()))throw conflict("仅询价中的外接货可以确认承运"); if(!"DRAFT".equals(row.getStatus()))throw conflict("仅询价中的外接货可以确认承运");
customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row); customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row);
requireCompleteCargo(cargo);
if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种"); if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种");
if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种"); if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种");
var source=reservations.quoteSource(row); var source=reservations.quoteSource(row);
@@ -84,6 +100,11 @@ public class FleetBookingService {
} }
@Transactional @Transactional
public FleetBookingModels.Result cancel(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){ public FleetBookingModels.Result cancel(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
rejectPoolLegacy(bookingNo);
return cancelPool(tripNo,bookingNo,operator,request);
}
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetBookingModels.Result cancelPool(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request); var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getCancelKey(),request.idempotencyKey())){if(!hash.equals(row.getCancelHash()))throw conflict("取消请求内容不一致");return result(trip,row,true);} if(Objects.equals(row.getCancelKey(),request.idempotencyKey())){if(!hash.equals(row.getCancelHash()))throw conflict("取消请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
@@ -113,8 +134,9 @@ public class FleetBookingService {
} }
@Transactional @Transactional
public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){ public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request); var trip=trip(tripNo,true);String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);} if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);}
if(vehicleBlock!=null)throw conflict(vehicleBlock);
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货"); if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货");
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence); var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
@@ -138,6 +160,7 @@ public class FleetBookingService {
if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对"); if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对");
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now()))); row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash); row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),confirmed,totalSpaces,operator);
save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false); save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false);
} }
@Transactional @Transactional
@@ -156,6 +179,15 @@ public class FleetBookingService {
if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致");
if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致");
} }
private void rejectPoolLegacy(String bookingNo){
var row=bookings.byNo(bookingNo);
if(row!=null&&row.getOfferId()!=null)throw conflict("拼车需求池意向请从需求池执行修改、成交或取消");
}
static void requireCompleteCargo(FleetBookingModels.Cargo cargo){
if(cargo.packageCount()==null||cargo.packageCount()<1||cargo.palletSpaces()==null||cargo.palletSpaces()<1
||cargo.weightKg()==null||cargo.weightKg().signum()<=0||cargo.volumeCbm()==null||cargo.volumeCbm().signum()<=0)
throw conflict("确认承运前请补齐预计箱数、托位、重量和体积");
}
private FleetQuoteCandidateModels.SourceSnapshot resolveQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){ private FleetQuoteCandidateModels.SourceSnapshot resolveQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){
var cargo=reservations.cargo(row); var cargo=reservations.cargo(row);
return quotes.lockAndResolve(quoteContext.resolve(trip,Long.parseLong(row.getCustomerId()),cargo.destinationKey(),cargo.currency(),cargo.priceBasis()),reference); return quotes.lockAndResolve(quoteContext.resolve(trip,Long.parseLong(row.getCustomerId()),cargo.destinationKey(),cargo.currency(),cargo.priceBasis()),reference);
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_fleet_capacity_offer_command")
public class FleetCapacityOfferCommand {
@TableId(type=IdType.INPUT) private Long id;
private Long tripId;
private String operatorId;
private String action;
private String scopeNo;
private String idempotencyKey;
private String requestHash;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
@Mapper
public interface FleetCapacityOfferCommandMapper extends BaseMapper<FleetCapacityOfferCommand> {
@Select("SELECT * FROM ll_fleet_capacity_offer_command WHERE trip_id=#{tripId} AND operator_id=#{operatorId} AND action=#{action} AND scope_no=#{scopeNo} AND idempotency_key=#{key}")
FleetCapacityOfferCommand find(@Param("tripId") long tripId,@Param("operatorId") String operatorId,
@Param("action") String action,@Param("scopeNo") String scopeNo,@Param("key") String key);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_fleet_capacity_offer")
public class FleetCapacityOfferEntity {
@TableId(type=IdType.INPUT) private Long id;
private String offerNo;
private Long tripId;
private String tripNo;
private String status;
private LocalDateTime cutoffAt;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String contactName;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String contactPhone;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String remark;
private String publishedFingerprint;
private Long recordVersion;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
@Mapper
public interface FleetCapacityOfferMapper extends BaseMapper<FleetCapacityOfferEntity> {
@Select("""
<script>
SELECT o.* FROM ll_fleet_capacity_offer o JOIN ll_fleet_trip t ON t.id=o.trip_id
<where>
<if test="salesOnly">o.status='OPEN'</if>
<if test="q.status != null">AND o.status=#{q.status}</if>
<if test="q.tripNo != null and q.tripNo != ''">AND o.trip_no=#{q.tripNo}</if>
<if test="q.keyword != null and q.keyword != ''">AND (o.offer_no LIKE CONCAT('%',#{q.keyword},'%') OR o.trip_no LIKE CONCAT('%',#{q.keyword},'%'))</if>
<if test="q.origin != null and q.origin != ''">AND t.warehouse_name LIKE CONCAT('%',#{q.origin},'%')</if>
<if test="q.destination != null and q.destination != ''">AND JSON_SEARCH(JSON_REMOVE(t.stops_json,'$[0]'),'one',CONCAT('%',#{q.destination},'%'),NULL,'$[*].name','$[*].destinationKey') IS NOT NULL</if>
<if test="q.departureFrom != null">AND t.planned_departure_at &gt;= #{q.departureFrom}</if>
<if test="q.departureTo != null">AND DATE(t.planned_departure_at) &lt;= #{q.departureTo}</if>
</where>
ORDER BY o.updated_at DESC,o.id DESC
</script>
""")
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> pageOffers(
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> page,
@Param("q") FleetCapacityOfferModels.Query query,@Param("salesOnly") boolean salesOnly);
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo}")
FleetCapacityOfferEntity byNo(@Param("offerNo") String offerNo);
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo} FOR UPDATE")
FleetCapacityOfferEntity lock(@Param("offerNo") String offerNo);
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE trip_id=#{tripId}")
FleetCapacityOfferEntity byTrip(@Param("tripId") long tripId);
}
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/** Dedicated sales projection: no driver, other customer cargo or procurement facts. */
public final class FleetCapacityOfferModels {
private FleetCapacityOfferModels() { }
public record Query(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
@Size(max=128) String keyword,@Size(max=128) String origin,@Size(max=128) String destination,
LocalDate departureFrom,LocalDate departureTo,@Pattern(regexp="DRAFT|OPEN|PAUSED|CLOSED") String status,
Boolean canConfirm,@Size(max=64) String tripNo) {
public Query {page=page==null?1:page;size=size==null?20:size;}
}
public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
@Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status,
@Pattern(regexp="MINE|ALL") String scope) {
public InquiryQuery {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"MINE":scope;}
}
public record Write(@NotBlank @Size(max=64) String tripNo,@NotNull @Min(0) Long tripRecordVersion,
@Min(0) Long offerRecordVersion,@NotNull Instant cutoffAt,@Size(max=128) String contactName,
@Size(max=128) String contactPhone,@Size(max=1000) String remark,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Command(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@Size(max=1000) String reason) { }
public record InquiryWrite(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
@Min(0) Long recordVersion,@NotNull @Positive Long customerId,
@NotBlank @Pattern(regexp="LOCAL_LOOSEBOARD|US_TRUCK") String businessType,
@NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode,
Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount,
@NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@Size(max=1000) String reason) { }
public record CustomerOption(String id,String code,String name) { }
public record Destination(String destinationKey,String name,int deliveryIndex,TripCapacityPolicy.Load remaining) { }
public record Segment(int fromIndex,int toIndex,String fromName,String toName,
TripCapacityPolicy.Load planned,TripCapacityPolicy.Load reserved,TripCapacityPolicy.Load remaining) { }
public record Actions(boolean canEdit,boolean canPublish,boolean canPause,boolean canResume,boolean canClose) { }
public record Offer(String offerNo,String tripNo,String status,long recordVersion,long tripRecordVersion,
String warehouseId,String warehouseName,Instant plannedDepartureAt,Instant cutoffAt,
String contactName,String contactPhone,String remark,List<Destination> destinations,List<Segment> segments,
boolean capacityKnown,boolean canCreateInquiry,boolean canConfirm,String blockReason,Actions actions) { }
public record InquiryActions(boolean canEdit,boolean canConfirm,boolean canCancel,String blockReason) { }
public record Inquiry(String bookingNo,String offerNo,String tripNo,String status,long recordVersion,
long offerRecordVersion,long tripRecordVersion,String salesOwnerUserId,String customerId,String customerCode,
String customerName,String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount,
FleetBookingModels.Cargo cargo,String destinationLabel,BigDecimal agreedAmount,
FleetQuoteCandidateModels.SourceSnapshot quoteSource,String businessOrderNo,String incomingNo,String dispatchNo,
Instant updatedAt,InquiryActions actions) { }
public record Detail(Offer offer,PageResult<Inquiry> inquiries) { }
public record OfferResult(Offer offer,boolean replayed) { }
public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { }
}
@@ -0,0 +1,285 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
@Service
public class FleetCapacityOfferService {
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
private final FleetCapacityOfferMapper offers;
private final FleetCapacityOfferCommandMapper commands;
private final FleetTripMapper trips;
private final FleetBookingMapper bookings;
private final FleetBookingService bookingService;
private final FleetBookingReservations reservations;
private final FleetTripExecutionService execution;
private final FleetCustomerDirectory customers;
private final FleetPoolOrderBridge orders;
private final ObjectMapper json;
private final Clock clock;
@Autowired
public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
FleetTripExecutionService execution,FleetCustomerDirectory customers,FleetPoolOrderBridge orders,ObjectMapper json){
this(offers,commands,trips,bookings,bookingService,reservations,execution,customers,orders,json,Clock.systemUTC());
}
FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
FleetTripExecutionService execution,FleetCustomerDirectory customers,FleetPoolOrderBridge orders,ObjectMapper json,Clock clock){
this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService;
this.reservations=reservations;this.execution=execution;this.customers=customers;this.orders=orders;this.json=json;this.clock=clock;
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
readPermission();
var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!manage());
if(query.canConfirm()!=null&&result.getTotal()>100)throw bad("实时接单资格筛选最多核对100趟车次,请缩小起运仓、目的地或日期范围");
var values=result.getRecords().stream().map(row->project(row,trip(row.getTripNo(),false)))
.filter(value->query.canConfirm()==null||query.canConfirm()==value.canConfirm()).toList();
if(query.canConfirm()!=null)return page(values,query.page(),query.size());
return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public FleetCapacityOfferModels.Detail detail(String number){
readPermission();var row=offer(number,false);requireVisible(row);
return new FleetCapacityOfferModels.Detail(project(row,trip(row.getTripNo(),false)),inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,number,null,manage()?"ALL":"MINE")));
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetCapacityOfferModels.Inquiry> inquiries(FleetCapacityOfferModels.InquiryQuery query){
readPermission();if("ALL".equals(query.scope())&&!manage())throw forbidden();
var filter=new LambdaQueryWrapper<FleetBookingEntity>().isNotNull(FleetBookingEntity::getOfferId);
if(!"ALL".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator());
if(query.offerNo()!=null){var row=offer(query.offerNo(),false);requireVisible(row);filter.eq(FleetBookingEntity::getOfferId,row.getId());}
if(nonblank(query.status()))filter.eq(FleetBookingEntity::getStatus,query.status());
if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword()));
filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId);
var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.page(),query.size()),filter);
var numbers=orders.businessOrderNumbers(result.getRecords().stream().map(FleetBookingEntity::getId).toList());
var projections=new HashMap<Long,FleetCapacityOfferModels.Offer>();
var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}))).toList();
return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public FleetCapacityOfferModels.Inquiry inquiry(String number){
readPermission();var row=inquiryRow(number);own(row);return inquiry(row,orders.businessOrderNumbers(List.of(row.getId())).get(row.getId()));
}
@Transactional(readOnly=true)
public List<FleetCapacityOfferModels.CustomerOption> customerOptions(String keyword){
writePermission("inquiry-create");
return customers.options(keyword).stream().map(c->new FleetCapacityOfferModels.CustomerOption(c.id(),c.code(),c.name())).toList();
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetQuoteCandidateModels.SourceSnapshot> quoteCandidates(String number,FleetBookingModels.QuoteCandidatesQuery query){
readPermission();if(!has("platform:fleet:quote:list"))throw forbidden();
var row=offer(number,false);requireVisible(row);customers.required(query.customerId());
return bookingService.quoteCandidates(row.getTripNo(),query);
}
@Transactional
public FleetCapacityOfferModels.OfferResult create(FleetCapacityOfferModels.Write request){
requireManage();var trip=trip(request.tripNo(),true);
var replay=replay(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
tripVersion(trip,request.tripRecordVersion());
if(offers.byTrip(trip.getId())!=null)throw conflict("该车次已有发布,请从既有发布修改");
validateCutoff(trip,request.cutoffAt());
var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo("FO-"+row.getId());row.setTripId(trip.getId());row.setTripNo(trip.getTripNo());
row.setStatus("DRAFT");row.setRecordVersion(0L);row.setCreatedBy(operator());row.setCreatedAt(now());apply(row,request);
if(offers.insert(row)!=1)throw conflict("发布创建失败");
var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,result);return result;
}
@Transactional
public FleetCapacityOfferModels.OfferResult update(String number,FleetCapacityOfferModels.Write request){
requireManage();var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
var replay=replay(trip,"EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
if(!Objects.equals(request.tripNo(),trip.getTripNo())||"CLOSED".equals(row.getStatus()))throw conflict("关闭的发布不可修改,也不能变更所属车次");
validateCutoff(trip,request.cutoffAt());apply(row,request);touch(row);
var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"EDIT",number,request.idempotencyKey(),request,result);return result;
}
@Transactional
public FleetCapacityOfferModels.OfferResult command(String number,String action,FleetCapacityOfferModels.Command request){
requireManage();var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offer(number,true);
var replay=replay(trip,action,number,request.idempotencyKey(),request,FleetCapacityOfferModels.OfferResult.class);
if(replay!=null)return new FleetCapacityOfferModels.OfferResult(replay.offer(),true);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
if("CLOSED".equals(row.getStatus()))throw conflict("发布已关闭,不能恢复;已有意向仍可查询");
switch(action){
case "PUBLISH","RESUME" -> {
if("PUBLISH".equals(action)&&!"DRAFT".equals(row.getStatus())||"RESUME".equals(action)&&!Set.of("OPEN","PAUSED").contains(row.getStatus()))throw conflict("发布状态已变化,请刷新");
validateCutoff(trip,instant(row.getCutoffAt()));
if(vehicleBlock!=null)throw conflict(vehicleBlock);
var planning=execution.capacityPlan(trip.getTripNo());requirePlanning(trip,planning);
row.setPublishedFingerprint(fingerprint(trip));row.setStatus("OPEN");
}
case "PAUSE" -> {if(!"OPEN".equals(row.getStatus()))throw conflict("只有开放发布可暂停");row.setStatus("PAUSED");}
case "CLOSE" -> row.setStatus("CLOSED");
default -> throw bad("发布操作无效");
}
touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);
saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result;
}
@Transactional
public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
writePermission("inquiry-create");var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
if(replay!=null)return replayed(replay);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
var created=bookingService.create(trip.getTripNo(),operator(),new FleetBookingModels.Create(trip.getRecordVersion(),request.customerId(),request.cargo(),"POOL:"+hash(Arrays.asList(number,request.idempotencyKey())),request.quoteRef()));
if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对");
var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request);
bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
}
@Transactional
public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());
var booking=bookings.lock(trip.getId(),number);own(booking);
var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记");
if(request.recordVersion()==null)throw bad("缺少意向版本");
bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef()));
booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking);
var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result;
}
@Transactional
public FleetCapacityOfferModels.InquiryResult confirm(String number,FleetCapacityOfferModels.InquiryCommand request){
requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true);
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number);
var replay=replay(trip,"CONFIRM",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);
if(vehicleBlock!=null)throw conflict(vehicleBlock);
var offer=project(row,trip);if(!offer.canConfirm())throw conflict(offer.blockReason());
var cargo=reservations.cargo(booking);FleetBookingService.requireCompleteCargo(cargo);
if(!nonblank(cargo.contactName())||!nonblank(cargo.contactPhone())||!nonblank(cargo.cargoName()))throw conflict("成交前请补齐货名和联系信息");
if("US_TRUCK".equals(booking.getBusinessType())&&(booking.getExpectedPalletCount()==null||booking.getExpectedPalletCount()<1))throw conflict("本土卡车成交前请填写预计实托数,不能用托位替代");
if(booking.getEstimatedArrivalAt()==null||booking.getEstimatedArrivalAt().isAfter(row.getCutoffAt()))throw conflict("请填写不晚于交货截止的预计到仓时间");
bookingService.confirmPool(trip.getTripNo(),number,operator(),new FleetBookingModels.Command(trip.getRecordVersion(),request.recordVersion(),request.idempotencyKey(),request.reason()));
booking=bookings.lock(trip.getId(),number);
orders.confirm(new FleetPoolOrderModels.ConfirmedBooking(booking.getId(),booking.getBookingNo(),booking.getCustomerId(),booking.getBusinessType(),trip.getWarehouseId().toString(),trip.getWarehouseName(),
booking.getDestinationKey(),booking.getDestinationLabel(),trip.getPlannedDepartureAt(),booking.getDeliveryMode(),cargo.remark(),booking.getEstimatedArrivalAt(),cargo.cargoName(),cargo.packageCount(),booking.getExpectedPalletCount(),cargo.weightKg(),cargo.volumeCbm(),cargo.currency(),cargo.contactName(),cargo.contactPhone(),cargo.remark()),operator());
var result=inquiryResult(row,booking);saveCommand(trip,"CONFIRM",number,request.idempotencyKey(),request,result);return result;
}
@Transactional
public FleetCapacityOfferModels.InquiryResult cancel(String number,FleetCapacityOfferModels.InquiryCommand request){
writePermission("inquiry-cancel");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number);own(booking);
var replay=replay(trip,"CANCEL",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());
if(!Objects.equals(booking.getRecordVersion(),request.recordVersion()))throw conflict("意向已变化,请刷新");
if(!Set.of("DRAFT","CONFIRMED").contains(booking.getStatus())||booking.getIncomingNo()!=null)throw conflict("已登记来货或作业的意向不能在需求池取消,请保留原作业事实");
if("CONFIRMED".equals(booking.getStatus())){requireManage();orders.cancelBeforeIncoming(booking.getId(),booking.getCustomerId(),operator(),request.reason());}
// Cancelling an uncommitted draft remains possible after offer closure or trip departure.
if("DRAFT".equals(booking.getStatus())&&!"PLANNED".equals(trip.getStatus())){
booking.setStatus("CANCELLED");booking.setRecordVersion(booking.getRecordVersion()+1);booking.setUpdatedBy(operator());booking.setUpdatedAt(now());bookings.updateById(booking);
}else bookingService.cancelPool(trip.getTripNo(),number,operator(),new FleetBookingModels.Command(trip.getRecordVersion(),booking.getRecordVersion(),request.idempotencyKey(),request.reason()));
var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result;
}
private FleetCapacityOfferModels.InquiryResult inquiryResult(FleetCapacityOfferEntity row,FleetBookingEntity booking){
return new FleetCapacityOfferModels.InquiryResult(inquiry(booking,orders.businessOrderNumbers(List.of(booking.getId())).get(booking.getId())),project(row,trip(row.getTripNo(),false)),false);
}
private FleetCapacityOfferModels.InquiryResult replayed(FleetCapacityOfferModels.InquiryResult value){return new FleetCapacityOfferModels.InquiryResult(value.inquiry(),value.offer(),true);}
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo){
var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)));
}
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer){
boolean owned=manage()||Objects.equals(operator(),booking.getSalesOwnerUserId());boolean draft="DRAFT".equals(booking.getStatus());
boolean canCancel=owned&&(draft||manage()&&"CONFIRMED".equals(booking.getStatus())&&booking.getIncomingNo()==null);
return new FleetCapacityOfferModels.Inquiry(booking.getBookingNo(),offer.offerNo(),booking.getTripNo(),booking.getStatus(),booking.getRecordVersion(),offer.recordVersion(),offer.tripRecordVersion(),booking.getSalesOwnerUserId(),booking.getCustomerId(),booking.getCustomerCode(),booking.getCustomerName(),booking.getBusinessType(),booking.getDeliveryMode(),instant(booking.getEstimatedArrivalAt()),booking.getExpectedPalletCount(),reservations.cargo(booking),booking.getDestinationLabel(),booking.getAgreedAmount(),reservations.quoteSource(booking),businessNo,booking.getIncomingNo(),booking.getDispatchNo(),instant(booking.getUpdatedAt()),
new FleetCapacityOfferModels.InquiryActions(owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))),manage()&&draft&&offer.canConfirm(),canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),offer.blockReason()));
}
private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){
String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo());
String capacityBlock=planning.blockReason();
if(capacityBlock==null&&!planning.planValid())capacityBlock="计划容量待调度核对";
if(capacityBlock==null&&trip.getDriverId()==null)capacityBlock="请先分配司机";
if(capacityBlock==null&&!Objects.equals(row.getPublishedFingerprint(),fingerprint(trip)))capacityBlock="发布承诺已变化,请调度重新核对并恢复接单";
if(!manage()&&!"OPEN".equals(row.getStatus()))capacityBlock="发布未开放,仅可查看本人历史意向";
boolean known=capacityBlock==null;
var stops=reservations.stops(trip);var segments=new ArrayList<FleetCapacityOfferModels.Segment>();
var destinations=new ArrayList<FleetCapacityOfferModels.Destination>();
if(known)for(var segment:planning.availableSegments()){
var own=planning.capacityPlan().segments().get(segment.fromIndex()).occupied();var all=segment.occupied();
segments.add(new FleetCapacityOfferModels.Segment(segment.fromIndex(),segment.toIndex(),stops.get(segment.fromIndex()).name(),stops.get(segment.toIndex()).name(),own,
new TripCapacityPolicy.Load(all.palletSpaces()-own.palletSpaces(),all.weightKg().subtract(own.weightKg()),all.volumeCbm().subtract(own.volumeCbm())),segment.remaining()));
}
for(int index=1;index<stops.size();index++)if(nonblank(stops.get(index).destinationKey())){
TripCapacityPolicy.Load remaining=null;
if(known){remaining=segments.get(0).remaining();for(int part=1;part<index;part++)remaining=min(remaining,segments.get(part).remaining());}
destinations.add(new FleetCapacityOfferModels.Destination(stops.get(index).destinationKey(),stops.get(index).name(),index,remaining));
}
if(block==null)block=capacityBlock;
if(block==null&&destinations.stream().noneMatch(d->positive(d.remaining())))block="可接运力已满";
boolean editable=manage()&&!"CLOSED".equals(row.getStatus());boolean baseReady=planning.planValid()&&planning.blockReason()==null&&trip.getDriverId()!=null&&cutoffValid(trip,row.getCutoffAt(),clock.instant());
return new FleetCapacityOfferModels.Offer(row.getOfferNo(),trip.getTripNo(),row.getStatus(),row.getRecordVersion(),trip.getRecordVersion(),String.valueOf(trip.getWarehouseId()),trip.getWarehouseName(),instant(trip.getPlannedDepartureAt()),instant(row.getCutoffAt()),row.getContactName(),row.getContactPhone(),row.getRemark(),List.copyOf(destinations),List.copyOf(segments),known,
openBlock(row,trip,clock.instant())==null,block==null,block,new FleetCapacityOfferModels.Actions(editable,editable&&"DRAFT".equals(row.getStatus())&&baseReady,editable&&"OPEN".equals(row.getStatus()),editable&&Set.of("OPEN","PAUSED").contains(row.getStatus())&&baseReady,editable));
}
static TripCapacityPolicy.Load min(TripCapacityPolicy.Load a,TripCapacityPolicy.Load b){return new TripCapacityPolicy.Load(Math.min(a.palletSpaces(),b.palletSpaces()),a.weightKg().min(b.weightKg()),a.volumeCbm().min(b.volumeCbm()));}
private static boolean positive(TripCapacityPolicy.Load value){return value!=null&&value.palletSpaces()>0&&value.weightKg().signum()>0&&value.volumeCbm().signum()>0;}
static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return "PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());}
static String openBlock(FleetCapacityOfferEntity row,FleetTripEntity trip,Instant now){
if(!"OPEN".equals(row.getStatus()))return "发布未开放接单";
if(!"PLANNED".equals(trip.getStatus()))return "车次已发车、结案或取消,不再接受新意向";
if(!cutoffValid(trip,row.getCutoffAt(),now))return "交货截止已过或不符合当前发车时间";return null;
}
private String fingerprint(FleetTripEntity trip){return hash(Arrays.asList(trip.getWarehouseId(),trip.getVehicleId(),trip.getDriverId(),trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm(),trip.getPlannedDepartureAt(),trip.getExpectedDriverEndAt(),trip.getStopsJson(),trip.getPlannedLoadJson()));}
private void validateCutoff(FleetTripEntity trip,Instant cutoff){if(!cutoffValid(trip,cutoff==null?null:LocalDateTime.ofInstant(cutoff,ZoneOffset.UTC),clock.instant()))throw conflict("请填写晚于当前时间且不晚于计划发车的交货截止");}
private void requireInquiryOpen(FleetCapacityOfferEntity row,FleetTripEntity trip){String reason=openBlock(row,trip,clock.instant());if(reason!=null)throw conflict(reason);}
private void requirePlanning(FleetTripEntity trip,FleetExecutionModels.Planning plan){if(!plan.planValid()||plan.blockReason()!=null||trip.getDriverId()==null)throw conflict(plan.blockReason()==null?"请先分配司机并核对计划容量":plan.blockReason());}
private void validateInquiry(FleetTripEntity trip,FleetCapacityOfferModels.InquiryWrite request){
if(!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(request.businessType())||!Set.of("SELF_DELIVERY","MANUAL_PICKUP_TO_WAREHOUSE").contains(request.deliveryMode()))throw bad("业务类型或交货方式无效");
if(FleetBookingReservations.index(reservations.stops(trip),request.cargo().destinationKey())<1)throw conflict("请选择本车已有卸货站");
if(request.estimatedArrivalAt()!=null&&request.estimatedArrivalAt().isAfter(instant(trip.getPlannedDepartureAt())))throw conflict("预计到仓不得晚于计划发车");
}
private void apply(FleetCapacityOfferEntity row,FleetCapacityOfferModels.Write request){row.setCutoffAt(LocalDateTime.ofInstant(request.cutoffAt(),ZoneOffset.UTC));row.setContactName(trim(request.contactName()));row.setContactPhone(trim(request.contactPhone()));row.setRemark(trim(request.remark()));row.setUpdatedBy(operator());row.setUpdatedAt(now());}
private void apply(FleetBookingEntity row,FleetCapacityOfferModels.InquiryWrite request){row.setBusinessType(request.businessType());row.setDeliveryMode(request.deliveryMode());row.setExpectedPalletCount(request.expectedPalletCount());row.setEstimatedArrivalAt(request.estimatedArrivalAt()==null?null:LocalDateTime.ofInstant(request.estimatedArrivalAt(),ZoneOffset.UTC));}
private void touch(FleetCapacityOfferEntity row){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator());row.setUpdatedAt(now());if(offers.updateById(row)!=1)throw conflict("发布已变化,请刷新");}
private <T>T replay(FleetTripEntity trip,String action,String scope,String key,Object request,Class<T> type){var command=commands.find(trip.getId(),operator(),action,scope,key);if(command==null)return null;if(!Objects.equals(command.getRequestHash(),hash(request)))throw conflict("同一请求键不能更换请求内容");try{return json.readValue(command.getResultJson(),type);}catch(Exception e){throw new IllegalStateException("原命令结果无法读取",e);}}
private void saveCommand(FleetTripEntity trip,String action,String scope,String key,Object request,Object result){var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator());command.setAction(action);command.setScopeNo(scope);command.setIdempotencyKey(key);command.setRequestHash(hash(request));command.setResultJson(write(result));command.setCreatedAt(now());if(commands.insert(command)!=1)throw conflict("命令记录保存失败");}
private void versions(FleetTripEntity trip,FleetCapacityOfferEntity row,Long tripVersion,Long offerVersion){tripVersion(trip,tripVersion);if(!Objects.equals(row.getRecordVersion(),offerVersion))throw conflict("发布已变化,请刷新后重新核对");}
private void tripVersion(FleetTripEntity trip,Long version){if(!Objects.equals(trip.getRecordVersion(),version))throw conflict("车次已变化,请刷新后重新核对");}
private FleetCapacityOfferEntity offer(String number,boolean lock){var row=lock?offers.lock(number):offers.byNo(number);if(row==null)throw missing();return row;}
private FleetTripEntity trip(String number,boolean lock){var row=lock?trips.lockByNo(number):trips.findByNo(number);if(row==null)throw missing();return row;}
private FleetBookingEntity inquiryRow(String number){var row=bookings.byNo(number);if(row==null||row.getOfferId()==null)throw missing();return row;}
private void own(FleetBookingEntity row){if(!manage()&&!Objects.equals(row.getSalesOwnerUserId(),operator()))throw forbidden();}
private void requireVisible(FleetCapacityOfferEntity row){if(!manage()&&!"OPEN".equals(row.getStatus())&&bookings.selectCount(new LambdaQueryWrapper<FleetBookingEntity>().eq(FleetBookingEntity::getOfferId,row.getId()).eq(FleetBookingEntity::getSalesOwnerUserId,operator()))==0)throw forbidden();}
private static boolean has(String permission){var auth=SecurityContextHolder.getContext().getAuthentication();return auth!=null&&auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));}
private static boolean manage(){return has(MANAGE);}
private static void readPermission(){if(!has(LIST)&&!manage())throw forbidden();operator();}
private static void writePermission(String action){if(!manage()&&(!has(LIST)||!has("platform:fleet:capacity-offer:"+action)))throw forbidden();operator();}
private static void requireManage(){if(!manage())throw forbidden();operator();}
private static String operator(){var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null||!(auth.getPrincipal() instanceof AuthenticatedUser user)||user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");if(!Long.valueOf(0).equals(user.tenantId()))throw forbidden();return user.userId().toString();}
private <T>PageResult<T> page(List<T> rows,int page,int size){int start=(int)Math.min(rows.size(),((long)page-1)*size);return new PageResult<>(List.copyOf(rows.subList(start,Math.min(rows.size(),start+size))),page,size,rows.size(),(rows.size()+size-1)/size);}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("拼车数据无法保存",e);}}
private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8)));}catch(java.security.NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);}
private static Instant instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC);}
private static boolean contains(String value,String needle){return !nonblank(needle)||value!=null&&value.toLowerCase(Locale.ROOT).contains(needle.trim().toLowerCase(Locale.ROOT));}
private static boolean nonblank(String value){return value!=null&&!value.isBlank();}
private static String trim(String value){return nonblank(value)?value.trim():null;}
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
private static ResponseStatusException bad(String value){return new ResponseStatusException(HttpStatus.BAD_REQUEST,value);}
private static ResponseStatusException forbidden(){return new ResponseStatusException(HttpStatus.FORBIDDEN,"无权操作该发布或意向");}
private static ResponseStatusException missing(){return new ResponseStatusException(HttpStatus.NOT_FOUND,"发布、车次或意向不存在");}
}
@@ -51,6 +51,8 @@ public class FleetTripExecutionService {
public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) { public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) {
var trip = requireTrip(tripNo, true); var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion()); requireVersionAndPlan(trip, request.recordVersion());
String vehicleBlock = currentVehicleBlockReason(trip, true);
if (vehicleBlock != null) throw conflict(vehicleBlock);
var cargo = cargo(manifests(trip, true)); var cargo = cargo(manifests(trip, true));
String reason = planningBlockReason(trip, cargo); String reason = planningBlockReason(trip, cargo);
if (reason != null) throw conflict(reason); if (reason != null) throw conflict(reason);
@@ -74,12 +76,28 @@ public class FleetTripExecutionService {
private String planningBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) { private String planningBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量"; if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量";
if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积"; if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积";
String vehicleReason = currentVehicleBlockReason(trip, false);
if (vehicleReason != null) return vehicleReason;
if (cargo.isEmpty()) return "请先关联本车自有货排车单"; if (cargo.isEmpty()) return "请先关联本车自有货排车单";
if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null)) if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null))
return "请先明确每票货物的实物托盘和卸货目的地"; return "请先明确每票货物的实物托盘和卸货目的地";
return null; return null;
} }
/** Caller holds the parent trip lock before requesting a vehicle write lock. */
public String currentVehicleBlockReason(FleetTripEntity trip, boolean lock) {
if (trip.getVehicleId() == null) return "请先分配车辆";
var vehicle = (lock ? vehicles.lockById(trip.getVehicleId()) : vehicles.findById(trip.getVehicleId())).orElse(null);
if (vehicle == null || !"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region()))
return "车辆当前不可用,请重新分配车辆并核对计划容量";
if (!Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate())
|| !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces())
|| !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg())
|| !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm()))
return "车辆容量或资料已变化,请重新分配车辆并核对计划容量";
return null;
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Loading loading(String tripNo) { public FleetExecutionModels.Loading loading(String tripNo) {
var trip = requireTrip(tripNo, false); var trip = requireTrip(tripNo, false);
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/fleet/capacity-offers")
public class PlatformFleetCapacityOfferController {
private final FleetCapacityOfferService service;
public PlatformFleetCapacityOfferController(FleetCapacityOfferService service){this.service=service;}
@GetMapping @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<PageResult<FleetCapacityOfferModels.Offer>> page(@Valid FleetCapacityOfferModels.Query query){return ApiResponse.success(service.page(query));}
@GetMapping("/{offerNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.Detail> detail(@PathVariable String offerNo){return ApiResponse.success(service.detail(offerNo));}
@PostMapping @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.OfferResult> create(@Valid @RequestBody FleetCapacityOfferModels.Write request){return ApiResponse.success(service.create(request));}
@PutMapping("/{offerNo}") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.OfferResult> update(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Write request){return ApiResponse.success(service.update(offerNo,request));}
@PostMapping("/{offerNo}/publish") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.OfferResult> publish(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"PUBLISH",request));}
@PostMapping("/{offerNo}/pause") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.OfferResult> pause(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"PAUSE",request));}
@PostMapping("/{offerNo}/resume") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.OfferResult> resume(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"RESUME",request));}
@PostMapping("/{offerNo}/close") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.OfferResult> close(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"CLOSE",request));}
@GetMapping("/inquiries") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<PageResult<FleetCapacityOfferModels.Inquiry>> inquiries(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.inquiries(query));}
@GetMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.Inquiry> inquiry(@PathVariable String bookingNo){return ApiResponse.success(service.inquiry(bookingNo));}
@PostMapping("/{offerNo}/inquiries") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))")
public ApiResponse<FleetCapacityOfferModels.InquiryResult> createInquiry(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.createInquiry(offerNo,request));}
@PutMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-edit'))")
public ApiResponse<FleetCapacityOfferModels.InquiryResult> updateInquiry(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.updateInquiry(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.InquiryResult> confirm(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryCommand request){return ApiResponse.success(service.confirm(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/cancel") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-cancel'))")
public ApiResponse<FleetCapacityOfferModels.InquiryResult> cancel(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryCommand request){return ApiResponse.success(service.cancel(bookingNo,request));}
@GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))")
public ApiResponse<List<FleetCapacityOfferModels.CustomerOption>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(service.customerOptions(keyword));}
@GetMapping("/{offerNo}/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:quote:list') and hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String offerNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(offerNo,query));}
}
@@ -8,12 +8,13 @@ import java.util.List;
public final class LocalLooseboardResponses { public final class LocalLooseboardResponses {
private LocalLooseboardResponses() { } private LocalLooseboardResponses() { }
/** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */
public record Summary( public record Summary(
Long id, String customerId, String orderNo, String transportMode, String palletRequired, Long id, String customerId, String orderNo, String transportMode, String palletRequired,
String transportType, String origin, String destination, String containerType, int packageCount, String transportType, String origin, String destination, String containerType, int packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, LocalDate requestedDate, String status, long version, String contactName, String contactInfo, LocalDate requestedDate, String status, long version,
LocalDateTime createdAt, LocalDateTime updatedAt) { } LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) { }
public record History( public record History(
String operationType, String fromStatus, String toStatus, String operatorId, String operationType, String fromStatus, String toStatus, String operatorId,
@@ -41,12 +41,13 @@ import java.util.Set;
@Service @Service
public class LocalLooseboardService { public class LocalLooseboardService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE"; public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
public static final String DISPATCHED = "DISPATCHED"; public static final String DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT"; public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED"; public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED"; public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED); private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
private static final Set<String> MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION"); private static final Set<String> MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION");
private static final Map<String, Set<String>> TRANSITIONS = Map.of( private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED), PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
@@ -100,6 +101,7 @@ public class LocalLooseboardService {
return detail(customerId, order.getOrderNo()); return detail(customerId, order.getOrderNo());
} }
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) { public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) {
validateQuery(query); validateQuery(query);
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>() LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
@@ -116,11 +118,13 @@ public class LocalLooseboardService {
.le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo()) .le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); .orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
Page<LocalLooseboardOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper); Page<LocalLooseboardOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(), var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages()); result.getSize(), result.getTotal(), result.getPages());
} }
/** Customer-scoped query that ignores any client-supplied customer filter. */ /** Customer-scoped query that ignores any client-supplied customer filter. */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId, public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) { LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
@@ -128,6 +132,7 @@ public class LocalLooseboardService {
query.requestedFrom(), query.requestedTo())); query.requestedFrom(), query.requestedTo()));
} }
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) { public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.findNo(customerId, orderNo); LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound(); if (order == null) throw notFound();
@@ -227,6 +232,7 @@ public class LocalLooseboardService {
private LocalLooseboardOrder lock(String customerId, String orderNo) { private LocalLooseboardOrder lock(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo); LocalLooseboardOrder order = orders.lockNo(customerId, orderNo);
if (order == null) throw notFound(); if (order == null) throw notFound();
fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId());
return order; return order;
} }
@@ -240,13 +246,19 @@ public class LocalLooseboardService {
value.getCreatedAt())).toList()); value.getCreatedAt())).toList());
} }
private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(LocalLooseboardOrder order) {
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
}
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) { private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
return summary(order, fleetPool.arrangement(poolKey(order)));
}
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(), return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(), order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0), order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(), order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(),
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
} }
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator, private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
@@ -18,6 +18,7 @@ import java.util.*;
@Component @Component
public class FleetIncomingAdapter implements FleetBookingIncomingGateway { public class FleetIncomingAdapter implements FleetBookingIncomingGateway {
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
private final WarehouseTransferService service; private final WarehouseTransferService service;
private final WarehouseTransferOrderMapper orders; private final WarehouseTransferOrderMapper orders;
private final ObjectMapper json; private final ObjectMapper json;
@@ -36,6 +37,8 @@ public class FleetIncomingAdapter implements FleetBookingIncomingGateway {
List.of(new WarehouseTransferRequests.Item(null,input.cargo().cargoName(),input.cargo().packageCount(),null)),null); List.of(new WarehouseTransferRequests.Item(null,input.cargo().cargoName(),input.cargo().packageCount(),null)),null);
var created=service.createAndSubmit(input.customerId(),operator,request); var created=service.createAndSubmit(input.customerId(),operator,request);
var approved=service.review(input.customerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","外接承运已确认,按实际来货收货")); var approved=service.review(input.customerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","外接承运已确认,按实际来货收货"));
fleetPool.bindIncoming(input.bookingNo(),input.customerId(),approved.transferNo(),approved.lastlegOrderId(),
approved.operatingWarehouseId(),key,operator);
return evidence(approved); return evidence(approved);
} }
@Override @Transactional(propagation=Propagation.MANDATORY) @Override @Transactional(propagation=Propagation.MANDATORY)
@@ -88,6 +88,8 @@ import java.util.stream.Collectors;
@Service @Service
public class WarehouseTransferService { public class WarehouseTransferService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries;
public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER"; public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER";
public static final String DRAFT = "DRAFT"; public static final String DRAFT = "DRAFT";
public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -273,6 +275,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Create request) { WarehouseTransferRequests.Create request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found"); if (transfer == null) throw notFound("Warehouse transfer order not found");
fleetPoolQueries.requireIndependentIncoming(customerId, transferNo);
if (!DRAFT.equals(transfer.getStatus())) { if (!DRAFT.equals(transfer.getStatus())) {
throw conflict("Only a draft warehouse transfer order can be edited"); throw conflict("Only a draft warehouse transfer order can be edited");
} }
@@ -642,6 +645,7 @@ public class WarehouseTransferService {
} }
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found"); if (transfer == null) throw notFound("Warehouse transfer order not found");
if (Set.of("REJECT", REJECTED).contains(decision)) fleetPoolQueries.requireIndependentIncoming(customerId, transferNo);
if (!PENDING_REVIEW.equals(transfer.getStatus())) { if (!PENDING_REVIEW.equals(transfer.getStatus())) {
if (next.equals(transfer.getStatus()) || APPROVED.equals(next) if (next.equals(transfer.getStatus()) || APPROVED.equals(next)
&& Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH,
@@ -712,6 +716,7 @@ public class WarehouseTransferService {
pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg()); pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg());
pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash); pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash);
pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet);
fleetPool.receiving(customerId, transferNo, pdaOperatorId());
return detail(customerId, transferNo); return detail(customerId, transferNo);
} }
@@ -869,6 +874,7 @@ public class WarehouseTransferService {
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo,
"PDA receiving measurement and palletization completed", now); "PDA receiving measurement and palletization completed", now);
fleetPool.receiving(customerId, transferNo, operatorId);
return detail(customerId, transferNo); return detail(customerId, transferNo);
} }
@@ -1037,6 +1043,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Allocate request) { WarehouseTransferRequests.Allocate request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found"); if (transfer == null) throw notFound("Warehouse transfer order not found");
fleetPoolQueries.requireIndependentIncoming(customerId, transferNo);
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
if (key.equals(transfer.getAllocationIdempotencyKey())) { if (key.equals(transfer.getAllocationIdempotencyKey())) {
@@ -1146,6 +1153,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Outbound request) { WarehouseTransferRequests.Outbound request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found"); if (transfer == null) throw notFound("Warehouse transfer order not found");
fleetPoolQueries.requireIndependentIncoming(customerId, transferNo);
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
if (Set.of(OUTBOUNDED, PARTIALLY_RECEIVED, RECEIVED).contains(transfer.getStatus()) if (Set.of(OUTBOUNDED, PARTIALLY_RECEIVED, RECEIVED).contains(transfer.getStatus())
@@ -1927,6 +1935,12 @@ public class WarehouseTransferService {
throw new IllegalStateException(exception); throw new IllegalStateException(exception);
} }
} }
private static String pdaOperatorId() {
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof com.baiyue.common.security.AuthenticatedUser user)
return user.userId() == null ? user.username() : user.userId().toString();
return auth == null ? null : auth.getName();
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); } private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); }
private static long packages(Long value) { return value == null ? 0L : value; } private static long packages(Long value) { return value == null ? 0L : value; }
@@ -8,13 +8,14 @@ import java.util.List;
public final class UsTruckOrderResponses { public final class UsTruckOrderResponses {
private UsTruckOrderResponses() {} private UsTruckOrderResponses() {}
/** Pool orders retain null containerType; forecast physical pallets do not imply a container type. */
public record Summary( public record Summary(
Long id, String customerId, String orderNo, String transportType, Long id, String customerId, String orderNo, String transportType,
String origin, String destination, String containerType, int palletCount, int packageCount, String origin, String destination, String containerType, int palletCount, int packageCount,
int receivedPalletCount, int allocatedPalletCount, int receivedPalletCount, int allocatedPalletCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate, String currency, String contactName, String contactInfo, LocalDate requestedDate,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) {}
public record History( public record History(
String operationType, String fromStatus, String toStatus, String operatorId, String operationType, String fromStatus, String toStatus, String operatorId,
@@ -40,6 +40,7 @@ import java.util.function.Consumer;
@Service @Service
public class UsTruckOrderService { public class UsTruckOrderService {
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS;
public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -106,6 +107,7 @@ public class UsTruckOrderService {
return detail(customerId, order.getOrderNo()); return detail(customerId, order.getOrderNo());
} }
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) { public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) {
validateQuery(query); validateQuery(query);
LambdaQueryWrapper<UsTruckOrder> w = new LambdaQueryWrapper<UsTruckOrder>() LambdaQueryWrapper<UsTruckOrder> w = new LambdaQueryWrapper<UsTruckOrder>()
@@ -122,10 +124,12 @@ public class UsTruckOrderService {
.le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo()) .le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo())
.orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId); .orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId);
Page<UsTruckOrder> result = orders.selectPage(Page.of(query.page(), query.size()), w); Page<UsTruckOrder> result = orders.selectPage(Page.of(query.page(), query.size()), w);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(), var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages()); result.getSize(), result.getTotal(), result.getPages());
} }
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) { public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) {
UsTruckOrder order = orders.findNo(customerId, orderNo); UsTruckOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound(); if (order == null) throw notFound();
@@ -397,7 +401,7 @@ public class UsTruckOrderService {
} }
} }
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; } private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; }
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) { private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
List<UsTruckOrderResponses.Pallet> palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream() List<UsTruckOrderResponses.Pallet> palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream()
@@ -413,7 +417,9 @@ public class UsTruckOrderService {
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()), return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt()); p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
} }
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); } private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(UsTruckOrder o) { return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(o.getCustomerId(), "US_TRUCK", o.getId()); }
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return summary(o, fleetPool.arrangement(poolKey(o))); }
private UsTruckOrderResponses.Summary summary(UsTruckOrder o, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt(), arrangement); }
private void history(UsTruckOrder o, String operationType, String from, String to, String operator, private void history(UsTruckOrder o, String operationType, String from, String to, String operator,
String idempotencyKey, String remark, LocalDateTime at) { String idempotencyKey, String remark, LocalDateTime at) {
UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory();
@@ -1131,12 +1131,51 @@ CREATE TABLE ll_fleet_trip (
CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at) CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次';
CREATE TABLE ll_fleet_capacity_offer (
id BIGINT NOT NULL COMMENT '内部主键',
offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号',
trip_id BIGINT NOT NULL COMMENT '唯一所属车次',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭',
cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间',
contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人',
contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话',
remark VARCHAR(1000) NULL COMMENT '公开承运说明',
published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_no(offer_no),
UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id),
KEY idx_fleet_capacity_offer_status(status,cutoff_at,id),
CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存';
CREATE TABLE ll_fleet_capacity_offer_command (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域',
operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者',
action VARCHAR(32) NOT NULL COMMENT '业务命令类型',
scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域',
idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要',
result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放',
created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key),
CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果';
CREATE TABLE ll_fleet_external_booking ( CREATE TABLE ll_fleet_external_booking (
id BIGINT NOT NULL COMMENT '内部主键', id BIGINT NOT NULL COMMENT '内部主键',
booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号', booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号',
trip_id BIGINT NOT NULL COMMENT '所属车次', trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
customer_id VARCHAR(128) NOT NULL COMMENT '委托客户及付款归属', customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属',
customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照', customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照',
customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照', customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照',
status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消',
@@ -1160,11 +1199,21 @@ CREATE TABLE ll_fleet_external_booking (
receipt_hash CHAR(64) NULL COMMENT '实收确认摘要', receipt_hash CHAR(64) NULL COMMENT '实收确认摘要',
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash CHAR(64) NULL COMMENT '关联发运摘要', binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间',
expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本',
created_by VARCHAR(64) NOT NULL COMMENT '登记人', created_by VARCHAR(64) NOT NULL COMMENT '登记人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人', updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
KEY idx_fleet_booking_offer(offer_id,status,id),
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
PRIMARY KEY(id), PRIMARY KEY(id),
UNIQUE KEY uk_fleet_booking_no(booking_no), UNIQUE KEY uk_fleet_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
@@ -1175,6 +1224,44 @@ CREATE TABLE ll_fleet_external_booking (
CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED')) CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实';
CREATE TABLE ll_fleet_pool_order_link (
id BIGINT NOT NULL COMMENT '内部主键',
booking_id BIGINT NOT NULL COMMENT '唯一外接预订',
booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围',
business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型',
business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联',
business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号',
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库',
destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键',
delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓',
delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明',
incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空',
lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号',
fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成',
stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间',
actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空',
actual_package_count INT NULL COMMENT '实际箱数,未知为空',
actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本',
creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要',
created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_pool_order_booking(booking_id),
UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no),
UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id),
UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no),
UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id),
KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no),
KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no),
CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id),
CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')),
CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费';
CREATE TABLE ll_fleet_resource_assignment ( CREATE TABLE ll_fleet_resource_assignment (
resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER', resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER',
resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用', resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用',
@@ -2574,7 +2661,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
origin_address VARCHAR(500) NULL COMMENT '起运地址', origin_address VARCHAR(500) NULL COMMENT '起运地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination VARCHAR(255) NOT NULL COMMENT '目的地',
destination_address VARCHAR(500) NULL COMMENT '目的地址', destination_address VARCHAR(500) NULL COMMENT '目的地址',
container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数',
package_count INT NULL COMMENT '可选箱数,零或空值均有效', package_count INT NULL COMMENT '可选箱数,零或空值均有效',
received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
@@ -2649,10 +2736,10 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型',
origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
File diff suppressed because it is too large Load Diff
@@ -340,11 +340,11 @@ components:
schemas: schemas:
BookingCargo: BookingCargo:
type: object type: object
required: [destinationKey, packageCount] required: [destinationKey]
properties: properties:
destinationKey: {type: string, maxLength: 160} destinationKey: {type: string, maxLength: 160}
cargoName: {type: string, nullable: true, maxLength: 255} cargoName: {type: string, nullable: true, maxLength: 255}
packageCount: {type: integer, minimum: 1} packageCount: {type: integer, nullable: true, minimum: 1, description: 询价未知可为空,确认承运前必须补齐正数箱数}
palletSpaces: {type: integer, nullable: true, minimum: 1} palletSpaces: {type: integer, nullable: true, minimum: 1}
weightKg: {type: number, nullable: true, minimum: 0.001} weightKg: {type: number, nullable: true, minimum: 0.001}
volumeCbm: {type: number, nullable: true, minimum: 0.001} volumeCbm: {type: number, nullable: true, minimum: 0.001}
@@ -437,6 +437,8 @@ components:
Booking: Booking:
type: object type: object
properties: properties:
offerNo: {type: string, nullable: true, description: 来源拼车发布号,池来源的成交和取消必须经专用接口}
expectedPalletCount: {type: integer, nullable: true, minimum: 1, description: 预计实托数,不等同托位}
bookingNo: {type: string} bookingNo: {type: string}
tripNo: {type: string} tripNo: {type: string}
customerId: {type: string} customerId: {type: string}
@@ -3353,6 +3353,7 @@ components:
properties: { records: { type: array, items: { $ref: '#/components/schemas/TruckSummary' } } } properties: { records: { type: array, items: { $ref: '#/components/schemas/TruckSummary' } } }
TruckSummary: TruckSummary:
type: object type: object
description: 卡车商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。列表批量查询承运摘要,不逐行请求车次。
required: [id, customerId, orderNo, origin, destination, palletCount, packageCount, receivedPalletCount, allocatedPalletCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] required: [id, customerId, orderNo, origin, destination, palletCount, packageCount, receivedPalletCount, allocatedPalletCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
properties: properties:
id: { type: string, pattern: '^[0-9]+$' } id: { type: string, pattern: '^[0-9]+$' }
@@ -3361,7 +3362,7 @@ components:
transportType: { type: string, nullable: true } transportType: { type: string, nullable: true }
origin: { type: string } origin: { type: string }
destination: { type: string } destination: { type: string }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ }
palletCount: { type: integer } palletCount: { type: integer }
packageCount: { type: integer } packageCount: { type: integer }
receivedPalletCount: { type: integer } receivedPalletCount: { type: integer }
@@ -3377,8 +3378,10 @@ components:
version: { type: integer, format: int64 } version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time }
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
TruckDetail: TruckDetail:
type: object type: object
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源手工状态入口禁止旁路实际履约。
required: [summary, pallets, histories] required: [summary, pallets, histories]
properties: properties:
summary: { $ref: '#/components/schemas/TruckSummary' } summary: { $ref: '#/components/schemas/TruckSummary' }
@@ -3407,17 +3410,18 @@ components:
properties: { records: { type: array, items: { $ref: '#/components/schemas/LooseboardSummary' } } } properties: { records: { type: array, items: { $ref: '#/components/schemas/LooseboardSummary' } } }
LooseboardSummary: LooseboardSummary:
type: object type: object
required: [id, customerId, orderNo, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。
required: [id, customerId, orderNo, transportMode, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
properties: properties:
id: { type: string, pattern: '^[0-9]+$' } id: { type: string, pattern: '^[0-9]+$' }
customerId: { type: string } customerId: { type: string }
orderNo: { type: string } orderNo: { type: string }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] } palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES }
transportType: { type: string } transportType: { type: string }
origin: { type: string } origin: { type: string }
destination: { type: string } destination: { type: string }
containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ }
packageCount: { type: integer } packageCount: { type: integer }
totalWeightKg: { type: number } totalWeightKg: { type: number }
totalVolumeCbm: { type: number } totalVolumeCbm: { type: number }
@@ -3430,8 +3434,10 @@ components:
version: { type: integer, format: int64 } version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time }
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
LooseboardDetail: LooseboardDetail:
type: object type: object
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源状态只能由实际履约推进。
required: [summary, histories] required: [summary, histories]
properties: properties:
summary: { $ref: '#/components/schemas/LooseboardSummary' } summary: { $ref: '#/components/schemas/LooseboardSummary' }
@@ -3440,6 +3446,34 @@ components:
remark: { type: string, nullable: true } remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true } createdBy: { type: string, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
FleetArrangement:
type: object
nullable: true
description: 公司内部拼车成交后的客户隔离承运摘要。无发布池内部ID、他人货物、司机私人信息或采购成本;agreedFreightAmount是约定运费,不代表已收款。未知数量保持null。
required: [sourceType, businessType, businessOrderNo, bookingNo, tripNo, plannedDepartureAt, originWarehouseName, destinationLabel, deliveryMode, fulfillmentStage, stageOccurredAt, readOnlyReason]
properties:
sourceType: { type: string, enum: [FLEET_CAPACITY_OFFER] }
businessType: { type: string, enum: [LOCAL_LOOSEBOARD, US_TRUCK] }
businessOrderNo: { type: string }
bookingNo: { type: string }
tripNo: { type: string }
plannedDepartureAt: { type: string, format: date-time, description: UTC Instant,明确包含Z,不将数据库UTC墙钟值当成本地时间 }
originWarehouseName: { type: string }
destinationLabel: { type: string }
deliveryMode: { type: string, enum: [SELF_DELIVERY, MANUAL_PICKUP_TO_WAREHOUSE] }
deliveryInstructions: { type: string, nullable: true }
fulfillmentStage: { type: string, enum: [WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, IN_TRANSIT, COMPLETED, CANCELLED] }
stageOccurredAt: { type: string, format: date-time, description: 真实履约阶段时间,按业务运行时区转换为Instant }
agreedFreightAmount: { type: number, nullable: true }
currency: { type: string, nullable: true, enum: [CNY, USD] }
incomingNo: { type: string, nullable: true }
dispatchNo: { type: string, nullable: true }
deliveryNo: { type: string, nullable: true }
signedPackages: { type: integer, minimum: 0, nullable: true }
unsignedPackages: { type: integer, minimum: 0, nullable: true }
returnedPackages: { type: integer, minimum: 0, nullable: true }
exceptionSummary: { type: string, nullable: true }
readOnlyReason: { type: string }
BusinessHistory: BusinessHistory:
type: object type: object
properties: properties:
@@ -3807,7 +3841,7 @@ components:
contactInfo: { type: string, maxLength: 128 } contactInfo: { type: string, maxLength: 128 }
requestedDate: { type: string, format: date } requestedDate: { type: string, format: date }
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
LooseboardStatusChange: LooseboardStatusChange:
type: object type: object
required: [version, status, idempotencyKey] required: [version, status, idempotencyKey]
@@ -40,6 +40,7 @@ class DeliverySourcePublicationPersistenceTest {
volatile Runnable beforeFirstSourceRead = () -> {}; volatile Runnable beforeFirstSourceRead = () -> {};
final Map<String,List<Long>> sourceLocks=new ConcurrentHashMap<>(); final Map<String,List<Long>> sourceLocks=new ConcurrentHashMap<>();
Fixture() { Fixture() {
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))");
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
@@ -46,6 +46,7 @@ class DeliveryDocumentServiceTest {
DeliveryOrder order; DeliveryOrder order;
@BeforeEach void setUp() { @BeforeEach void setUp() {
service = new DeliveryDocumentService(); service = new DeliveryDocumentService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
ReflectionTestUtils.setField(service,"orders",orders); ReflectionTestUtils.setField(service,"orders",orders);
ReflectionTestUtils.setField(service,"outboundOrders",outboundOrders); ReflectionTestUtils.setField(service,"outboundOrders",outboundOrders);
ReflectionTestUtils.setField(service,"revisions",revisions); ReflectionTestUtils.setField(service,"revisions",revisions);
@@ -66,6 +66,7 @@ class DispatchPdaWorkflowServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new DispatchPdaWorkflowService(); service = new DispatchPdaWorkflowService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
objectMapper = new ObjectMapper(); objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orders", orders);
@@ -0,0 +1,180 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.*;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.springframework.transaction.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetPoolOrderBridgeTest {
FleetPoolOrderMapper links=mock(FleetPoolOrderMapper.class);
LocalLooseboardOrderMapper loose=mock(LocalLooseboardOrderMapper.class);
LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class);
UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class);
UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class);
LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class);
DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class);
FleetPoolOrderBridge service;
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
@BeforeEach void setup(){
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence);
when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L));
when(evidence.returns("9",70)).thenReturn(List.of());
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
when(links.lockIncoming("9","WT-7")).thenAnswer(a->row);
when(links.lockDispatch("9","DISPATCH-7")).thenAnswer(a->row==null?List.of():List.of(row));
doAnswer(a->{row=a.getArgument(0);return 1;}).when(links).insert(any(FleetPoolOrderLink.class));
doAnswer(a->{looseOrder=a.getArgument(0);return 1;}).when(loose).insert(any(LocalLooseboardOrder.class));
doAnswer(a->{truckOrder=a.getArgument(0);return 1;}).when(trucks).insert(any(UsTruckOrder.class));
when(loose.lockNo("9","LOCAL-001")).thenAnswer(a->looseOrder);
when(trucks.lockNo("9","LOCAL-001")).thenAnswer(a->truckOrder);
}
ConfirmedBooking input(String type){return new ConfirmedBooking(7,"FB-7","9",type,"92","起运仓","FBA:ONT8","ONT8",
LocalDateTime.of(2030,1,1,12,0),"SELF_DELIVERY","客户自送",null,"货物",4,2,
BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");}
void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");}
FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-7","WT-7","92","FBA:ONT8","READY_FOR_DISPATCH","hash",List.of(
new FleetBookingIncomingGateway.Pallet("101","PALLET-1",2,BigDecimal.TEN,BigDecimal.ONE),
new FleetBookingIncomingGateway.Pallet("102","PALLET-2",2,BigDecimal.TEN,BigDecimal.ONE)),4);}
void dispatch(String type){service.confirm(input(type),"op");incoming();service.receiving("9","WT-7","pda");
service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op");}
DeliveryOrder delivery(int signed){var d=new DeliveryOrder();d.setId(71L);d.setDispatchOrderId(70L);d.setDeliveryGroupKey("CODE:ONT8");d.setCustomerId("9");d.setDispatchNo("DISPATCH-7");d.setDeliveryNo("DELIVERY-7");
d.setStatus("DELIVERED");d.setPackageCount(4);d.setSignedPackages(signed);d.setSignedAt(LocalDateTime.now());
when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",4,(long)signed,d.getSignedAt(),d.getSignedAt().minusHours(1))));
when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null)));
return d;}
@Test void looseboardConfirmationCreatesOnlyCommercialRecordAndReplaysWithoutAnotherOrder(){
var result=service.confirm(input("LOCAL_LOOSEBOARD"),"op");
assertThat(result.businessOrderNo()).isEqualTo("LOCAL-001");assertThat(looseOrder.getLastlegOrderId()).isNull();
assertThat(looseOrder.getBudgetAmount()).isNull();assertThat(row.getIncomingNo()).isNull();
assertThat(looseOrder.getStatus()).isEqualTo("WAITING_INBOUND");
assertThat(service.confirm(input("LOCAL_LOOSEBOARD"),"op")).isEqualTo(result);
verify(loose,times(1)).insert(any(LocalLooseboardOrder.class));verifyNoInteractions(trucks);
assertThatThrownBy(()->service.confirm(input("US_TRUCK"),"op")).hasMessageContaining("不同商业订单");
}
@Test void truckKeepsForecastPhysicalPalletsSeparateFromActualSpaces(){
dispatch("US_TRUCK");
assertThat(truckOrder.getPalletCount()).isEqualTo(2);assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);
assertThat(truckOrder.getAllocatedPalletCount()).isEqualTo(2);assertThat(row.getActualPalletSpaces()).isEqualTo(3);
assertThat(truckOrder.getBudgetAmount()).isNull();assertThat(truckOrder.getStatus()).isEqualTo("PENDING_DISPATCH");
verifyNoInteractions(loose);
}
@Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){
var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op");
assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull();
assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK");
assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE");
assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel());
assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount());
assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm());
assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull();
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");
assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull();
}
@Test void truckInquiryPreservesUnknownContainerWhileZeroActualCountsRemainRealUntilReceipt(){
var source=input("US_TRUCK");service.confirm(source,"op");
assertThat(truckOrder.getContainerType()).isNull();assertThat(truckOrder.getPalletCount()).isEqualTo(source.expectedPalletCount());
assertThat(truckOrder.getReceivedPalletCount()).isZero();assertThat(truckOrder.getAllocatedPalletCount()).isZero();
assertThat(truckOrder.getPackageCount()).isEqualTo(source.packageCount());assertThat(truckOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE");
assertThat(truckOrder.getOriginAddress()).isNull();assertThat(truckOrder.getDestinationAddress()).isNull();assertThat(truckOrder.getBudgetAmount()).isNull();
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");
assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);assertThat(truckOrder.getContainerType()).isNull();
}
@Test void incomingMustMatchCustomerWarehouseAndDestinationAndNeverBindSecondTailOrder(){
service.confirm(input("LOCAL_LOOSEBOARD"),"op");
assertThatThrownBy(()->service.bindIncoming("FB-7","another","WT-7",77,"92","FBA:ONT8","op")).hasMessageContaining("客户");
assertThatThrownBy(()->service.bindIncoming("FB-7","9","WT-7",77,"93","FBA:ONT8","op")).hasMessageContaining("仓库");
assertThatThrownBy(()->service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:LAX9","op")).hasMessageContaining("目的地");
incoming();incoming();assertThat(looseOrder.getLastlegOrderId()).isEqualTo(77L);
assertThatThrownBy(()->service.bindIncoming("FB-7","9","WT-8",78,"92","FBA:ONT8","op")).hasMessageContaining("其他来货");
}
@Test void cancellationOnlyBeforeIncomingAndIsIdempotent(){
service.confirm(input("US_TRUCK"),"op");service.cancelBeforeIncoming(7,"9","op","取消承运");
service.cancelBeforeIncoming(7,"9","op","取消承运");assertThat(truckOrder.getStatus()).isEqualTo("CANCELLED");
verify(truckHistory,times(2)).insert(any(UsTruckOrderStatusHistory.class));
assertThatThrownBy(this::incoming).hasMessageContaining("不能登记来货");
}
@Test void incomingBlocksCancellationAndWrongReceiptIdentityOrDuplicatePhysicalPallets(){
service.confirm(input("US_TRUCK"),"op");incoming();
assertThatThrownBy(()->service.cancelBeforeIncoming(7,"9","op","取消")).hasMessageContaining("已登记来货");
var r=receipt();var wrong=new FleetBookingIncomingGateway.Evidence("OTHER",r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),r.pallets(),4);
assertThatThrownBy(()->service.acceptedReceipt(7,wrong,3,"op")).hasMessageContaining("来源");
var duplicate=new FleetBookingIncomingGateway.Evidence(r.incomingNo(),r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),List.of(r.pallets().get(0),r.pallets().get(0)),4);
assertThatThrownBy(()->service.acceptedReceipt(7,duplicate,3,"op")).hasMessageContaining("重复");
}
@Test void actualDepartureAndFullSignatureDriveLooseboardExactlyOnce(){
dispatch("LOCAL_LOOSEBOARD");
assertThatThrownBy(()->service.departed("9","DISPATCH-7",Map.of(77L,3),"op",LocalDateTime.now())).hasMessageContaining("完整货量");
service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
assertThat(looseOrder.getStatus()).isEqualTo("IN_TRANSIT");
service.signed(delivery(2),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(looseOrder.getStatus()).isEqualTo("IN_TRANSIT");
service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(looseOrder.getStatus()).isEqualTo("COMPLETED");
long version=row.getRecordVersion();service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(row.getRecordVersion()).isEqualTo(version);
}
@Test void truckCompletesOnlyFromItsWholeActualShipment(){
dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
assertThatThrownBy(()->service.signed(delivery(4),Map.of(77L,3L),"op",LocalDateTime.now())).hasMessageContaining("来源数量不完整");
service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED");
long version=row.getRecordVersion();service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());assertThat(row.getRecordVersion()).isEqualTo(version);
}
@Test void truckRequiresActualForecastPalletCountWithoutSubstitutingSpacesOrOne(){
var p=input("US_TRUCK");var missing=new ConfirmedBooking(p.bookingId(),p.bookingNo(),p.customerId(),p.businessType(),p.warehouseId(),
p.warehouseName(),p.destinationKey(),p.destinationLabel(),p.plannedDepartureAt(),p.deliveryMode(),p.deliveryInstructions(),
p.estimatedArrivalAt(),p.cargoName(),p.packageCount(),null,p.weightKg(),p.volumeCbm(),p.currency(),p.contactName(),p.contactPhone(),p.remark());
assertThatThrownBy(()->service.confirm(missing,"op")).hasMessageContaining("独立实托数");
verifyNoInteractions(trucks,loose,links);
}
@Test void anotherCoShippedSourceReturnDoesNotBlockThisFullySignedCommercialOrder(){
dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
var d=delivery(6);d.setPackageCount(8);
when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",8,6L,d.getSignedAt(),d.getSignedAt().minusHours(1))));
when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null),
new SourceDeliveryCompletionPolicy.Pick(2,70,"CODE:ONT8",78,4,2L,null,null,null)));
when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,78,2,d.getSignedAt().minusMinutes(5))));
service.signed(d,Map.of(77L,4L,78L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED");
}
@Test void thisSourceReturnOrMissingHistoryCannotBeCountedAsWholeSignature(){
dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
var d=delivery(2);
when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,77,2,d.getSignedAt().minusMinutes(5))));
service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT");
d=delivery(4);when(evidence.dispatchIds("9",77)).thenReturn(List.of());
service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT");
}
@Test void bindingDoesNotMeanDepartureAndCannotCompleteBeforeActualDeparture(){
dispatch("US_TRUCK");assertThat(truckOrder.getStatus()).isEqualTo("PENDING_DISPATCH");
assertThatThrownBy(()->service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now())).hasMessageContaining("实际出库");
service.unboundDispatch(7,"9","DISPATCH-7","op");assertThat(row.getDispatchNo()).isNull();
assertThat(truckOrder.getStatus()).isEqualTo("WAITING_CONSOLIDATION");
}
@Test void allLifecycleMethodsRequireCallersExistingTransactionAndNoParentServicesAreInjected(){
for(var method:FleetPoolOrderBridge.class.getDeclaredMethods())if(Set.of("confirm","bindIncoming","receiving","acceptedReceipt","boundDispatch","unboundDispatch","departed","signed","cancelBeforeIncoming").contains(method.getName()))
assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY);
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName()))
.noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory"));
}
@Test void poolOnlyCallbacksCannotSucceedWithoutTheirCommercialLink(){
assertThatThrownBy(()->service.acceptedReceipt(7,receipt(),1,"op")).hasMessageContaining("缺少唯一商业订单关联");
assertThatThrownBy(()->service.boundDispatch(7,"9","DISPATCH-7","op")).hasMessageContaining("缺少唯一商业订单关联");
assertThatThrownBy(()->service.unboundDispatch(7,"9","DISPATCH-7","op")).hasMessageContaining("缺少唯一商业订单关联");
assertThatThrownBy(()->service.cancelBeforeIncoming(7,"9","op","cancel")).hasMessageContaining("缺少唯一商业订单关联");
when(links.poolBooking("FB-7")).thenReturn(1);
assertThatThrownBy(this::incoming).hasMessageContaining("缺少唯一商业订单关联");
verifyNoInteractions(loose,trucks);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.fleet.business;
import org.junit.jupiter.api.Test;
import java.util.*;
import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetPoolOrderQueriesTest {
FleetPoolArrangementRow row(){var r=new FleetPoolArrangementRow();r.setCustomerId("9");r.setBusinessType("US_TRUCK");r.setBusinessOrderId(1L);
r.setBusinessOrderNo("LOCAL-1");r.setBookingNo("FB-1");r.setActualPackageCount(4);r.setFulfillmentStage("IN_TRANSIT");return r;}
@Test void batchSummaryIsOneQueryAndRejectsForeignCustomerRows(){
var mapper=mock(FleetPoolOrderMapper.class);var service=new FleetPoolOrderQueries(mapper);
var keys=List.of(new OrderKey("9","US_TRUCK",1),new OrderKey("9","US_TRUCK",2));
when(mapper.arrangements(any())).thenReturn(List.of(row()));
assertThat(service.arrangements(keys)).hasSize(1);verify(mapper,times(1)).arrangements(any());
var foreign=row();foreign.setCustomerId("OTHER");when(mapper.arrangements(any())).thenReturn(List.of(foreign));
assertThatThrownBy(()->service.arrangements(keys)).hasMessageContaining("客户范围");
}
@Test void independentOrdersAndEmptyPagesRemainUnlinked(){
var mapper=mock(FleetPoolOrderMapper.class);var service=new FleetPoolOrderQueries(mapper);
assertThat(service.arrangements(List.of())).isEmpty();verifyNoInteractions(mapper);
assertThat(service.arrangement(new OrderKey("9","US_TRUCK",1))).isNull();
}
@Test void partialReturnsDoNotPretendAllSignedAndUnknownCountsStayUnknown(){
var r=row();r.setDeliveryStatus("DELIVERED");r.setDeliveryPackages(4);r.setSignedPackages(3);r.setReturnedPackages(1);r.setDeliveryReturnedPackages(1);
var result=FleetPoolOrderQueries.project(r);assertThat(result.signedPackages()).isEqualTo(3);assertThat(result.unsignedPackages()).isEqualTo(1);
assertThat(result.returnedPackages()).isEqualTo(1);assertThat(result.exceptionSummary()).isNotBlank();
r.setDeliveryReturnedPackages(0);assertThat(FleetPoolOrderQueries.project(r).signedPackages()).isNull();
r.setActualPackageCount(null);assertThat(FleetPoolOrderQueries.project(r).unsignedPackages()).isNull();
}
@Test void signedOrderRetainsHistoricalSignatureAfterLaterReturn(){
var r=row();r.setFulfillmentStage("COMPLETED");r.setReturnedPackages(1);
var result=FleetPoolOrderQueries.project(r);assertThat(result.signedPackages()).isEqualTo(4);assertThat(result.unsignedPackages()).isZero();
assertThat(result.exceptionSummary()).isNotBlank();
}
@Test void businessAndIncomingOldCommandsFailClosedForPoolSources(){
var mapper=mock(FleetPoolOrderMapper.class);var service=new FleetPoolOrderQueries(mapper);
when(mapper.linked("9","US_TRUCK",1)).thenReturn(1);when(mapper.linkedIncoming("9","WT-1")).thenReturn(1);
assertThatThrownBy(()->service.requireStandalone("9","US_TRUCK",1)).hasMessageContaining("承运安排");
assertThatThrownBy(()->service.requireIndependentIncoming("9","WT-1")).hasMessageContaining("不支持");
}
@Test void publicSummaryHasNoInternalIdsOrPrivateFleetAndCustomerFields(){
var names=Arrays.stream(Arrangement.class.getRecordComponents()).map(c->c.getName()).toList();
assertThat(names).doesNotContain("bookingId","offerId","customerId","driverPhone","purchasePrice","remainingCapacity");
assertThat(names).contains("bookingNo","businessOrderNo","signedPackages","returnedPackages","readOnlyReason");
}
@Test void plannedTripUtcAndLocalBusinessStageAreReturnedAsUnambiguousInstants(){
var r=row();r.setPlannedDepartureAt(java.time.LocalDateTime.of(2030,1,1,8,0));r.setStageOccurredAt(java.time.LocalDateTime.of(2030,1,1,9,0));
var result=FleetPoolOrderQueries.project(r);
assertThat(result.plannedDepartureAt()).isEqualTo(java.time.Instant.parse("2030-01-01T08:00:00Z"));
assertThat(result.stageOccurredAt()).isEqualTo(r.getStageOccurredAt().atZone(java.time.ZoneId.systemDefault()).toInstant());
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.mockito.ArgumentCaptor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class FleetCapacityOfferServiceTest {
private final FleetCapacityOfferMapper offers=mock(FleetCapacityOfferMapper.class);
private final FleetTripMapper trips=mock(FleetTripMapper.class);
private final FleetTripExecutionService execution=mock(FleetTripExecutionService.class);
private final FleetBookingReservations reservations=mock(FleetBookingReservations.class);
private final Instant now=Instant.parse("2026-10-07T01:00:00Z");
private FleetCapacityOfferService service;
@BeforeEach void setup(){
var permissions=Set.of(FleetCapacityOfferService.LIST);
var user=new AuthenticatedUser(101L,0L,0L,101L,"sales","ADMIN","WEB",permissions);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
service=new FleetCapacityOfferService(offers,mock(FleetCapacityOfferCommandMapper.class),trips,mock(FleetBookingMapper.class),mock(FleetBookingService.class),reservations,
execution,mock(FleetCustomerDirectory.class),mock(FleetPoolOrderBridge.class),new ObjectMapper().registerModule(new JavaTimeModule()),Clock.fixed(now,ZoneOffset.UTC));
}
@AfterEach void clear(){SecurityContextHolder.clearContext();}
@Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){
var rows=List.of(offer(1),offer(2));var page=new Page<FleetCapacityOfferEntity>(3,20,2000);page.setRecords(rows);
when(offers.pageOffers(any(),any(),eq(true))).thenReturn(page);
for(var row:rows){var trip=trip(row.getTripNo());when(trips.findByNo(row.getTripNo())).thenReturn(trip);
when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("ONT8",null,"FBA:ONT8")));
when(execution.capacityPlan(row.getTripNo())).thenReturn(new FleetExecutionModels.Planning(row.getTripNo(),1L,List.of(),"hash",null,false,"计划待核对",List.of()));}
var query=new FleetCapacityOfferModels.Query(3,20,"FO",null,null,null,null,null,null,null);
var result=service.page(query);
assertThat(result.total()).isEqualTo(2000);assertThat(result.page()).isEqualTo(3);assertThat(result.records()).hasSize(2);
var captor=ArgumentCaptor.forClass(Page.class);verify(offers).pageOffers(captor.capture(),same(query),eq(true));
assertThat(captor.getValue().getCurrent()).isEqualTo(3);assertThat(captor.getValue().getSize()).isEqualTo(20);
verify(execution,times(2)).capacityPlan(anyString());verify(trips,times(2)).findByNo(anyString());verifyNoMoreInteractions(execution,trips);
}
@Test void broadDynamicAvailabilityFilterRejectsBeforeAnyPerTripCapacityQuery(){
when(offers.pageOffers(any(),any(),eq(true))).thenReturn(new Page<>(1,100,101));
assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null))).hasMessageContaining("100");
verifyNoInteractions(execution,trips);
}
@Test void cutoffUsesStrictServerInstantAndRechecksChangedDeparture(){
var trip=trip("FT-A");var offer=offer(1);offer.setStatus("OPEN");
assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).isNull();
assertThat(FleetCapacityOfferService.openBlock(offer,trip,offer.getCutoffAt().toInstant(ZoneOffset.UTC))).contains("截止");
trip.setPlannedDepartureAt(offer.getCutoffAt().minusMinutes(1));
assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).contains("截止");
trip.setStatus("IN_TRANSIT");assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).contains("已发车");
}
@Test void destinationCapacityUsesIndependentMinimumForEveryDimension(){
var a=new TripCapacityPolicy.Load(12,new BigDecimal("600"),new BigDecimal("8"));
var b=new TripCapacityPolicy.Load(4,new BigDecimal("1000"),new BigDecimal("3"));
var result=FleetCapacityOfferService.min(a,b);assertThat(result.palletSpaces()).isEqualTo(4);
assertThat(result.weightKg()).isEqualByComparingTo("600");assertThat(result.volumeCbm()).isEqualByComparingTo("3");
}
private FleetCapacityOfferEntity offer(long id){var row=new FleetCapacityOfferEntity();row.setId(id);row.setOfferNo("FO-"+id);row.setTripNo("FT-"+id);row.setTripId(id);row.setStatus("OPEN");row.setRecordVersion(0L);row.setCutoffAt(LocalDateTime.ofInstant(now.plusSeconds(1800),ZoneOffset.UTC));return row;}
private FleetTripEntity trip(String number){var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo(number);trip.setStatus("PLANNED");trip.setWarehouseId(92L);trip.setWarehouseName("起点");trip.setRecordVersion(1L);trip.setPlannedDepartureAt(LocalDateTime.ofInstant(now.plusSeconds(3600),ZoneOffset.UTC));return trip;}
}
@@ -57,6 +57,7 @@ class FleetTripAuthorizationTest {
} }
@Configuration @EnableMethodSecurity @Configuration @EnableMethodSecurity
static class Config { static class Config {
@Bean com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders() { return mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class); }
@Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); } @Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); }
@Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); } @Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); }
@Bean PlatformFleetBookingController booking(FleetBookingService service, com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers) { return new PlatformFleetBookingController(service,customers); } @Bean PlatformFleetBookingController booking(FleetBookingService service, com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers) { return new PlatformFleetBookingController(service,customers); }
@@ -64,6 +64,8 @@ class FleetTripPersistenceTest {
private FleetTripExecutionService execution; private FleetTripExecutionService execution;
private FleetTripCompletionService completion; private FleetTripCompletionService completion;
private FleetBookingService bookingService; private FleetBookingService bookingService;
private FleetCapacityOfferService capacityOffers;
private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders;
private FleetBookingIncomingGateway incoming; private FleetBookingIncomingGateway incoming;
private FleetBookingQuoteContext quoteContext; private FleetBookingQuoteContext quoteContext;
private com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService quoteCandidates; private com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService quoteCandidates;
@@ -87,7 +89,7 @@ class FleetTripPersistenceTest {
jdbc = new JdbcTemplate(dataSource); transactions = new DataSourceTransactionManager(dataSource); jdbc = new JdbcTemplate(dataSource); transactions = new DataSourceTransactionManager(dataSource);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history",
"ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order", "ll_fleet_capacity_offer", "ll_fleet_capacity_offer_command", "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order",
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation", "ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation",
"ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) { "ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
@@ -96,7 +98,7 @@ class FleetTripPersistenceTest {
} }
var configuration = new MybatisConfiguration(); var configuration = new MybatisConfiguration();
for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class, for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class,
FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class, FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, FleetCapacityOfferMapper.class, FleetCapacityOfferCommandMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class,
DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper); DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper);
var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor(); var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
@@ -115,6 +117,10 @@ class FleetTripPersistenceTest {
vehicleService = transactional(new VehicleApplicationService(vehicles, new FleetVehicleAssignmentGuard(resources)), VehicleApplicationService.class); vehicleService = transactional(new VehicleApplicationService(vehicles, new FleetVehicleAssignmentGuard(resources)), VehicleApplicationService.class);
var bookingMapper = session.getMapper(FleetBookingMapper.class); var bookingMapper = session.getMapper(FleetBookingMapper.class);
var reservations = new FleetBookingReservations(bookingMapper, json); var reservations = new FleetBookingReservations(bookingMapper, json);
poolOrders=mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class);
when(poolOrders.businessOrderNumbers(anyCollection())).thenReturn(java.util.Map.of());
ReflectionTestUtils.setField(reservations,"offers",session.getMapper(FleetCapacityOfferMapper.class));
ReflectionTestUtils.setField(reservations,"poolOrders",poolOrders);
var bindingMapper = session.getMapper(FleetTripDispatchMapper.class); var bindingMapper = session.getMapper(FleetTripDispatchMapper.class);
bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class); bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class);
var workflow = new DispatchPdaWorkflowService(); var workflow = new DispatchPdaWorkflowService();
@@ -128,6 +134,7 @@ class FleetTripPersistenceTest {
ReflectionTestUtils.setField(workflow, "fleetBindings", bindingGuard); ReflectionTestUtils.setField(workflow, "fleetBindings", bindingGuard);
ReflectionTestUtils.setField(workflow, "objectMapper", json); ReflectionTestUtils.setField(workflow, "objectMapper", json);
ReflectionTestUtils.setField(workflow, "pickAttribution", mock(DispatchPickOrderAttributionService.class)); ReflectionTestUtils.setField(workflow, "pickAttribution", mock(DispatchPickOrderAttributionService.class));
ReflectionTestUtils.setField(workflow,"fleetPool",poolOrders);
var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class), var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class),
session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class), session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class),
transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class);
@@ -141,9 +148,111 @@ class FleetTripPersistenceTest {
quoteContext=mock(FleetBookingQuoteContext.class); quoteContext=mock(FleetBookingQuoteContext.class);
quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class); quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class);
bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class); bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class);
ReflectionTestUtils.setField(bookingService,"poolOrders",poolOrders);
capacityOffers=transactional(new FleetCapacityOfferService(session.getMapper(FleetCapacityOfferMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),
session.getMapper(FleetTripMapper.class),bookingMapper,bookingService,reservations,execution,customerDirectory,poolOrders,json),FleetCapacityOfferService.class);
completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class); completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class);
} }
@Test void poolSalesAreIsolatedByUserIdAndCannotConfirmOrReadAllInquiries() {
var offer=publishedOffer();poolLogin(101,false);
var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"owner-first"));
poolLogin(102,false);
assertThatThrownBy(()->capacityOffers.inquiry(first.inquiry().bookingNo())).hasMessageContaining("无权");
assertThatThrownBy(()->capacityOffers.inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,null,null,"ALL"))).hasMessageContaining("无权");
assertThatThrownBy(()->capacityOffers.confirm(first.inquiry().bookingNo(),poolCommand(first.inquiry(),"sales-confirm"))).hasMessageContaining("无权");
poolLogin(101,false);
assertThat(capacityOffers.inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,null,null,"MINE")).records()).extracting(FleetCapacityOfferModels.Inquiry::bookingNo).containsExactly(first.inquiry().bookingNo());
assertThat(jdbc.queryForObject("SELECT sales_owner_user_id FROM ll_fleet_external_booking WHERE booking_no=?",String.class,first.inquiry().bookingNo())).isEqualTo("101");
}
@Test void poolConfirmSerializesLastSpaceAndOriginalSuccessfulCommandReplaysAfterClose() throws Exception {
var offer=publishedOffer();poolLogin(101,false);
var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"pool-first"));
poolLogin(102,false);
var second=capacityOffers.createInquiry(offer.offerNo(),poolDraft(first.offer(),8,"pool-second"));
poolLogin(1,true);
var firstCurrent=capacityOffers.inquiry(first.inquiry().bookingNo());
var secondCurrent=capacityOffers.inquiry(second.inquiry().bookingNo());
var firstRequest=poolCommand(firstCurrent,"pool-confirm-first");var secondRequest=poolCommand(secondCurrent,"pool-confirm-second");
var latch=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
java.util.concurrent.Callable<Boolean> a=()->poolConfirmAttempt(latch,firstCurrent.bookingNo(),firstRequest);
java.util.concurrent.Callable<Boolean> b=()->poolConfirmAttempt(latch,secondCurrent.bookingNo(),secondRequest);
try {var fa=workers.submit(a);var fb=workers.submit(b);latch.countDown();assertThat(List.of(fa.get(15,TimeUnit.SECONDS),fb.get(15,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);}
finally {workers.shutdownNow();}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_external_booking WHERE status='CONFIRMED'",Integer.class)).isEqualTo(1);
var current=capacityOffers.detail(offer.offerNo()).offer();assertThat(current.destinations().get(0).remaining().palletSpaces()).isEqualTo(4);
String winner=jdbc.queryForObject("SELECT booking_no FROM ll_fleet_external_booking WHERE status='CONFIRMED'",String.class);
String loser=winner.equals(firstCurrent.bookingNo())?secondCurrent.bookingNo():firstCurrent.bookingNo();
var loserRow=capacityOffers.inquiry(loser);
assertThatThrownBy(()->capacityOffers.confirm(loser,poolCommand(loserRow,"retry-rejected-space"))).hasMessageContaining("超过车辆");
capacityOffers.command(offer.offerNo(),"CLOSE",new FleetCapacityOfferModels.Command(current.tripRecordVersion(),current.recordVersion(),"pool-close-test",null));
var original=winner.equals(firstCurrent.bookingNo())?firstRequest:secondRequest;
assertThat(capacityOffers.confirm(winner,original).replayed()).isTrue();
assertThatThrownBy(()->capacityOffers.confirm(winner,new FleetCapacityOfferModels.InquiryCommand(original.tripRecordVersion(),original.offerRecordVersion(),original.recordVersion(),original.idempotencyKey(),"changed"))).hasMessageContaining("不能更换");
verify(poolOrders,times(1)).confirm(any(),eq("1"));
poolLogin(winner.equals(firstCurrent.bookingNo())?101:102,false);
assertThat(capacityOffers.inquiry(winner).status()).isEqualTo("CONFIRMED");
}
@Test void poolCommercialFailureRollsBackReservationAndOldBookingEndpointCannotBypassPool() {
var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"bridge-rollback-create"));
doThrow(new IllegalStateException("bridge unavailable")).when(poolOrders).confirm(any(),anyString());
assertThatThrownBy(()->capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"bridge-rollback-command"))).hasMessageContaining("bridge unavailable");
assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).status()).isEqualTo("DRAFT");
assertThat(capacityOffers.detail(offer.offerNo()).offer().destinations().get(0).remaining().palletSpaces()).isEqualTo(12);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_capacity_offer_command WHERE action='CONFIRM'",Integer.class)).isZero();
assertThatThrownBy(()->bookingService.confirm(offer.tripNo(),draft.inquiry().bookingNo(),"1",new FleetBookingModels.Command(draft.inquiry().tripRecordVersion(),0L,"old-pool-bypass",null))).hasMessageContaining("需求池");
}
@Test void liveVehicleChangesBlockCommitButDoNotFabricateDraftQuantityOrCapacity() {
var offer=publishedOffer();var currentTrip=service.detail(offer.tripNo()).trip();
jdbc.update("UPDATE ll_vehicle SET max_pallet_spaces=20 WHERE id=?",Long.parseLong(currentTrip.vehicleId()));
var current=capacityOffers.detail(offer.offerNo()).offer();
assertThat(current.capacityKnown()).isFalse();assertThat(current.canConfirm()).isFalse();assertThat(current.canCreateInquiry()).isTrue();
assertThat(current.destinations()).allMatch(row->row.remaining()==null);
var unknown=new FleetBookingModels.Cargo("FBA:ONT8","待核货物",null,null,null,null,"PALLET",null,"USD",null,null,null);
var draft=capacityOffers.createInquiry(offer.offerNo(),new FleetCapacityOfferModels.InquiryWrite(current.tripRecordVersion(),current.recordVersion(),null,9L,"US_TRUCK","SELF_DELIVERY",null,null,unknown,null,"unknown-pool-cargo"));
assertThat(draft.inquiry().cargo().packageCount()).isNull();assertThat(draft.inquiry().expectedPalletCount()).isNull();
assertThatThrownBy(()->capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"changed-vehicle-confirm"))).hasMessageContaining("车辆容量");
}
@Test void poolCancellationReleasesOnlyUnstartedBookingAndPreservesActualIncoming(){
var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"cancel-pool-draft"));
var confirmed=capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"cancel-pool-confirm"));
var cancelled=capacityOffers.cancel(confirmed.inquiry().bookingNo(),poolCommand(confirmed.inquiry(),"cancel-pool-safe"));
assertThat(cancelled.inquiry().status()).isEqualTo("CANCELLED");
assertThat(cancelled.offer().destinations().get(0).remaining().palletSpaces()).isEqualTo(12);
verify(poolOrders).cancelBeforeIncoming(anyLong(),eq("9"),eq("1"),isNull());
var next=capacityOffers.createInquiry(offer.offerNo(),poolDraft(cancelled.offer(),8,"cancel-incoming-draft"));
var reserved=capacityOffers.confirm(next.inquiry().bookingNo(),poolCommand(next.inquiry(),"cancel-incoming-confirm"));
jdbc.update("UPDATE ll_fleet_external_booking SET incoming_no='WT-STARTED' WHERE booking_no=?",reserved.inquiry().bookingNo());
assertThatThrownBy(()->capacityOffers.cancel(reserved.inquiry().bookingNo(),poolCommand(reserved.inquiry(),"cancel-incoming-refused"))).hasMessageContaining("已登记来货");
assertThat(jdbc.queryForObject("SELECT incoming_no FROM ll_fleet_external_booking WHERE booking_no=?",String.class,reserved.inquiry().bookingNo())).isEqualTo("WT-STARTED");
verify(poolOrders,times(1)).cancelBeforeIncoming(anyLong(),anyString(),anyString(),any());
}
private boolean poolConfirmAttempt(CountDownLatch latch,String booking,FleetCapacityOfferModels.InquiryCommand request) throws Exception {
poolLogin(1,true);try {latch.await(5,TimeUnit.SECONDS);capacityOffers.confirm(booking,request);return true;}catch(ResponseStatusException conflict){return false;}finally{SecurityContextHolder.clearContext();}
}
private FleetCapacityOfferModels.Offer publishedOffer(){
poolLogin(1,true);String trip=readyTrip();var plan=execution.capacityPlan(trip);
var measured=new FleetExecutionModels.Measurement("901",18,new BigDecimal("100"),new BigDecimal("1"),1);
var confirmed=execution.confirmCapacityPlan(trip,"1",new FleetExecutionModels.Confirm(plan.recordVersion(),plan.manifestHash(),List.of(measured),"自有18托位"));
var created=capacityOffers.create(new FleetCapacityOfferModels.Write(trip,confirmed.recordVersion(),null,departure.minusSeconds(60),"调度","10086",null,"offer-create-test"));
return capacityOffers.command(created.offer().offerNo(),"PUBLISH",new FleetCapacityOfferModels.Command(confirmed.recordVersion(),0L,"offer-publish-test",null)).offer();
}
private FleetCapacityOfferModels.InquiryWrite poolDraft(FleetCapacityOfferModels.Offer offer,int spaces,String key){
var cargo=new FleetBookingModels.Cargo("FBA:ONT8","外接货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD","验收联系人","10086",null);
return new FleetCapacityOfferModels.InquiryWrite(offer.tripRecordVersion(),offer.recordVersion(),null,9L,"LOCAL_LOOSEBOARD","SELF_DELIVERY",departure.minusSeconds(120),2,cargo,null,key);
}
private static FleetCapacityOfferModels.InquiryCommand poolCommand(FleetCapacityOfferModels.Inquiry row,String key){return new FleetCapacityOfferModels.InquiryCommand(row.tripRecordVersion(),row.offerRecordVersion(),row.recordVersion(),key,null);}
private static void poolLogin(long id,boolean manager){
var permissions=manager?java.util.Set.of(FleetCapacityOfferService.MANAGE):java.util.Set.of(FleetCapacityOfferService.LIST,"platform:fleet:capacity-offer:inquiry-create","platform:fleet:capacity-offer:inquiry-edit","platform:fleet:capacity-offer:inquiry-cancel");
var user=new com.baiyue.common.security.AuthenticatedUser(id,0L,0L,id,"same-display-name","ADMIN","WEB",permissions);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"unused",permissions.stream().map(SimpleGrantedAuthority::new).toList()));
}
@Test void creationReplayUsesSameBusinessNumberAndRejectsChangedPayload() { @Test void creationReplayUsesSameBusinessNumberAndRejectsChangedPayload() {
var request = createRequest("creation-one"); var request = createRequest("creation-one");
var first = service.create("1", request); var first = service.create("1", request);
@@ -43,4 +43,20 @@ class LocalLooseboardSchemaContractTest {
int end = sql.indexOf(";", start); int end = sql.indexOf(";", start);
return start < 0 || end < 0 ? "" : sql.substring(start, end); return start < 0 || end < 0 ? "" : sql.substring(start, end);
} }
@Test
void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception {
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : java.util.List.of(baseline, unified)) {
String block = tableBlock(sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE"), "ll_local_looseboard_order").toLowerCase();
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Unknown container must remain null when MyBatis omits its insert column");
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*pallet_required\\s+varchar\\(8\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Forecast pallets must not invent YES or NO palletizing service");
}
}
} }
@@ -53,6 +53,7 @@ class LocalLooseboardServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new LocalLooseboardService(); service = new LocalLooseboardService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "operations", operations);
@@ -15,7 +15,9 @@ import static org.assertj.core.api.Assertions.*;
class FleetIncomingAdapterTest { class FleetIncomingAdapterTest {
@Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() { @Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() {
var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper()); var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper());
org.springframework.test.util.ReflectionTestUtils.setField(adapter,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
var detail=mock(WarehouseTransferResponses.Detail.class); var detail=mock(WarehouseTransferResponses.Detail.class);
when(detail.lastlegOrderId()).thenReturn(71L);
when(detail.transferNo()).thenReturn("WT-1");when(detail.inboundCode()).thenReturn("WT-1");when(detail.operatingWarehouseId()).thenReturn("92");when(detail.fbaWarehouseCode()).thenReturn("ONT8"); when(detail.transferNo()).thenReturn("WT-1");when(detail.inboundCode()).thenReturn("WT-1");when(detail.operatingWarehouseId()).thenReturn("92");when(detail.fbaWarehouseCode()).thenReturn("ONT8");
when(detail.status()).thenReturn("WAIT_PDA_RECEIVING");when(detail.pallets()).thenReturn(List.of()); when(detail.status()).thenReturn("WAIT_PDA_RECEIVING");when(detail.pallets()).thenReturn(List.of());
when(service.createAndSubmit(eq("9"),eq("1"),any())).thenReturn(detail);when(service.review(eq("9"),eq("WT-1"),eq("1"),any())).thenReturn(detail); when(service.createAndSubmit(eq("9"),eq("1"),any())).thenReturn(detail);when(service.review(eq("9"),eq("WT-1"),eq("1"),any())).thenReturn(detail);
@@ -190,6 +190,8 @@ class WarehouseTransferServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new WarehouseTransferService(); service = new WarehouseTransferService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPoolQueries", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "receiptCoordinator", receiptCoordinator); ReflectionTestUtils.setField(service, "receiptCoordinator", receiptCoordinator);
lenient().when(receiptCoordinator.receive(anyString(), anyString(), anyString(), any(), any(), any())) lenient().when(receiptCoordinator.receive(anyString(), anyString(), anyString(), any(), any(), any()))
.thenAnswer(call -> { .thenAnswer(call -> {
@@ -48,4 +48,19 @@ class UsTruckOrderSchemaContractTest {
int end = sql.indexOf(';', start); int end = sql.indexOf(';', start);
return start < 0 || end < 0 ? "" : sql.substring(start, end); return start < 0 || end < 0 ? "" : sql.substring(start, end);
} }
@Test
void poolUnknownContainerSurvivesOmittedNullInsertColumnInBothBaselines() throws Exception {
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : java.util.List.of(baseline, unified)) {
String normalized = sql.toLowerCase().replace("create table if not exists", "create table");
String block = tableBlock(normalized, "ll_us_truck_order");
assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(),
"Unknown container must remain null when MyBatis omits its insert column");
}
}
} }
@@ -53,6 +53,7 @@ class UsTruckOrderServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new UsTruckOrderService(); service = new UsTruckOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "pallets", pallets); ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "histories", histories);
+6
View File
@@ -1,5 +1,11 @@
# 后端脚本管理与全量 HTTP 验收索引 # 后端脚本管理与全量 HTTP 验收索引
拼车主入口 `fleet-capacity-pool-test-env.mjs` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。
`fleet-capacity-pool-ui-fixture-test-env.mjs` 仅准备内部拼车池TEST页面验收数据,默认 `--plan` 零网络;实际命令沿 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,另必须显式传 `--private-credentials-file <用户.codex/env/chongchong-global目录直接子级的独占JSON绝对路径>`。通过明确TEST环境文件读取CHONGCHONG_ADMIN及Jenkins只读取证配置,创建两客户真实来货和计划车次、OPEN发布、两销售各12条不占位草稿及最小调度账号;停在可交互状态,不确认承运、不拣货发车、不声称全量验收。密码只在首次写入前独占创建的私有文件中保存,报告 `reports/fleet-capacity-pool-ui-<新批次>/` 不含密码/Token;失败保留现场和原请求,不自动重跑。退出脚本会话并停用自有fixture PDA,保留UI账号供浏览器验收;不部署、不写业务SQL、不删除业务样本。离线安全与作用域合同为 `fleet-capacity-pool-ui-fixture-test-env.test.mjs`。
`fleet-capacity-pool-test-env.mjs` 是内部拼车需求池 TEST 专项入口,默认 `--plan` 零网络;实际执行须显式 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,凭据由命令前的 `node --env-file=<明确TEST配置>` 提供,配置同时包含 Jenkins 只读取证凭据与 `FBA_CORE_JENKINS_AGENT`。创建隔离客户、销售、调度、预订及商业订单,验证容量争抢、销售隔离、命令重放、两类本土订单实际到仓/PDA收货/共享车次/出库签收和费用唯一;不部署、不写业务SQL。证据保存在 `reports/fleet-cross-<新批次>/`,包含 `pool-scope.json` 和写请求journal;未知结果停止,保留原样本与请求,不覆盖批次、不自动重造数据。结束退出独立会话并沿fixture清理其自有PDA访问,业务样本保留;页面及未覆盖矩阵单独报告,不将专项通过当成全量验收。离线保护由 `fleet-capacity-pool-test-env.test.mjs` 验证。
`inbound-plan-resource-fault-executor.mjs` 是单柜入仓计划资源确认故障的 TEST 环境执行入口,内部使用 `inbound-plan-resource-fault-runtime.py`。默认 `plan --plan <单柜计划.json>` 零网络;实际动作必须读取明确的TEST/Jenkins外部配置并传 `--confirm-test-runtime yes`。`preflight --routing <私有路由.json>` 只读校验Lastleg与Warehouse Overseas镜像/指纹、实际网络和Nacos common/lastleg配置摘要;`prepare`另要求 `--expected-image <已冻结镜像ID>` 及预检摘要。`activate`只临时改Lastleg的 `baiyue.warehouse.base-url`,配合 `mode --mode CONFIRM_ONCE --ttl 60` 精确注入一次confirm失败;业务转换和关闭重开页面由调用者独立操作,工具没有造数、SQL或业务写接口。`status/health/records`用于观测,结果未知先 `status/reconcile`,禁止因观察超时重复创建容器;`restore/verify-restored/finish`须按原镜像、完整配置/挂载/网络、Nacos及上游健康恢复后才停自有代理、清私有凭据。服务变更先记录意图,受限远端恢复状态目录为 `/tmp/chongchong-inbound-resource-<marker>`,由总控持独占Lastleg故障窗口;调用结果和业务证据另存本次独立 `reports/` 目录,失败保留、不覆盖,不与部署或其他共享业务写入并行。`inbound-plan-resource-fault-executor.test.mjs`、`inbound-plan-resource-fault-runtime.test.mjs` 为离线边界及恢复测试,不证明真实故障已跑通。 `inbound-plan-resource-fault-executor.mjs` 是单柜入仓计划资源确认故障的 TEST 环境执行入口,内部使用 `inbound-plan-resource-fault-runtime.py`。默认 `plan --plan <单柜计划.json>` 零网络;实际动作必须读取明确的TEST/Jenkins外部配置并传 `--confirm-test-runtime yes`。`preflight --routing <私有路由.json>` 只读校验Lastleg与Warehouse Overseas镜像/指纹、实际网络和Nacos common/lastleg配置摘要;`prepare`另要求 `--expected-image <已冻结镜像ID>` 及预检摘要。`activate`只临时改Lastleg的 `baiyue.warehouse.base-url`,配合 `mode --mode CONFIRM_ONCE --ttl 60` 精确注入一次confirm失败;业务转换和关闭重开页面由调用者独立操作,工具没有造数、SQL或业务写接口。`status/health/records`用于观测,结果未知先 `status/reconcile`,禁止因观察超时重复创建容器;`restore/verify-restored/finish`须按原镜像、完整配置/挂载/网络、Nacos及上游健康恢复后才停自有代理、清私有凭据。服务变更先记录意图,受限远端恢复状态目录为 `/tmp/chongchong-inbound-resource-<marker>`,由总控持独占Lastleg故障窗口;调用结果和业务证据另存本次独立 `reports/` 目录,失败保留、不覆盖,不与部署或其他共享业务写入并行。`inbound-plan-resource-fault-executor.test.mjs`、`inbound-plan-resource-fault-runtime.test.mjs` 为离线边界及恢复测试,不证明真实故障已跑通。
F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`:只有完整逐箱作业事实后才可绑定实物托盘,不用默认数量补齐缺失事实。已明确拒绝的绑定仅允许当前已冻结原票、原批次、原命令摘要的 `F33_BIND_A0409_AFTER_UNIT` 恢复,必须同时核对不可变失败journal、完整回滚证明和新作业事实/原资源版本,恢复写入独立journal,不覆盖原失败。未知结果、其他拒绝和其他阶段仍禁止自动重试;响应message脱敏留存。该受限恢复只用于原TEST验收,不放宽生产接口或变更库存规则。 F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`:只有完整逐箱作业事实后才可绑定实物托盘,不用默认数量补齐缺失事实。已明确拒绝的绑定仅允许当前已冻结原票、原批次、原命令摘要的 `F33_BIND_A0409_AFTER_UNIT` 恢复,必须同时核对不可变失败journal、完整回滚证明和新作业事实/原资源版本,恢复写入独立journal,不覆盖原失败。未知结果、其他拒绝和其他阶段仍禁止自动重试;响应message脱敏留存。该受限恢复只用于原TEST验收,不放宽生产接口或变更库存规则。
+12
View File
@@ -1,5 +1,17 @@
# Admin 测试环境 HTTP 模块 # Admin 测试环境 HTTP 模块
`fleet-capacity-pool-schema-contract.test.mjs` 是离线数据库合同:验证发布与持久命令唯一约束、唯一商业履约来源、可空实际数量、服务与统一基线一致、非破坏性结构补齐及独立销售/调度权限;不访问环境、不证明真实履约已通过。
## 当前新增脚本的执行边界
- 人工状态矩阵的 `FEE_CONFIRMING` 使用独立新订单,在测量提交成功后暂停审核,经正式 `fees/calculate` 生成费用;回查同一费用版本、入库客户身份、测量仍待审核及入仓码精确筛选结果。该状态是必验项,不再列入不可造数缺口;不发送费用、不推进该样本、不借历史数据。入口仍为 `--modules manual-state-matrix`,对应 `order-inbound-status-fixtures-contract.test.mjs` 和 `manual-state-matrix-contract.test.mjs`。
- `WAIT_PRICING` 另建保留原始 `APPROVED` 的订单,先形成未发布的 `FEE_CONFIRMING`,再通过客户实时渠道目录与报价选择同仓、同目的仓、同税制的不同渠道,以正式申报更新触发费用失效;回查 `WAIT_PRICING/UNPRICED`、新渠道及精确筛选。缺少可计价替换渠道、客户漂移或状态未收敛均失败;不写业务SQL,退出专用目录查询会话。该状态同样为必验项,原先借历史 `WAIT_SPLIT` 样本的兜底已移除。
- `grouped-dispatch-acceptance.mjs` 由默认 `phase4-overseas-dispatch` 在主链预排前调用。使用本轮独立未拣托盘,通过 `/drafts/pre-arrange` 验证错组拒绝、原草稿不变、每组独立排车、同命令重放及数量/来源;成功响应明确的本轮未拣明细通过正式 API 移除并释放锁,保留空对照单据。写请求超时或 5xx 保留资源,不自动重试或清理未知结果。对应 `grouped-dispatch-acceptance-contract.test.mjs`。
- `fleet-trip-finance-acceptance.mjs` 由默认 `phase5-overseas-fulfillment` 结案后调用。只读本轮实际发车车次及已冻结派送来源,核对 AVAILABLE/BILLED/SETTLED 来源、原币应收、明确归属实收与单据,未分配实收保持未知(null 或 NON_NULL 响应省略);前后车次、资源、审核不得变化。失败保留 CHECKING 证据,不创建账单、收款或财务分配。对应 `fleet-trip-finance-acceptance-contract.test.mjs` 的部分/全额付款和受限权限反例不代替真实样本;默认链没有独立实付样本,不能据此宣称付款生命周期或真实权限边界已经验收。
两者都是内部模块,没有独立 CLI;沿 Admin 原入口的 TEST 配置、统一会话、JSON 报告与权限执行。依赖缺失、版本未部署或前置链失败时明确失败,不借历史样本或生成模拟成功。源码准备与实际执行分开记录;离线合同不能代替真实 HTTP。开始验收后,先跑新增合同及脚本文档门禁,再跑相应依赖链和全量;当前执行结果仅维护在测试环境部署主契约及其原始报告中。
`fleet-handover-acceptance.mjs` 是车辆专项实际DEPART后的内部交接助手,由`../fleet-test-env-acceptance.mjs`调用;仅接受本轮已验证车次/派送及自有PDA会话,核对原DEPART/费用不变、真实剩余货物和接替资源、同键重放及旧资源分别释放。调用方必须注入发送前持久化检查点,落盘失败不发送HTTP;仅保存脱敏原命令及附件摘要。签收前导出本轮`handover-fixture.json`供页面/运营统计检查,成本验证与账号清理由同一入口编排;失败保留证据,不借旧车次或自动续跑。对应`fleet-handover-acceptance-contract.test.mjs`为离线合同。 `fleet-handover-acceptance.mjs` 是车辆专项实际DEPART后的内部交接助手,由`../fleet-test-env-acceptance.mjs`调用;仅接受本轮已验证车次/派送及自有PDA会话,核对原DEPART/费用不变、真实剩余货物和接替资源、同键重放及旧资源分别释放。调用方必须注入发送前持久化检查点,落盘失败不发送HTTP;仅保存脱敏原命令及附件摘要。签收前导出本轮`handover-fixture.json`供页面/运营统计检查,成本验证与账号清理由同一入口编排;失败保留证据,不借旧车次或自动续跑。对应`fleet-handover-acceptance-contract.test.mjs`为离线合同。
`fleet-quotes-operations.mjs` 的`runQuoteSession`使用独立TEST登录及本轮客户准备,`run`只验证报价增改/状态/历史及原子CSV;`verifyOperations`另需当前轮DEPART的独立托位/货量/原币成本期望,报价通过不代表运营统计通过。后者默认只读,实测更正必须显式传入写入开关和独立幂等命名空间;不推算财务实付。由专项编排器提供持久化检查点和独占报告目录,原请求未知时不自动重造样本,结束退出自身会话。对应`fleet-quotes-operations-contract.test.mjs`不访问真实环境。 `fleet-quotes-operations.mjs` 的`runQuoteSession`使用独立TEST登录及本轮客户准备,`run`只验证报价增改/状态/历史及原子CSV;`verifyOperations`另需当前轮DEPART的独立托位/货量/原币成本期望,报价通过不代表运营统计通过。后者默认只读,实测更正必须显式传入写入开关和独立幂等命名空间;不推算财务实付。由专项编排器提供持久化检查点和独占报告目录,原请求未知时不自动重造样本,结束退出自身会话。对应`fleet-quotes-operations-contract.test.mjs`不访问真实环境。
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { readInitialization, table, seedRows } from '../sql-baseline-contract-helpers.mjs'
const overseas = readInitialization('OVERSEAS')
const cn = readInitialization()
const read = relative => readFileSync(new URL(`../../${relative}`, import.meta.url), 'utf8')
test('pool has a unique publication per trip and full durable command identity, no second capacity balance', () => {
const offer = table(overseas, 'baiyue_lastleg', 'll_fleet_capacity_offer')
assert.match(offer, /UNIQUE KEY\s+\w+\(trip_id\)/i)
assert.match(offer, /UNIQUE KEY\s+\w+\(offer_no\)/i)
assert.match(offer, /published_fingerprint CHAR\(64\) NULL/i)
assert.doesNotMatch(offer, /remaining_pallet|available_pallet|reserved_pallet/i)
const command = table(overseas, 'baiyue_lastleg', 'll_fleet_capacity_offer_command')
assert.match(command, /UNIQUE KEY\s+\w+\(trip_id,operator_id,action,scope_no,idempotency_key\)/i)
assert.match(command, /request_hash CHAR\(64\) NOT NULL/i)
assert.match(command, /result_json JSON NOT NULL/i)
})
test('one booking maps to exactly one commercial order and one actual incoming source', () => {
const link = table(overseas, 'baiyue_lastleg', 'll_fleet_pool_order_link')
for (const columns of ['booking_id', 'booking_no', 'business_type,business_order_id', 'incoming_no', 'lastleg_order_id']) {
assert.match(link, new RegExp(`UNIQUE KEY\\s+\\w+\\(${columns}\\)`, 'i'))
}
assert.match(link, /incoming_no VARCHAR\(64\) NULL/i)
assert.match(link, /lastleg_order_id BIGINT NULL/i)
assert.match(link, /actual_pallet_count INT NULL/i)
assert.match(link, /actual_pallet_spaces INT NULL/i)
assert.doesNotMatch(link, /(?:agreed_amount|receivable_amount|paid_amount)\s+DECIMAL/i)
const booking = table(overseas, 'baiyue_lastleg', 'll_fleet_external_booking')
assert.match(booking, /sales_owner_user_id VARCHAR\(64\) NULL/i)
assert.match(booking, /expected_pallet_count INT NULL/i)
})
test('module and unified fresh baselines contain the exact same new table definitions', () => {
const module = read('lastleg-service/src/main/resources/db/lastleg.sql')
for (const name of ['ll_fleet_capacity_offer', 'll_fleet_capacity_offer_command', 'll_fleet_pool_order_link']) {
const definition = table(overseas, 'baiyue_lastleg', name)
assert.ok(module.includes(definition), `${name}: module and unified contract drift`)
}
const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql')
assert.doesNotMatch(sql.replace(/^--.*$/gm, ''), /\b(?:DROP|TRUNCATE|DELETE|UPDATE|REPLACE)\b/i)
assert.match(sql, /ADD COLUMN expected_pallet_count INT NULL/i)
assert.match(sql, /ADD CONSTRAINT fk_fleet_booking_offer/i)
})
test('customer identity joins use the same explicit collation as existing fulfillment orders', () => {
for (const name of ['ll_fleet_external_booking', 'll_fleet_pool_order_link']) {
assert.match(table(overseas, 'baiyue_lastleg', name), /customer_id VARCHAR\(128\) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL/i)
}
const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V11__align_capacity_pool_collation.sql')
assert.equal((sql.match(/MODIFY COLUMN customer_id/g) || []).length, 2)
assert.doesNotMatch(sql.replace(/^--.*$/gm, ''), /\b(?:DROP|TRUNCATE|DELETE|UPDATE|REPLACE)\b/i)
})
test('commercial schemas do not invent container or palletizing facts for pool orders', () => {
for (const name of ['ll_local_looseboard_order', 'll_us_truck_order']) {
const definition = table(overseas, 'baiyue_lastleg', name)
assert.match(definition, /container_type\s+varchar\(32\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i)
assert.doesNotMatch(definition, /container_type[^\n]*DEFAULT '40HQ'/i)
}
assert.match(table(overseas, 'baiyue_lastleg', 'll_local_looseboard_order'), /pallet_required\s+varchar\(8\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i)
const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql')
assert.equal((sql.match(/MODIFY COLUMN/g) || []).length, 3)
assert.doesNotMatch(sql.replace(/^--.*$/gm, ''), /\b(?:DROP|TRUNCATE|DELETE|UPDATE|REPLACE)\b/i)
})
test('pool permissions are independent of trip details and default only to the platform administrator', () => {
const permissions = seedRows(cn, 'iam_permission').filter(row => String(row.permission_code).startsWith('platform:fleet:capacity-offer:'))
assert.equal(permissions.length, 5)
assert.deepEqual(permissions.map(row => row.permission_code.split(':').at(-1)).sort(), ['inquiry-cancel','inquiry-create','inquiry-edit','list','manage'])
const ids = new Set(permissions.map(row => String(row.id)))
const grants = seedRows(cn, 'iam_role_permission').filter(row => ids.has(String(row.permission_id)))
assert.equal(grants.length, 5)
assert.ok(grants.every(row => String(row.role_id) === '4000001' && String(row.tenant_id) === '0'))
})
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse']
test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 154]]) { for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 157]]) {
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
const body = sql.replace(/^--.*$/gm, '') const body = sql.replace(/^--.*$/gm, '')
@@ -39,7 +39,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|154) AS initialized_tables;$/) assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|157) AS initialized_tables;$/)
} }
}) })
@@ -121,7 +121,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
}) })
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { test('必要系统种子完整保留,但不写入示例资源与业务事实', () => {
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 412, iam_role_permission: 593, for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 417, iam_role_permission: 598,
iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4,
fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4,
file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name)
+30 -2
View File
@@ -628,7 +628,7 @@ foreach ($route in @(
Assert-Matches $clientOverseasRoutes $routePattern "Client 正式路由 $route" Assert-Matches $clientOverseasRoutes $routePattern "Client 正式路由 $route"
} }
# 四端统一 Gateway,保留服务与会话均来自 TEST 配置。 # 四端统一 Gateway;Compose默认TEST,生产只走Jenkins固定环境及部署白名单。
$unifiedGateway = Read-RequiredText (Join-Path $backendRoot 'gateway/src/main/resources/application.yml') $unifiedGateway = Read-RequiredText (Join-Path $backendRoot 'gateway/src/main/resources/application.yml')
foreach ($gatewayPath in @('/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', '/api/pda/v1/last-leg/**', '/api/platform/v1/warehouse-admin/**', '/api/platform/v1/overseas-warehouse-resources/**')) { foreach ($gatewayPath in @('/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', '/api/pda/v1/last-leg/**', '/api/platform/v1/warehouse-admin/**', '/api/platform/v1/overseas-warehouse-resources/**')) {
Assert-Contains $unifiedGateway $gatewayPath '统一 Gateway 路由' Assert-Contains $unifiedGateway $gatewayPath '统一 Gateway 路由'
@@ -638,9 +638,37 @@ Assert-Contains $unifiedGateway 'uri: lb://warehouse-overseas-service' '海外
$serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') $serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml')
$testCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.yml') $testCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.yml')
$serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') $serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service')
$dispatchJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile')
$builtServiceDeploy = Read-RequiredText (Join-Path $backendRoot 'deploy/scripts/deploy-built-service.sh')
$batchServiceDeploy = Read-RequiredText (Join-Path $backendRoot 'deploy/scripts/deploy-services.sh')
Assert-Contains $testCompose 'AUTH_SESSION_VALIDATION_URL: http://auth:9100/internal/auth/accounts/sessions/validate' '统一权威会话校验' Assert-Contains $testCompose 'AUTH_SESSION_VALIDATION_URL: http://auth:9100/internal/auth/accounts/sessions/validate' '统一权威会话校验'
Assert-Contains $testCompose 'REDIS_TOKEN_KEY_PREFIX' '统一会话前缀' Assert-Contains $testCompose 'REDIS_TOKEN_KEY_PREFIX' '统一会话前缀'
Assert-Contains $testCompose 'SPRING_PROFILES_ACTIVE: test' '唯一 TEST profile' Assert-Matches $testCompose '(?m)^ SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\}\r?$' 'Compose业务profile默认TEST'
Assert-Matches $testCompose '(?m)^ SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\},cn\r?$' '统一Gateway默认TEST及cn路由profile'
Assert-Contains $testCompose 'name: firstleg-${DEPLOY_ENV:-test}' 'Compose项目默认TEST并按环境隔离'
Assert-Contains $builtServiceDeploy 'case "$DEPLOY_ENV" in test|production) ;; *)' '单服务部署环境白名单'
Assert-Contains $builtServiceDeploy 'Only test and production are supported' '非法部署环境拒绝'
Assert-Contains $batchServiceDeploy '[ "$1" = "test" ] || exit 2' '批量部署入口仍限TEST'
Assert-Matches $dispatchJenkinsfile '(?s)case ''backend-java'':\s*return ''test''.*?case ''backend-java-prod'':\s*return ''production''' '总控Job固定TEST及生产映射'
Assert-Contains $dispatchJenkinsfile 'if (!resolvedEnvironment || !resolvedJobPrefix)' '未知总控Job拒绝'
Assert-Contains $dispatchJenkinsfile 'params.ENVIRONMENT != resolvedEnvironment' '总控参数不得切换固定环境'
Assert-Contains $dispatchJenkinsfile "return [fixedEnvironmentForJob(jobBaseName) ?: 'test']" '总控环境选项默认TEST'
Assert-Contains $serviceJenkinsfile "if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) return 'production'" '生产单服务Job固定映射'
Assert-Contains $serviceJenkinsfile "!jobBaseName.startsWith('baiyue-firstleg-dev-')" '单服务Job不接受开发环境前缀'
Assert-Contains $serviceJenkinsfile 'if (!resolvedEnvironment)' '未知单服务Job拒绝'
Assert-Contains $serviceJenkinsfile 'params.ENVIRONMENT != resolvedEnvironment' '单服务参数不得切换固定环境'
Assert-Contains $serviceJenkinsfile "return [fixedEnvironmentForServiceJob(jobBaseName) ?: 'test']" '单服务环境选项默认TEST'
foreach ($mapping in @(
@{ Text = $dispatchJenkinsfile; Function = 'fixedEnvironmentForJob'; Scope = '总控环境映射' },
@{ Text = $serviceJenkinsfile; Function = 'fixedEnvironmentForServiceJob'; Scope = '单服务环境映射' }
)) {
$bodyMatch = [regex]::Match($mapping.Text, '(?ms)^def ' + $mapping.Function + '\([^\r\n]*\)\s*\{(.*?)^\}')
Assert-Matches $bodyMatch.Value 'return ''test''' ($mapping.Scope + '默认TEST能力')
Assert-Matches $bodyMatch.Value 'return null' ($mapping.Scope + '未知Job无环境')
foreach ($returned in [regex]::Matches($bodyMatch.Value, 'return ''([^'']+)''')) {
Assert-Matches $returned.Groups[1].Value '^(test|production)$' ($mapping.Scope + '仅允许TEST及生产')
}
}
foreach ($retired in @('gateway-overseas:', 'event-bridge-service:')) { foreach ($retired in @('gateway-overseas:', 'event-bridge-service:')) {
Assert-NotContains $serviceMap $retired '部署映射' Assert-NotContains $serviceMap $retired '部署映射'
Assert-NotContains $testCompose $retired 'TEST Compose' Assert-NotContains $testCompose $retired 'TEST Compose'
+643
View File
@@ -0,0 +1,643 @@
import { mkdirSync, openSync, writeFileSync, fsyncSync, closeSync, readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createHash, randomUUID } from 'node:crypto'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs'
import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs'
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam'
const MODULE = 'fleet-capacity-pool', PREFIX = 'platform:fleet:capacity-offer:'
const id = value => String(value ?? ''), requireThat = (ok, reason) => { if (!ok) throw Error(reason) }
const success = response => response?.http === 200 && id(response.body?.code) === '200'
const denied = response => [403, 409].includes(response?.http) || response?.http === 200 && ['A0403', 'A0409'].includes(id(response.body?.code))
const encode = encodeURIComponent
// DeliveryRequests.Transfer uses LocalDateTime. Fleet offer/booking Instant fields below retain Z.
export function poolLocalDateTime(date) {
requireThat(date instanceof Date && Number.isFinite(date.getTime()), 'VALID_LOCAL_DATE_REQUIRED')
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
}
const localTime = ms => poolLocalDateTime(new Date(Date.now() + ms))
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
export const POOL_COVERAGE = Object.freeze([
'same-segment-plan-18-of-30-reserve-8-leaves-4', 'two-sales-owner-isolation', 'two-dispatchers-last-four-race',
'unknown-load-stays-null', 'close-and-successful-confirmation-replay', 'one-commercial-order-and-one-incoming',
'both-business-types-real-pda-receipt', 'shared-real-departure-and-signature', 'business-completion-and-fee-uniqueness',
'merchant-cannot-read-pool', 'old-manual-order-endpoints-rejected',
'assigned-vehicle-disable-and-shrink-rejected', 'paused-create-edit-confirm-rejected',
'expired-cutoff-create-edit-confirm-rejected', 'weight-and-volume-independent-capacity-rejections',
'confirmed-cancel-before-incoming-releases-capacity-and-replays', 'incoming-cancel-rejected-with-source-unchanged',
'receipt-overcapacity-rejected-with-source-unchanged', 'open-offer-bind-replan-requires-explicit-resume',
'bound-cargo-reservation-removed-without-double-count', 'other-sales-write-and-sensitive-trip-denied',
'other-customer-commercial-read-denied-with-source-unchanged',
'pool-unspecified-container-and-palletizing-remain-unknown',
])
export const POOL_REMAINING = Object.freeze([
'out-of-band-assigned-vehicle-drift-not-constructible-through-supported-api',
'full-UI-1920x1080-and-network-request-budget',
])
export function parsePoolArguments(args) { return parseCrossCustomerArguments(args) }
export function poolPlan(config) {
return { environment: 'TEST', execute: Boolean(config.execute), runId: config.runId,
output: `reports/fleet-cross-${config.runId}`, coveredByThisEntry: POOL_COVERAGE, remainingSeparateAcceptance: POOL_REMAINING,
sample: 'Two fresh qualified customers and owned one-box pallets, one 30-space trip. Each pool business type has one safely cancelled pre-incoming sample and one fulfilled order with one real incoming/pallet. Planning uses 18 spaces; actual physical load is separately confirmed.',
prerequisites: ['explicit TEST private env with CHONGCHONG_ENV=test and CHONGCHONG_ADMIN_*', 'current Warehouse/FBA IDs',
'TEST read-only inventory evidence credentials', 'current capacity-offer permission seeds and backend deployed'],
boundary: 'Default plan performs no network or filesystem writes. Execute retains every sample and original command, blocks all further writes on an unknown outcome, never deploys or writes business SQL. No automatic rerun or resume.' }
}
/** Strict quantities are based on the selected unloading segment, never a global displayed total. */
export function assertPoolRemaining(offer, destinationKey, expected) {
const destination = offer?.destinations?.filter(row => row.destinationKey === destinationKey)
requireThat(offer?.capacityKnown === true && destination?.length === 1
&& Number(destination[0].remaining?.palletSpaces) === expected, `EXPECTED_EXACT_SEGMENT_REMAINING_${expected}`)
}
export function assertSingleCommercial(before, after) {
requireThat(before?.bookingNo && before.businessOrderNo && before.businessOrderNo === after?.businessOrderNo
&& before.bookingNo === after.bookingNo && before.businessType === after.businessType
&& before.customerId === after.customerId, 'COMMERCIAL_REPLAY_IDENTITY_CHANGED')
}
export function assertPoolUnknownCommercialFacts(businessType, summary) {
requireThat(['LOCAL_LOOSEBOARD', 'US_TRUCK'].includes(businessType) && summary && typeof summary === 'object',
'POOL_COMMERCIAL_SUMMARY_REQUIRED')
requireThat(summary.containerType == null, 'POOL_CONTAINER_TYPE_MUST_REMAIN_UNKNOWN')
if (businessType === 'LOCAL_LOOSEBOARD') requireThat(summary.palletRequired == null, 'POOL_PALLETIZING_SERVICE_MUST_REMAIN_UNKNOWN')
return true
}
export function poolInquiryWrite(inquiry, idempotencyKey) {
return { tripRecordVersion: inquiry.tripRecordVersion, offerRecordVersion: inquiry.offerRecordVersion,
recordVersion: inquiry.recordVersion, customerId: inquiry.customerId, businessType: inquiry.businessType,
deliveryMode: inquiry.deliveryMode, estimatedArrivalAt: inquiry.estimatedArrivalAt,
expectedPalletCount: inquiry.expectedPalletCount, cargo: inquiry.cargo, quoteRef: null, idempotencyKey }
}
export function poolReadIsDenied(response) {
return ([403, 404].includes(response?.http) || response?.http === 200 && ['A0403', 'A0404'].includes(id(response.body?.code)))
&& response.body?.data == null
}
/** This fixture has exactly one shared unloading segment; verify all dimensions, not only a badge. */
export function assertPoolBoundCapacity(offer, destinationKey, planned, reserved, capacity) {
requireThat(offer?.status === 'OPEN' && offer.capacityKnown && offer.canConfirm && offer.segments?.length === 1,
'OPEN_KNOWN_SINGLE_SEGMENT_REQUIRED')
const segment = offer.segments[0]
requireThat(segment.fromIndex === 0 && segment.toIndex === 1, 'EXACT_SHARED_SEGMENT_REQUIRED')
for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) {
const expected = Number(capacity[dimension]) - Number(planned[dimension]) - Number(reserved[dimension])
requireThat(Number.isFinite(expected) && Number(segment.planned?.[dimension]) === Number(planned[dimension])
&& Number(segment.reserved?.[dimension]) === Number(reserved[dimension])
&& Number(segment.remaining?.[dimension]) === expected, `BOUND_LOAD_DOUBLE_COUNT_OR_MISSING_${dimension}`)
const destinations = offer.destinations?.filter(row => row.destinationKey === destinationKey)
requireThat(destinations?.length === 1 && Number(destinations[0].remaining?.[dimension]) === expected,
`BOUND_DESTINATION_REMAINING_MISMATCH_${dimension}`)
}
}
export async function verifyPoolConfirmedCancellation({ inquiry, destinationKey, request, check, latest, command,
businessPath, dispatcherToken, label }) {
const path = `${POOL}/inquiries/${inquiry.bookingNo}`
const confirmed = await request('POST', `${path}/confirm`, command(await latest(inquiry.bookingNo), `${label}-CONFIRM`), dispatcherToken)
check('Cancellation sample really reserves one space', confirmed.inquiry.status === 'CONFIRMED' && confirmed.inquiry.businessOrderNo)
assertPoolRemaining(confirmed.offer, destinationKey, 11)
const before = await request('GET', businessPath(confirmed.inquiry))
check('Confirmed cancellation sample has no incoming', before.summary.status === 'WAITING_INBOUND'
&& before.summary.fleetArrangement?.bookingNo === inquiry.bookingNo && !before.summary.fleetArrangement.incomingNo)
const cancelBody = { ...command(await latest(inquiry.bookingNo), `${label}-CANCEL`), reason: 'TEST未登记来货取消并释放承诺' }
const cancelled = await request('POST', `${path}/cancel`, cancelBody, dispatcherToken)
assertSingleCommercial(confirmed.inquiry, cancelled.inquiry)
check('Confirmed booking is cancelled before incoming', cancelled.inquiry.status === 'CANCELLED' && !cancelled.inquiry.incomingNo)
assertPoolRemaining(cancelled.offer, destinationKey, 12)
const commercial = await request('GET', businessPath(cancelled.inquiry))
check('Commercial cancellation is atomic with reservation release', commercial.summary.status === 'CANCELLED'
&& commercial.summary.fleetArrangement?.bookingNo === inquiry.bookingNo && !commercial.summary.fleetArrangement.incomingNo)
const replay = await request('POST', `${path}/cancel`, cancelBody, dispatcherToken)
check('Original cancellation command replays without changing source', replay.replayed === true
&& inquiryFact(replay.inquiry) === inquiryFact(cancelled.inquiry))
check('Cancel replay keeps persisted commercial and booking facts unchanged',
JSON.stringify(commercial) === JSON.stringify(await request('GET', businessPath(cancelled.inquiry)))
&& inquiryFact(await latest(inquiry.bookingNo)) === inquiryFact(cancelled.inquiry))
const current = await request('GET', `${POOL}/${inquiry.offerNo}`, undefined, dispatcherToken)
assertPoolRemaining(current.offer, destinationKey, 12)
return { bookingNo: inquiry.bookingNo, businessOrderNo: cancelled.inquiry.businessOrderNo, businessType: inquiry.businessType,
status: 'CANCELLED', replayed: true, remainingPalletSpaces: 12 }
}
export async function verifyPoolBindingRevalidation({ offerDetail, replan, resume, blockedConfirm, check }) {
const invalidated = (await offerDetail()).offer
check('Binding invalidates capacity while publication remains OPEN', invalidated.status === 'OPEN'
&& !invalidated.capacityKnown && !invalidated.canConfirm && /计划|核对/.test(id(invalidated.blockReason)))
await blockedConfirm('BIND-INVALIDATED')
await replan()
const recalculated = (await offerDetail()).offer
check('Recalculating cargo alone cannot silently resume acceptance', recalculated.status === 'OPEN'
&& !recalculated.capacityKnown && !recalculated.canConfirm && /恢复接单|承诺已变化/.test(id(recalculated.blockReason)))
await blockedConfirm('REPLANNED-NOT-RESUMED')
const restored = await resume(recalculated)
check('Only explicit resume makes current capacity confirmable', restored.status === 'OPEN' && restored.capacityKnown && restored.canConfirm)
return restored
}
export function poolPickScope(context, member) {
requireThat(context?.marker && /^[1-9]\d*$/.test(id(member?.customerId)) && member?.dispatchNo,
'EXACT_CUSTOMER_DISPATCH_PICK_SCOPE_REQUIRED')
const scoped = Object.create(context)
// pickAll keys only include its context marker and item/position indexes; the PDA key scope is customer-wide.
scoped.marker = `POOL-PICK-${createHash('sha256').update(JSON.stringify([context.marker, id(member.customerId), member.dispatchNo])).digest('hex').slice(0, 32)}`
return scoped
}
export async function pickPoolDispatch(context, token, member, pick = pickAll) {
const scoped = poolPickScope(context, member)
const passed = await pick(scoped, token, member.dispatchNo)
requireThat(passed === true, `PDA_PICK_FAILED:${member.dispatchNo}`)
return scoped.marker
}
export function shrinkingVehicleRequest(vehicle) {
requireThat(vehicle?.id && Number.isInteger(Number(vehicle.recordVersion)) && Number(vehicle.maxPalletSpaces) > 1
&& Number(vehicle.maxLoadKg) > 0 && Number(vehicle.maxVolumeCbm) > 0, 'COMPLETE_OWNED_VEHICLE_SNAPSHOT_REQUIRED')
return { recordVersion: vehicle.recordVersion, plateNumber: vehicle.plateNumber, model: vehicle.model,
fuelConsumptionPer100Km: vehicle.fuelConsumptionPer100Km, mileageKm: vehicle.mileageKm,
driverOrDispatchCompany: vehicle.driverOrDispatchCompany ?? null, phone: vehicle.phone ?? null,
region: vehicle.region, ownership: vehicle.ownership, remark: vehicle.remark ?? null,
maxPalletSpaces: Number(vehicle.maxPalletSpaces) - 1, maxLoadKg: vehicle.maxLoadKg, maxVolumeCbm: vehicle.maxVolumeCbm }
}
export function expiryWaitMs(cutoffAt, now = Date.now()) {
const cutoff = Date.parse(cutoffAt)
requireThat(Number.isFinite(cutoff) && Number.isFinite(now), 'INVALID_SERVER_CUTOFF')
const delay = Math.max(0, cutoff + 300 - now)
requireThat(delay <= 15000, 'CUTOFF_SCENARIO_WAIT_EXCEEDS_BOUNDED_WINDOW')
return delay
}
const capacitySnapshot = offer => JSON.stringify({ capacityKnown: offer.capacityKnown, destinations: offer.destinations, segments: offer.segments })
const inquiryFact = row => JSON.stringify({ bookingNo: row.bookingNo, customerId: row.customerId, businessType: row.businessType,
recordVersion: row.recordVersion, status: row.status, cargo: row.cargo, expectedPalletCount: row.expectedPalletCount,
businessOrderNo: row.businessOrderNo ?? null, incomingNo: row.incomingNo ?? null, dispatchNo: row.dispatchNo ?? null })
/** Current supported vehicle API prevents this state from being created on an assigned trip.
* Verify the real rejection; never write SQL or claim an impossible live-drift fixture succeeded. */
async function verifyAssignedVehicleGuards(context, fixture, tripProof, { request, reject, check, offerDetail, proof }) {
requireThat(context.createdResources.fleetTrips.includes(tripProof) && tripProof.tripNo === fixture.tripNo && tripProof.vehicleId,
'ONLY_CURRENT_FIXTURE_VEHICLE_MAY_BE_TESTED')
const path = `${API}/master-data/vehicles/${encode(tripProof.vehicleId)}`
const before = await request('GET', path), offerBefore = (await offerDetail()).offer
check('Vehicle guard targets the original fixture assigned vehicle', id(before.id) === id(tripProof.vehicleId) && before.status === 'IN_SERVICE')
const disabled = await reject('Assigned vehicle cannot be disabled through master-data API', 'PATCH', `${path}/status`,
{ recordVersion: before.recordVersion, status: 'INACTIVE' })
check('Disable rejected specifically because the vehicle is assigned', /正在车次|改派|取消安排/.test(id(disabled.body?.message)))
const shrunk = await reject('Assigned vehicle cannot shrink capacity through master-data API', 'PUT', path, shrinkingVehicleRequest(before))
check('Shrink rejected specifically because the vehicle is assigned', /正在车次|改派|取消安排/.test(id(shrunk.body?.message)))
const after = await request('GET', path), offerAfter = (await offerDetail()).offer
check('Rejected vehicle commands leave master version status and all capacities unchanged', JSON.stringify(after) === JSON.stringify(before))
check('Rejected vehicle commands do not alter pool capacity or acceptance eligibility', capacitySnapshot(offerBefore) === capacitySnapshot(offerAfter)
&& offerAfter.canConfirm === offerBefore.canConfirm)
proof.vehicleGuards = { status: 'VERIFIED_REJECTION', vehicleId: id(before.id), originalVersion: before.recordVersion,
liveDrift: 'NOT_CONSTRUCTIBLE_THROUGH_SUPPORTED_API', explanation: 'VehicleApplicationService requires unassigned before status/capacity changes; no database bypass used.' }
proof.covered.push('assigned-vehicle-disable-and-shrink-rejected')
}
async function verifyPauseAndCutoff({ offerNo, bookingNo, salesToken, dispatcherToken, request, reject, check, key, proof,
wait = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
const detail = async () => (await request('GET', `${POOL}/${offerNo}`, undefined, dispatcherToken)).offer
const latest = () => request('GET', `${POOL}/inquiries/${bookingNo}`, undefined, dispatcherToken)
const control = async (action, suffix) => {
const offer = await detail()
return (await request('POST', `${POOL}/${offerNo}/${action}`, { tripRecordVersion: offer.tripRecordVersion,
offerRecordVersion: offer.recordVersion, idempotencyKey: key(suffix), reason: 'TEST暂停与截止门禁' }, dispatcherToken)).offer
}
const editCutoff = async (cutoffAt, suffix) => {
const offer = await detail()
return (await request('PUT', `${POOL}/${offerNo}`, { tripNo: offer.tripNo, tripRecordVersion: offer.tripRecordVersion,
offerRecordVersion: offer.recordVersion, cutoffAt, contactName: offer.contactName, contactPhone: offer.contactPhone,
remark: offer.remark, idempotencyKey: key(suffix) }, dispatcherToken)).offer
}
const refusedCommands = async (label, reason) => {
const current = await latest(), before = inquiryFact(current), beforeOffer = await detail()
const body = { tripRecordVersion: current.tripRecordVersion, offerRecordVersion: current.offerRecordVersion,
recordVersion: current.recordVersion, customerId: current.customerId, businessType: current.businessType,
deliveryMode: current.deliveryMode, estimatedArrivalAt: current.estimatedArrivalAt, expectedPalletCount: current.expectedPalletCount,
cargo: current.cargo, quoteRef: null, idempotencyKey: key(`${label}-EDIT`) }
for (const [method, path, command, token] of [
['POST', `${POOL}/${offerNo}/inquiries`, { ...body, recordVersion: null, idempotencyKey: key(`${label}-CREATE`) }, salesToken],
['PUT', `${POOL}/inquiries/${bookingNo}`, body, salesToken],
['POST', `${POOL}/inquiries/${bookingNo}/confirm`, { tripRecordVersion: current.tripRecordVersion,
offerRecordVersion: current.offerRecordVersion, recordVersion: current.recordVersion, idempotencyKey: key(`${label}-CONFIRM`) }, dispatcherToken],
]) {
const response = await reject(`${label} ${method} cannot mutate business`, method, path, command, token)
check(`${label} rejection comes from expected offer gate`, reason.test(id(response.body?.message)))
}
check(`${label} preserves original draft and does not create a commercial order`, before === inquiryFact(await latest()))
check(`${label} preserves actual reservations`, capacitySnapshot(beforeOffer) === capacitySnapshot(await detail()))
}
const original = await detail()
const paused = await control('pause', 'PAUSE-MATRIX')
check('Pause blocks both create and confirm affordances', paused.status === 'PAUSED' && !paused.canCreateInquiry && !paused.canConfirm)
await refusedCommands('PAUSED', /暂停|开放|接单|发布/)
const resumed = await control('resume', 'RESUME-MATRIX')
check('Explicit resume restores the same capacity', resumed.status === 'OPEN' && resumed.canCreateInquiry && resumed.canConfirm
&& capacitySnapshot(original) === capacitySnapshot(resumed))
proof.covered.push('paused-create-edit-confirm-rejected')
const expiring = await editCutoff(new Date(Date.now() + 8000).toISOString(), 'SHORT-CUTOFF')
const delay = expiryWaitMs(expiring.cutoffAt)
await wait(delay)
const expired = await detail()
check('Server marks cutoff expiry without mutating the stored OPEN status', expired.status === 'OPEN' && !expired.canCreateInquiry
&& !expired.canConfirm && /截止|发车时间/.test(id(expired.blockReason)))
await refusedCommands('EXPIRED', /截止|发车时间/)
const restoreAt = Math.min(Date.now() + 1800000, Date.parse(expired.plannedDepartureAt) - 60000)
requireThat(restoreAt > Date.now() + 10000, 'TRIP_NO_LONGER_HAS_TIME_TO_RESTORE_CUTOFF')
await editCutoff(new Date(restoreAt).toISOString(), 'RESTORE-CUTOFF')
const restored = await control('resume', 'RESTORE-AFTER-CUTOFF')
check('Deadline scenario ends with explicit valid acceptance restored', restored.canCreateInquiry && restored.canConfirm)
proof.cutoffMatrix = { status: 'VERIFIED', cutoffAt: expiring.cutoffAt, waitedMs: delay, restoredCutoffAt: restored.cutoffAt }
proof.covered.push('expired-cutoff-create-edit-confirm-rejected')
}
async function poolScenario(context, fixture, { execute = false, readInventory, credentials }) {
if (!execute) return poolPlan({ execute: false, runId: 'plan' })
requireThat(fixture?.marker === context.marker && fixture.members?.length === 2 && fixture.pdaToken
&& typeof readInventory === 'function', 'CURRENT_PHYSICAL_FIXTURE_REQUIRED')
const proof = context.createdResources.fleetCapacityPool = { status: 'CHECKING', marker: context.marker,
tripNo: fixture.tripNo, covered: [], remaining: [...POOL_REMAINING], actors: [], orders: [], snapshots: {} }
const tripProof = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo)
const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}`
const key = label => createHash('sha256').update(`${context.marker}:POOL:${label}`).digest('hex')
const check = (label, ok, response = null) => { context.assert(MODULE, label, response, Boolean(ok), '', { scenario: MODULE, deploymentBlocker: true }); requireThat(ok, label) }
const raw = (method, path, body, token = context.token) => context.request(method, path, body, token)
const request = async (method, path, body, token = context.token) => {
const response = await raw(method, path, body, token)
check(`${method} ${path}`, success(response) && response.requestId && response.traceId, response)
return response.body.data
}
const reject = async (label, method, path, body, token) => {
const response = await raw(method, path, body, token)
check(label, denied(response) && response.requestId && response.traceId, response)
return response
}
const permissions = await request('GET', `${IAM}/permissions`)
const actorSessions = [], merchantSessions = []
const businessPath = order => `${API}/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}?customerId=${encode(order.customerId)}`
const createActor = async (kind, index, codes) => {
const suffix = createHash('sha256').update(`${context.marker}:${kind}:${index}`).digest('hex').slice(0, 16)
const found = codes.map(code => permissions.filter(p => p.permissionCode === code && p.status === 'ACTIVE'))
check('Exact pool permissions exist', found.every(rows => rows.length === 1 && rows[0].id))
const role = await request('POST', `${IAM}/roles`, { roleCode: `POOL_${kind}_${suffix}`.toUpperCase(), roleName: `${context.marker}-${kind}-${index}`, description: context.marker })
const actor = { kind, index, roleId: id(role.id) }; proof.actors.push(actor)
await request('PUT', `${IAM}/roles/permissions`, { roleId: actor.roleId, permissionIds: found.map(rows => id(rows[0].id)) })
const username = `pool_${kind}_${suffix}`, password = `Pool${randomUUID().replaceAll('-', '')}A9`
const user = await request('POST', `${IAM}/users`, { username, displayName: `${context.marker}-同名测试人员`,
phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', roleIds: [actor.roleId], password, status: 'ACTIVE' })
actor.membershipId = id(user.membershipId)
const login = await request('POST', '/api/auth/login', { loginId: username, password }, '')
actorSessions.push({ accessToken: login.accessToken, refreshToken: login.refreshToken })
const effective = await request('GET', `${IAM}/authorization/permissions`, undefined, login.accessToken)
check('Temporary actor has exact permissions', Array.isArray(effective) && JSON.stringify([...effective].sort()) === JSON.stringify([...codes].sort()))
return login.accessToken
}
try {
const sales = [], dispatchers = []
for (let n = 0; n < 2; n++) sales.push(await createActor('sales', n, ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(s => PREFIX + s)))
for (let n = 0; n < 2; n++) dispatchers.push(await createActor('dispatch', n, [PREFIX + 'manage']))
for (const member of fixture.members) {
const credential = credentials?.get(member.customerId)
check('Original fixture customer credentials available only in memory', Boolean(credential))
const login = await request('POST', '/api/auth/login', { loginId: credential.username, password: credential.password }, '')
merchantSessions.push({ customerId: member.customerId, accessToken: login.accessToken, refreshToken: login.refreshToken })
await reject('Merchant token cannot read internal capacity pool', 'GET', `${POOL}?page=1&size=20`, undefined, login.accessToken)
}
proof.covered.push('merchant-cannot-read-pool')
let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`)
check('One actual thirty-space trip and two original real pallets', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2)
const planned = controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(p => ({ ...p, palletSpaces: 9 }))
await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, pallets: planned, reason: 'TEST容量计划18托位;实际装载另行实测确认' })
detail = await request('GET', tripPath)
const offerDraft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion,
cutoffAt: new Date(Date.now() + 1800000).toISOString(), contactName: 'TEST调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, dispatchers[0])
let offer = (await request('POST', `${POOL}/${offerDraft.offer.offerNo}/publish`, { tripRecordVersion: offerDraft.offer.tripRecordVersion,
offerRecordVersion: offerDraft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, dispatchers[0])).offer
proof.offerNo = offer.offerNo
const destinationKey = plan.cargo[0].destinationKey
check('Owned physical sources share exact destination', plan.cargo.every(row => row.destinationKey === destinationKey))
assertPoolRemaining(offer, destinationKey, 12)
const offerDetail = async token => request('GET', `${POOL}/${proof.offerNo}`, undefined, token)
const createInquiry = async (index, type, spaces, label, incomplete = false, cargoOverrides = {}) => {
offer = (await offerDetail(sales[index])).offer
const body = { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion,
customerId: fixture.members[index].customerId, businessType: type, deliveryMode: 'SELF_DELIVERY', expectedPalletCount: incomplete ? null : 1,
estimatedArrivalAt: new Date(Date.now() + 900000).toISOString(), idempotencyKey: key(label),
cargo: { destinationKey, cargoName: `${context.marker}-${label}`, packageCount: incomplete ? null : 1, palletSpaces: spaces,
weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 30, currency: 'CNY', contactName: 'TEST货主', contactPhone: '19900000000', remark: context.marker, ...cargoOverrides } }
return (await request('POST', `${POOL}/${proof.offerNo}/inquiries`, body, sales[index])).inquiry
}
const incomplete = await createInquiry(0, 'US_TRUCK', null, 'UNKNOWN', true)
check('Unknown box/pallet counts are null instead of fabricated one', incomplete.cargo.packageCount == null && incomplete.expectedPalletCount == null)
proof.covered.push('unknown-load-stays-null')
const first = await createInquiry(0, 'LOCAL_LOOSEBOARD', 8, 'LOOSE')
await reject('Other sales cannot read this inquiry', 'GET', `${POOL}/inquiries/${first.bookingNo}`, undefined, sales[1])
const second = await createInquiry(1, 'US_TRUCK', 4, 'TRUCK-A')
check('Same display name does not collapse distinct sales identities', first.salesOwnerUserId && second.salesOwnerUserId && first.salesOwnerUserId !== second.salesOwnerUserId)
proof.covered.push('two-sales-owner-isolation')
const latest = no => request('GET', `${POOL}/inquiries/${no}`, undefined, dispatchers[0])
const command = (inquiry, label) => ({ tripRecordVersion: inquiry.tripRecordVersion, offerRecordVersion: inquiry.offerRecordVersion,
recordVersion: inquiry.recordVersion, idempotencyKey: key(label) })
const ownershipBefore = await latest(first.bookingNo), sensitiveTripBefore = await request('GET', tripPath)
const ownerOfferBefore = (await offerDetail(dispatchers[0])).offer
await reject('Other sales cannot edit an owned inquiry', 'PUT', `${POOL}/inquiries/${first.bookingNo}`,
poolInquiryWrite(ownershipBefore, key('OTHER-SALES-EDIT')), sales[1])
await reject('Other sales cannot cancel an owned inquiry', 'POST', `${POOL}/inquiries/${first.bookingNo}/cancel`,
{ ...command(ownershipBefore, 'OTHER-SALES-CANCEL'), reason: 'TEST越权负例' }, sales[1])
const sensitiveRead = await raw('GET', tripPath, undefined, sales[1])
check('Minimal sales cannot read sensitive operational trip', poolReadIsDenied(sensitiveRead)
&& sensitiveRead.requestId && sensitiveRead.traceId, sensitiveRead)
check('Rejected sales writes and reads preserve original owner draft and trip facts',
inquiryFact(ownershipBefore) === inquiryFact(await latest(first.bookingNo))
&& JSON.stringify(sensitiveTripBefore) === JSON.stringify(await request('GET', tripPath))
&& capacitySnapshot(ownerOfferBefore) === capacitySnapshot((await offerDetail(dispatchers[0])).offer))
proof.covered.push('other-sales-write-and-sensitive-trip-denied')
await verifyAssignedVehicleGuards(context, fixture, tripProof, { request, reject, check, offerDetail: () => offerDetail(dispatchers[0]), proof })
await verifyPauseAndCutoff({ offerNo: proof.offerNo, bookingNo: first.bookingNo, salesToken: sales[0],
dispatcherToken: dispatchers[0], request, reject, check, key, proof })
proof.dimensionRejections = []
for (const dimension of ['weightKg', 'volumeCbm']) {
const available = (await offerDetail(dispatchers[0])).offer.destinations.find(row => row.destinationKey === destinationKey)?.remaining
check('Independent dimension test has ordinary spare pallet weight and volume capacity', Number(available?.palletSpaces) >= 1
&& Number(available?.weightKg) > 100 && Number(available?.volumeCbm) > 1)
const inquiry = await createInquiry(1, 'US_TRUCK', 1, `OVER-${dimension}`, false, { [dimension]: Number(available[dimension]) + 1 })
const before = await latest(inquiry.bookingNo), capacityBefore = (await offerDetail(dispatchers[0])).offer
const response = await reject(`${dimension} overrun cannot be hidden by spare pallet spaces`, 'POST', `${POOL}/inquiries/${inquiry.bookingNo}/confirm`,
command(before, `REJECT-${dimension}`), dispatchers[1])
check('Independent load rejection is an actual capacity-limit conflict', /超过.*限制|容量|载重|容积/.test(id(response.body?.message)))
const after = await latest(inquiry.bookingNo)
check('Dimension refusal leaves draft source and all prior reservations unchanged', inquiryFact(before) === inquiryFact(after)
&& after.status === 'DRAFT' && !after.businessOrderNo && capacitySnapshot(capacityBefore) === capacitySnapshot((await offerDetail(dispatchers[0])).offer))
proof.dimensionRejections.push({ dimension, bookingNo: inquiry.bookingNo, submitted: inquiry.cargo[dimension], available: available[dimension], status: 'REJECTED_UNCHANGED' })
}
proof.covered.push('weight-and-volume-independent-capacity-rejections')
proof.cancelledOrders = []
for (const [index, type] of ['LOCAL_LOOSEBOARD', 'US_TRUCK'].entries()) {
const inquiry = await createInquiry(index, type, 1, `CANCEL-${type}`)
proof.cancelledOrders.push(await verifyPoolConfirmedCancellation({ inquiry, destinationKey, request, check, latest,
command, businessPath, dispatcherToken: dispatchers[index], label: `CANCEL-${type}` }))
}
proof.covered.push('confirmed-cancel-before-incoming-releases-capacity-and-replays')
let current = await latest(first.bookingNo), firstCommand = command(current, 'CONFIRM-LOOSE')
await reject('Sales cannot confirm capacity', 'POST', `${POOL}/inquiries/${first.bookingNo}/confirm`, firstCommand, sales[0])
const firstConfirmed = await request('POST', `${POOL}/inquiries/${first.bookingNo}/confirm`, firstCommand, dispatchers[0])
assertPoolRemaining(firstConfirmed.offer, destinationKey, 4)
proof.covered.push('same-segment-plan-18-of-30-reserve-8-leaves-4')
const oversized = await createInquiry(1, 'US_TRUCK', 5, 'OVER-CAPACITY')
proof.revalidationDraftNo = oversized.bookingNo
await reject('Five spaces cannot fit last four', 'POST', `${POOL}/inquiries/${oversized.bookingNo}/confirm`, command(await latest(oversized.bookingNo), 'REJECT-FIVE'), dispatchers[1])
const rival = await createInquiry(0, 'US_TRUCK', 4, 'TRUCK-B')
const contenders = [await latest(second.bookingNo), await latest(rival.bookingNo)]
const race = await Promise.all(contenders.map((row, index) => raw('POST', `${POOL}/inquiries/${row.bookingNo}/confirm`, command(row, `RACE-${index}`), dispatchers[index])))
check('Two dispatcher race confirms exactly one remaining reservation', race.filter(success).length === 1 && race.filter(denied).length === 1)
const winner = race.find(success).body.data.inquiry
offer = (await offerDetail(dispatchers[0])).offer; assertPoolRemaining(offer, destinationKey, 0)
proof.orders = [firstConfirmed.inquiry, winner]
proof.covered.push('two-dispatchers-last-four-race')
const poolMembers = []
for (const [index, order] of proof.orders.entries()) {
const source = await request('GET', businessPath(order))
check('New pool commercial order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, source.summary))
check('Pool commercial order has source arrangement and waits for real inbound', source.summary.status === 'WAITING_INBOUND'
&& source.summary.fleetArrangement?.bookingNo === order.bookingNo && !source.summary.fleetArrangement.incomingNo)
const oldStatusPath = businessPath(order).replace('?', '/status?')
await reject('Old commercial status mutation cannot bypass pool workflow', 'PATCH', oldStatusPath,
{ version: source.summary.version, status: 'CANCELLED', idempotencyKey: key(`OLD-CANCEL-${index}`), remark: context.marker })
const otherCustomer = merchantSessions.find(row => row.customerId !== order.customerId)
check('Cross-customer negative has a genuinely different qualified customer', Boolean(otherCustomer))
const merchantPath = `/api/merchant/v1/last-leg/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}`
const foreignRead = await raw('GET', merchantPath, undefined, otherCustomer.accessToken)
check('Another customer cannot read this commercial order', poolReadIsDenied(foreignRead)
&& foreignRead.requestId && foreignRead.traceId, foreignRead)
check('Denied old status and foreign read preserve commercial source', JSON.stringify(source) === JSON.stringify(await request('GET', businessPath(order))))
poolMembers.push(await receiveAndBindPoolOrder(context, fixture, order, index, { request, reject, check, key, readInventory,
latest, command, businessPath, offerDetail: () => offerDetail(dispatchers[0]), dispatcherToken: dispatchers[0], destinationKey, proof }))
}
proof.covered.push('old-manual-order-endpoints-rejected', 'one-commercial-order-and-one-incoming', 'both-business-types-real-pda-receipt',
'other-customer-commercial-read-denied-with-source-unchanged', 'incoming-cancel-rejected-with-source-unchanged',
'receipt-overcapacity-rejected-with-source-unchanged', 'open-offer-bind-replan-requires-explicit-resume', 'bound-cargo-reservation-removed-without-double-count')
offer = (await offerDetail(dispatchers[0])).offer
const beforeClose = await Promise.all(proof.orders.map(row => latest(row.bookingNo)))
const closed = await request('POST', `${POOL}/${proof.offerNo}/close`, { tripRecordVersion: offer.tripRecordVersion,
offerRecordVersion: offer.recordVersion, idempotencyKey: key('CLOSE'), reason: context.marker }, dispatchers[0])
check('Offer closed without cancelling commitments', closed.offer.status === 'CLOSED')
const replay = await request('POST', `${POOL}/inquiries/${first.bookingNo}/confirm`, firstCommand, dispatchers[0])
assertSingleCommercial(firstConfirmed.inquiry, replay.inquiry)
check('Successful confirmation still replays after close', replay.replayed === true)
for (const row of beforeClose) check('Old confirmation replay cannot revert bound source', inquiryFact(row) === inquiryFact(await latest(row.bookingNo)))
const history = await request('GET', `${POOL}/inquiries?page=1&size=100&scope=MINE`, undefined, sales[0])
check('Sales retains its own history after close', history.records.some(row => row.bookingNo === first.bookingNo)
&& history.records.every(row => row.salesOwnerUserId === first.salesOwnerUserId))
proof.covered.push('close-and-successful-confirmation-replay')
detail = await request('GET', tripPath)
for (const binding of detail.dispatches) detail = await request('POST', `${tripPath}/dispatches/${encode(binding.dispatchNo)}/review`, { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' })
const members = [...fixture.members, ...poolMembers]
proof.pickScopes = []
for (const member of members) {
const namespace = await pickPoolDispatch(context, fixture.pdaToken, member)
proof.pickScopes.push({ customerId: member.customerId, dispatchNo: member.dispatchNo, namespace })
const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(member.dispatchNo)}`, undefined, fixture.pdaToken)
check('Every owned and external pallet was actually picked', task.items.every(row => Number(row.pickedPackages) === Number(row.scheduledPackages)))
}
const loading = await request('GET', `${tripPath}/loading`)
check('All four original physical pallets present for actual loading', loading.cargo.length === 4 && !loading.blockReason)
const loaded = await request('POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, manifestHash: loading.manifestHash,
pallets: controlledPalletMeasurements(loading.cargo, detail.trip.stops), reason: 'TEST受控实测每实托1托位;不同于之前18托位接单计划' })
detail = await request('POST', `${tripPath}/loading-review`, { recordVersion: loaded.recordVersion, decision: 'APPROVED' })
const departBody = { recordVersion: detail.trip.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker }
const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.members[0].dispatchNo)}/depart`
const departed = await request('POST', departPath, departBody, fixture.pdaToken)
check('One whole-trip actual departure includes exactly four dispatches', departed.status === 'IN_TRANSIT' && Number(departed.shippedPackages) === 4
&& departed.dispatches.length === 4 && members.every(member => departed.dispatches.some(row => row.dispatchNo === member.dispatchNo)))
check('Departure original-key replay is explicit', (await request('POST', departPath, departBody, fixture.pdaToken)).replayed === true)
for (const [index, member] of members.entries()) {
const path = `${API}/delivery-orders/${encode(member.deliveryNo)}?customerId=${encode(member.customerId)}`
const currentDelivery = await request('GET', path)
await request('PUT', path.replace('?', '/status?'), { status: 'DELIVERING', expectedVersion: currentDelivery.order.version, remark: context.marker })
const signed = await saveReceiptAndComplete(context, { deliveryNo: member.deliveryNo }, member.customerId, index)
check('Receipt actually completes this delivery', signed?.order?.status === 'DELIVERED')
}
proof.covered.push('shared-real-departure-and-signature')
for (const [index, order] of proof.orders.entries()) {
const commercial = await request('GET', businessPath(order))
check('Completed commercial order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, commercial.summary))
check('Each business type completes from actual signature', commercial.summary.status === 'COMPLETED'
&& commercial.summary.fleetArrangement?.fulfillmentStage === 'COMPLETED' && Number(commercial.summary.fleetArrangement.signedPackages) === 1)
const member = poolMembers[index], actual = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${order.customerId}`)
const fees = actual.fees.filter(row => row.feeType === 'DELIVERY_FEE' && row.feeDirection === 'RECEIVABLE')
check('One agreed receivable remains, no order/receipt duplicate fee', fees.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY')
const merchant = merchantSessions.find(session => session.customerId === order.customerId)
const merchantPath = `/api/merchant/v1/last-leg/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}`
const own = await request('GET', merchantPath, undefined, merchant.accessToken)
check('Client completed order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, own.summary))
check('Client sees only own complete commercial arrangement', own.summary.orderNo === order.businessOrderNo && own.summary.fleetArrangement?.businessOrderNo === order.businessOrderNo)
}
proof.covered.push('business-completion-and-fee-uniqueness', 'pool-unspecified-container-and-palletizing-remain-unknown')
proof.poolMembers = poolMembers; proof.status = 'VERIFIED'
return proof
} catch (error) { proof.status = 'FAILED'; throw error }
finally {
for (const actor of proof.actors) {
if (actor.membershipId) await request('PUT', `${IAM}/users/${actor.membershipId}/status`, { status: 'DISABLED' })
if (actor.roleId) await request('PUT', `${IAM}/roles/${actor.roleId}/status`, { status: 'DISABLED' })
}
// The outer runner tracks and logs out all successful login sessions, including these.
actorSessions.length = 0; merchantSessions.length = 0
}
}
async function receiveAndBindPoolOrder(context, fixture, order, index, { request, reject, check, key, readInventory,
latest, command, businessPath, offerDetail, dispatcherToken, destinationKey, proof }) {
const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}`, bookingPath = `${tripPath}/bookings/${encode(order.bookingNo)}`
let bookingList = await request('GET', `${tripPath}/bookings`), booking = bookingList.records.find(row => row.bookingNo === order.bookingNo)
check('Pool commercial association survived into existing booking workflow', booking?.status === 'CONFIRMED')
const body = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, estimatedArrivalAt: new Date(Date.now() + 300000).toISOString(),
deliveryInstructions: `${context.marker} TEST客户自送`, idempotencyKey: key(`INCOMING-${index}`) }
const created = await request('POST', `${bookingPath}/incoming`, body), incomingNo = created.booking.incomingNo
const repeated = await request('POST', `${bookingPath}/incoming`, body)
check('One incoming document on exact replay', incomingNo && repeated.replayed === true && repeated.booking.incomingNo === incomingNo)
const incomingBeforeCancel = await request('GET', `${bookingPath}/incoming`), sourceBeforeCancel = await request('GET', businessPath(order))
const inquiryBeforeCancel = await latest(order.bookingNo), capacityBeforeCancel = (await offerDetail()).offer
await reject('Incoming already registered cannot be cancelled from pool', 'POST', `${POOL}/inquiries/${order.bookingNo}/cancel`,
{ ...command(inquiryBeforeCancel, `CANCEL-AFTER-INCOMING-${index}`), reason: 'TEST已来货取消拒绝' }, dispatcherToken)
check('Rejected incoming cancellation preserves incoming commercial booking and capacity',
JSON.stringify(incomingBeforeCancel) === JSON.stringify(await request('GET', `${bookingPath}/incoming`))
&& JSON.stringify(sourceBeforeCancel) === JSON.stringify(await request('GET', businessPath(order)))
&& inquiryFact(inquiryBeforeCancel) === inquiryFact(await latest(order.bookingNo))
&& capacitySnapshot(capacityBeforeCancel) === capacitySnapshot((await offerDetail()).offer))
const pdaPath = `/api/pda/v1/last-leg/warehouse-transfers/${encode(incomingNo)}`
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${index}`), cartonCount: 1,
lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }, fixture.pdaToken)
check('One actual measured draft only', measured.pallets.length === 1 && measured.pallets[0].status === 'DRAFT')
const palletPath = `${pdaPath}/pallets/${measured.pallets[0].id}`
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${index}`) }, fixture.pdaToken)
const pallet = generated.pallets[0]
await request('POST', `${palletPath}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key(`BIND-PALLET-${index}`) }, fixture.pdaToken)
const uploaded = await context.requestMultipart('POST', `${pdaPath}/measurement-photos/files`, { name: `${context.marker}-pool-${index}-TEST.png`, type: 'image/png', content: PNG }, fixture.pdaToken)
check('Real TEST measurement file exists', success(uploaded) && uploaded.body.data?.fileId)
const received = await request('POST', `${pdaPath}/palletize`, { idempotencyKey: key(`PALLETIZE-${index}`), photoFileIds: [id(uploaded.body.data.fileId)] }, fixture.pdaToken)
check('Actual PDA receipt completed one original pallet', received.status === 'WAIT_CUSTOMER_CONFIRMATION' && received.pallets.length === 1 && received.pallets[0].status === 'COMPLETED')
const evidence = await request('GET', `${bookingPath}/incoming`)
const beforeOverReceipt = await latest(order.bookingNo), sourceBeforeReceipt = await request('GET', businessPath(order))
const capacityBeforeReceipt = (await offerDetail()).offer, tripBeforeReceipt = await request('GET', tripPath)
const inventoryBeforeReceipt = await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId })
const overReceipt = await reject('Actual received pallet spaces cannot exceed whole vehicle capacity', 'POST', `${bookingPath}/receipt-confirmation`,
{ tripRecordVersion: evidence.tripRecordVersion, recordVersion: evidence.recordVersion, receiptHash: evidence.incoming.receiptHash,
pallets: [{ warehousePalletId: id(pallet.warehousePalletId), palletSpaces: Number(tripBeforeReceipt.trip.maxPalletSpaces) + 1 }],
unitPrice: 30, idempotencyKey: key(`OVER-RECEIPT-${index}`) })
check('Receipt overrun is refused by actual capacity calculation', /超过.*限制|容量|载重|容积/.test(id(overReceipt.body?.message)))
check('Rejected overreceipt leaves source receipt commercial booking and reservations unchanged',
JSON.stringify(evidence) === JSON.stringify(await request('GET', `${bookingPath}/incoming`))
&& inquiryFact(beforeOverReceipt) === inquiryFact(await latest(order.bookingNo))
&& JSON.stringify(sourceBeforeReceipt) === JSON.stringify(await request('GET', businessPath(order)))
&& capacitySnapshot(capacityBeforeReceipt) === capacitySnapshot((await offerDetail()).offer))
check('Rejected overreceipt leaves complete customer warehouse inventory unchanged', JSON.stringify(inventoryBeforeReceipt)
=== JSON.stringify(await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId })))
await request('POST', `${bookingPath}/receipt-confirmation`, { tripRecordVersion: evidence.tripRecordVersion, recordVersion: evidence.recordVersion,
receiptHash: evidence.incoming.receiptHash, pallets: [{ warehousePalletId: id(pallet.warehousePalletId), palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key(`RECEIPT-${index}`) })
const inventory = await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId })
check('Immutable actual incoming STOCK_IN is unique', inventory.scope?.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE'
&& inventory.ledger.filter(row => row.businessOrderNo === incomingNo && row.businessType === 'STOCK_IN' && Number(row.deltaPackages) === 1).length === 1)
const dispatchBase = `${API}/dispatches`, candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: incomingNo })}`)
check('Received candidate exactly belongs to this commercial incoming', candidates.length === 1 && candidates[0].inboundCode === incomingNo
&& id(candidates[0].customerId) === order.customerId && id(candidates[0].warehousePalletId) === id(pallet.warehousePalletId))
const draftToken = key(`DRAFT-${index}`), container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken, capacity: 30 })
await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidates[0].planPalletId), draftToken, draftContainerId: id(container.id) })
const dispatch = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds: [id(candidates[0].planPalletId)], draftToken })
bookingList = await request('GET', `${tripPath}/bookings`); booking = bookingList.records.find(row => row.bookingNo === order.bookingNo)
await request('POST', `${bookingPath}/dispatch`, { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion,
dispatchNo: dispatch.dispatchNo, idempotencyKey: key(`BIND-DISPATCH-${index}`) })
let plan = await request('GET', `${tripPath}/capacity-plan`), detail = await request('GET', tripPath)
const actualMeasurements = controlledPalletMeasurements(plan.cargo, detail.trip.stops)
const restored = await verifyPoolBindingRevalidation({ offerDetail, check,
blockedConfirm: async label => {
const before = await latest(proof.revalidationDraftNo)
const rejected = await reject('Stale OPEN publication cannot confirm another draft', 'POST', `${POOL}/inquiries/${before.bookingNo}/confirm`,
command(before, `${label}-${index}`), dispatcherToken)
check('Confirmation refused specifically for stale plan or publication commitment', /计划|核对|恢复接单|承诺已变化/.test(id(rejected.body?.message)))
check('Revalidation refusal keeps competing draft unchanged', inquiryFact(before) === inquiryFact(await latest(before.bookingNo)))
},
replan: () => request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash,
pallets: actualMeasurements, reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }),
resume: async offer => (await request('POST', `${POOL}/${proof.offerNo}/resume`, { tripRecordVersion: offer.tripRecordVersion,
offerRecordVersion: offer.recordVersion, idempotencyKey: key(`RESUME-AFTER-BIND-${index}`), reason: 'TEST实际绑定核量后显式恢复接单' }, dispatcherToken)).offer })
const bound = await latest(order.bookingNo)
check('Bound booking points to this actual incoming and dispatch', bound.status === 'BOUND' && bound.incomingNo === incomingNo && bound.dispatchNo === dispatch.dispatchNo)
const allBookings = await request('GET', `${tripPath}/bookings`)
const load = rows => rows.reduce((total, row) => { for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) total[dimension] += Number(row[dimension]); return total },
{ palletSpaces: 0, weightKg: 0, volumeCbm: 0 })
const plannedLoad = load(actualMeasurements)
const reservedLoad = load(allBookings.records.filter(row => ['CONFIRMED', 'RECEIVED'].includes(row.status)).map(row => row.cargo))
const capacity = { palletSpaces: detail.trip.maxPalletSpaces, weightKg: detail.trip.maxLoadKg, volumeCbm: detail.trip.maxVolumeCbm }
assertPoolBoundCapacity(restored, destinationKey, plannedLoad, reservedLoad, capacity)
check('BOUND reservation is removed exactly once from all capacity dimensions', Number(plannedLoad.palletSpaces) === 3 + index
&& Number(reservedLoad.palletSpaces) === (index === 0 ? 4 : 0))
;(proof.bindingCapacity ||= []).push({ bookingNo: order.bookingNo, status: bound.status, planned: plannedLoad,
reserved: reservedLoad, remaining: restored.destinations.find(row => row.destinationKey === destinationKey).remaining })
const path = `${dispatchBase}/${encode(dispatch.dispatchNo)}`, freight = await request('GET', `${path}/external-freight`)
check('The one pool commercial source owns the one agreed freight', freight.requiresDeliveryFee === false && freight.quotes.length === 1
&& freight.quotes[0].bookingNo === order.bookingNo && Number(freight.quotes[0].amount) === 30)
detail = await request('GET', tripPath)
const transfer = { idempotencyKey: key(`TRANSFER-${index}`), plannedDeliveryAt: localTime(21600000), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP',
loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', vehiclePlate: detail.trip.vehiclePlate, driverName: detail.trip.driverName,
driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', externalFreightHash: freight.snapshotHash, fees: [], remark: context.marker }
const converted = await request('POST', `${path}/transfer-delivery`, transfer)
check('Pool source produces exactly one outbound and delivery', converted.outboundNo && converted.deliveries.length === 1)
const replay = await request('POST', `${path}/transfer-delivery`, transfer)
check('Transfer replay reuses outbound and delivery', replay.replayed === true && replay.outboundNo === converted.outboundNo && replay.deliveries[0].deliveryNo === converted.deliveries[0].deliveryNo)
return { customerId: order.customerId, bookingNo: order.bookingNo, businessOrderNo: order.businessOrderNo, incomingNo, dispatchNo: dispatch.dispatchNo,
outboundNo: converted.outboundNo, deliveryNo: converted.deliveries[0].deliveryNo, palletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo }
}
export async function runPoolAcceptance(config, { env = process.env, outputRoot = ROOT, runner = runCrossCustomer } = {}) {
if (!config.execute) return poolPlan(config)
const credentials = new Map(); let restoreWrites
try {
const result = await runner(config, { env, outputRoot,
contextFactory: options => {
const context = new AcceptanceRun(options), original = context.request
context.request = async function (...args) {
const response = await original.apply(this, args)
if (args[0] === 'POST' && args[1] === `${IAM}/tenants` && success(response)) {
const data = response.body.data, customer = data.tenant || data.customer || data
credentials.set(id(customer.id || data.tenantId), { username: data.administratorUsername, password: data.temporaryPassword })
}
return response
}
const directory = join(outputRoot, 'reports', `fleet-cross-${config.runId}`, 'pool-write-journal')
mkdirSync(directory)
restoreWrites = guardFleetWrites(context, async entry => {
const file = openSync(join(directory, `${entry.sequence}.json`), 'w')
try { writeFileSync(file, JSON.stringify(redactFleetWrite(entry), null, 2) + '\n'); fsyncSync(file) } finally { closeSync(file) }
}, { guardFetch: true })
return context
},
verify: (context, fixture, options) => poolScenario(context, fixture, { ...options, credentials }) })
result.scope = 'FLEET_CAPACITY_POOL_HTTP'; result.fullAcceptance = false
result.remainingSeparateAcceptance = [...POOL_REMAINING]
const output = join(outputRoot, 'reports', `fleet-cross-${config.runId}`)
const sources = ['scripts/fleet-capacity-pool-test-env.mjs'].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') }))
writeFileSync(join(output, 'pool-scope.json'), JSON.stringify({ plan: poolPlan(config), sources, success: result.success,
fullAcceptance: false, covered: result.createdResources?.fleetCapacityPool?.covered || [], remaining: POOL_REMAINING }, null, 2) + '\n', { flag: 'wx' })
return result
} finally { restoreWrites?.(); credentials.clear() }
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const config = parsePoolArguments(process.argv.slice(2)), result = await runPoolAcceptance(config)
console.log(JSON.stringify(config.execute ? { success: result.success, fullAcceptance: result.fullAcceptance, error: result.error,
summary: result.summary, covered: result.createdResources?.fleetCapacityPool?.covered, remaining: result.remainingSeparateAcceptance,
output: poolPlan(config).output } : result, null, 2))
if (config.execute && !result.success) process.exitCode = 1
} catch (error) { console.error(error.message); process.exitCode = 1 }
}
@@ -0,0 +1,279 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { parsePoolArguments, poolPlan, runPoolAcceptance, assertPoolRemaining, assertSingleCommercial,
shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, poolPickScope, pickPoolDispatch, POOL_COVERAGE, POOL_REMAINING,
poolInquiryWrite, poolReadIsDenied, assertPoolBoundCapacity, verifyPoolConfirmedCancellation, verifyPoolBindingRevalidation,
assertPoolUnknownCommercialFacts } from './fleet-capacity-pool-test-env.mjs'
test('default and explicit plans make no network requests or invoke the mutating runner', async () => {
const original = globalThis.fetch
globalThis.fetch = async () => { throw Error('unexpected network') }
try {
for (const args of [[], ['--plan']]) {
const config = parsePoolArguments(args)
const plan = await runPoolAcceptance(config, { runner: async () => { throw Error('unexpected runner') } })
assert.equal(plan.execute, false)
assert.equal(plan.environment, 'TEST')
assert.equal(plan.runId, 'plan')
assert.match(plan.boundary, /no network or filesystem writes/)
}
} finally { globalThis.fetch = original }
})
test('execution needs explicit TEST, fresh run ID and current warehouse identifiers', () => {
for (const args of [
['--execute'], ['--execute', '--run-id', 'fresh', '--warehouse-id', '10', '--destination-warehouse-id', '11'],
['--execute', '--environment', 'gray', '--run-id', 'fresh', '--warehouse-id', '10', '--destination-warehouse-id', '11'],
['--execute', '--environment', 'TEST', '--run-id', '../reuse', '--warehouse-id', '10', '--destination-warehouse-id', '11'],
['--execute', '--plan'], ['--resume', 'old-report'],
]) assert.throws(() => parsePoolArguments(args))
const parsed = parsePoolArguments(['--execute', '--environment', 'TEST', '--run-id', 'pool-r1', '--warehouse-id', '10', '--destination-warehouse-id', '11'])
assert.equal(parsed.execute, true)
assert.equal(parsed.runId, 'pool-r1')
})
test('plan distinguishes implemented HTTP scope from remaining whole-feature acceptance', () => {
const plan = poolPlan(parsePoolArguments([]))
assert.deepEqual(plan.coveredByThisEntry, POOL_COVERAGE)
assert.deepEqual(plan.remainingSeparateAcceptance, POOL_REMAINING)
assert.ok(plan.coveredByThisEntry.includes('both-business-types-real-pda-receipt'))
assert.ok(plan.remainingSeparateAcceptance.includes('full-UI-1920x1080-and-network-request-budget'))
assert.ok(plan.coveredByThisEntry.includes('weight-and-volume-independent-capacity-rejections'))
assert.ok(plan.coveredByThisEntry.includes('paused-create-edit-confirm-rejected'))
assert.ok(plan.coveredByThisEntry.includes('expired-cutoff-create-edit-confirm-rejected'))
assert.ok(plan.coveredByThisEntry.includes('assigned-vehicle-disable-and-shrink-rejected'))
assert.ok(plan.remainingSeparateAcceptance.includes('out-of-band-assigned-vehicle-drift-not-constructible-through-supported-api'))
assert.match(plan.boundary, /unknown outcome/)
assert.match(plan.boundary, /No automatic rerun or resume/)
})
test('remaining capacity uses unique current destination and rejects unknown, other-stop or duplicate rows', () => {
const offer = { capacityKnown: true, destinations: [{ destinationKey: 'FBA:A', remaining: { palletSpaces: 4 } },
{ destinationKey: 'FBA:B', remaining: { palletSpaces: 30 } }] }
assert.doesNotThrow(() => assertPoolRemaining(offer, 'FBA:A', 4))
for (const change of [o => { o.capacityKnown = false }, o => { o.destinations[0].remaining = null },
o => { o.destinations[0].destinationKey = 'FBA:C' }, o => { o.destinations.push(structuredClone(o.destinations[0])) },
o => { o.destinations[0].remaining.palletSpaces = 5 }]) {
const altered = structuredClone(offer); change(altered)
assert.throws(() => assertPoolRemaining(altered, 'FBA:A', 4), /EXACT_SEGMENT/)
}
})
test('successful confirmation replay must preserve commercial, booking, business type and customer identity', () => {
const before = { bookingNo: 'FB-1', businessOrderNo: 'LB-1', businessType: 'LOCAL_LOOSEBOARD', customerId: '9' }
assert.doesNotThrow(() => assertSingleCommercial(before, { ...before }))
for (const field of Object.keys(before)) assert.throws(() => assertSingleCommercial(before, { ...before, [field]: 'OTHER' }), /IDENTITY_CHANGED/)
assert.throws(() => assertSingleCommercial({ ...before, businessOrderNo: null }, { ...before }), /IDENTITY_CHANGED/)
})
test('default executor rejects non-TEST process configuration before any fixture writes', async () => {
const config = parsePoolArguments(['--execute', '--environment', 'TEST', '--run-id', 'pool-r1', '--warehouse-id', '10', '--destination-warehouse-id', '11'])
await assert.rejects(runPoolAcceptance(config, { env: { CHONGCHONG_ENV: 'prod' } }), /Explicit TEST configuration/)
})
test('vehicle shrink command preserves the authoritative full request and only changes capacity by one', () => {
const vehicle = { id: '7', recordVersion: 11, plateNumber: 'TEST-7', model: 'Box Truck', fuelConsumptionPer100Km: 12.5,
mileageKm: 100, driverOrDispatchCompany: 'TEST', phone: null, region: 'OVERSEAS', ownership: 'OWNED', remark: 'own fixture',
maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, status: 'IN_SERVICE' }
const request = shrinkingVehicleRequest(vehicle)
assert.equal(request.maxPalletSpaces, 29)
for (const field of Object.keys(request).filter(name => name !== 'maxPalletSpaces')) assert.equal(request[field], vehicle[field])
assert.equal('status' in request, false)
assert.equal('id' in request, false)
assert.equal(vehicle.maxPalletSpaces, 30)
})
test('vehicle matrix refuses incomplete snapshots instead of inventing capacity for a write', () => {
for (const input of [null, {}, { id: '7', recordVersion: 1, maxPalletSpaces: 30 },
{ id: '7', recordVersion: 1, maxPalletSpaces: 1, maxLoadKg: 18000, maxVolumeCbm: 90 }]) {
assert.throws(() => shrinkingVehicleRequest(input), /SNAPSHOT_REQUIRED/)
}
})
test('cutoff wait is bounded and follows the returned instant without negative or unbounded sleeps', () => {
const now = Date.parse('2030-01-01T08:00:00Z')
assert.equal(expiryWaitMs('2030-01-01T08:00:08Z', now), 8300)
assert.equal(expiryWaitMs('2030-01-01T07:59:00Z', now), 0)
assert.throws(() => expiryWaitMs('invalid', now), /INVALID_SERVER_CUTOFF/)
assert.throws(() => expiryWaitMs('2030-01-01T08:10:00Z', now), /BOUNDED_WINDOW/)
})
test('LocalDateTime transport time keeps actual local wall clock while Instant dates remain zoned', () => {
class ShanghaiDate extends Date { getTimezoneOffset() { return -480 } }
class LosAngelesDate extends Date { getTimezoneOffset() { return 420 } }
const instant = '2030-01-01T20:30:45.123Z'
assert.equal(poolLocalDateTime(new ShanghaiDate(instant)), '2030-01-02T04:30:45')
assert.equal(poolLocalDateTime(new LosAngelesDate(instant)), '2030-01-01T13:30:45')
assert.equal(new ShanghaiDate(instant).toISOString(), instant)
assert.equal(new LosAngelesDate(instant).toISOString(), instant)
assert.throws(() => poolLocalDateTime(new Date('invalid')), /VALID_LOCAL_DATE_REQUIRED/)
})
test('PDA namespace belongs to the exact run, customer and dispatch while parent evidence stays shared', () => {
const context = { marker: 'ORIGINAL-RUN', steps: [], createdResources: {} }, first = { customerId: '9', dispatchNo: 'DS-A' }
const a = poolPickScope(context, first), replay = poolPickScope(context, first)
assert.equal(a.marker, replay.marker)
assert.notEqual(a.marker, poolPickScope(context, { ...first, dispatchNo: 'DS-B' }).marker)
assert.notEqual(a.marker, poolPickScope(context, { ...first, customerId: '10' }).marker)
assert.notEqual(a.marker, poolPickScope({ ...context, marker: 'OTHER-RUN' }, first).marker)
assert.equal(a.steps, context.steps)
assert.equal(a.createdResources, context.createdResources)
assert.equal(context.marker, 'ORIGINAL-RUN')
assert.throws(() => poolPickScope(context, { dispatchNo: 'DS-A' }), /PICK_SCOPE_REQUIRED/)
})
test('real pickAll helper handles two same-customer dispatches with separate pick/complete keys and exact replays', async () => {
const operations = new Map(), requests = [], steps = [], picked = new Map()
const dispatches = ['DS-A', 'DS-B']
const context = {
marker: 'SAME-CUSTOMER-RUN', steps, createdResources: {},
addStep(_module, _name, _response, passed) { steps.push(passed) },
assert(_module, _name, _response, passed) { assert.equal(Boolean(passed), true) },
addCoverageGap() { assert.fail('unexpected coverage gap') },
async request(method, path, body) {
const dispatchNo = path.split('/dispatches/')[1].split('/')[0], index = dispatches.indexOf(dispatchNo)
assert.ok(index >= 0)
const response = data => ({ http: 200, body: { code: '200', data }, requestId: 'test', traceId: 'test' })
if (method === 'GET') return response({ items: [{ dispatchItemId: `${index + 1}`, scheduledPackages: 1,
pickedPackages: picked.get(dispatchNo) || 0, positions: [{ id: `${index + 100}`, availablePackages: 1 }] }] })
requests.push({ method, path, body: structuredClone(body) })
const prior = operations.get(body.idempotencyKey), payload = JSON.stringify([path, body])
if (prior) {
if (prior.payload !== payload) return { http: 200, body: { code: 'A0409', message: 'Idempotency key was reused with a different request' } }
return response({ ...prior.result, replayed: true })
}
let result
if (path.endsWith('/pick')) { picked.set(dispatchNo, 1); result = { pickedPackages: 1 } }
else result = { status: 'READY_TO_DEPART', plannedPackages: 1, pickedPackages: 1, remainingPackages: 0, replayed: false }
operations.set(body.idempotencyKey, { payload, result })
return response(result)
},
}
for (const dispatchNo of dispatches) await pickPoolDispatch(context, 'test-pda', { customerId: '9', dispatchNo })
assert.equal(operations.size, 4)
assert.equal(requests.length, 8)
assert.ok(steps.every(Boolean))
for (let index = 0; index < requests.length; index += 2) assert.deepEqual(requests[index], requests[index + 1])
})
test('failed helper result stops the pool stage immediately instead of appending a misleading generic failure', async () => {
const context = { marker: 'R', steps: [] }
await assert.rejects(pickPoolDispatch(context, 'test', { customerId: '9', dispatchNo: 'DS-A' }, async () => false), /PDA_PICK_FAILED:DS-A/)
})
test('foreign read rejection requires actual access/not-found code and no leaked business payload', () => {
for (const response of [{ http: 403, body: { code: 'A0403' } }, { http: 404, body: { code: 'A0404', data: null } },
{ http: 200, body: { code: 'A0403' } }, { http: 200, body: { code: 'A0404' } }]) assert.equal(poolReadIsDenied(response), true)
for (const response of [{ http: 200, body: { code: '200' } }, { http: 401 }, { http: 409 }, { http: 500 },
{ http: 200, body: { code: 'A0409' } }, { http: 403, body: { data: { orderNo: 'PRIVATE' } } }]) assert.equal(poolReadIsDenied(response), false)
})
test('pool commercial facts accept nullish Jackson fields but reject invented container or palletizing answers', () => {
for (const type of ['LOCAL_LOOSEBOARD', 'US_TRUCK']) {
for (const summary of [{}, { containerType: null, palletRequired: null }, { containerType: undefined, palletRequired: undefined }]) {
assert.doesNotThrow(() => assertPoolUnknownCommercialFacts(type, summary))
}
for (const containerType of ['40HQ', '40GP', '45HQ', '', 'UNKNOWN', false, 0]) {
assert.throws(() => assertPoolUnknownCommercialFacts(type, { containerType }), /CONTAINER_TYPE_MUST_REMAIN_UNKNOWN/)
}
assert.throws(() => assertPoolUnknownCommercialFacts(type, null), /SUMMARY_REQUIRED/)
}
for (const palletRequired of ['YES', 'NO', '', 'UNKNOWN', false, 0]) {
assert.throws(() => assertPoolUnknownCommercialFacts('LOCAL_LOOSEBOARD', { palletRequired }), /PALLETIZING_SERVICE_MUST_REMAIN_UNKNOWN/)
}
assert.throws(() => assertPoolUnknownCommercialFacts('OTHER', {}), /SUMMARY_REQUIRED/)
})
test('owner rejection sends a valid current edit rather than malformed input', () => {
const source = { tripRecordVersion: 5, offerRecordVersion: 7, recordVersion: 9, customerId: '42', businessType: 'US_TRUCK',
deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: '2030-01-01T00:00:00Z', expectedPalletCount: 1,
cargo: { destinationKey: 'FBA:1', packageCount: 1, palletSpaces: 4 }, bookingNo: 'FB-1', actions: { canEdit: false } }
const body = poolInquiryWrite(source, 'CURRENT-KEY')
assert.equal(body.idempotencyKey, 'CURRENT-KEY')
assert.equal(body.tripRecordVersion, 5)
assert.equal(body.offerRecordVersion, 7)
assert.equal(body.recordVersion, 9)
assert.deepEqual(body.cargo, source.cargo)
assert.equal(body.customerId, '42')
assert.equal(body.estimatedArrivalAt, source.estimatedArrivalAt)
assert.equal('actions' in body, false)
assert.equal('bookingNo' in body, false)
})
test('BOUND accounting detects stale reservation, omitted actual pallet and wrong destination in every dimension', () => {
const planned = { palletSpaces: 3, weightKg: 300, volumeCbm: 3 }, reserved = { palletSpaces: 4, weightKg: 100, volumeCbm: 1 }
const capacity = { palletSpaces: 30, weightKg: 18000, volumeCbm: 90 }
const remaining = { palletSpaces: 23, weightKg: 17600, volumeCbm: 86 }
const offer = { status: 'OPEN', capacityKnown: true, canConfirm: true,
segments: [{ fromIndex: 0, toIndex: 1, planned, reserved, remaining }], destinations: [{ destinationKey: 'FBA:1', remaining }] }
assert.doesNotThrow(() => assertPoolBoundCapacity(offer, 'FBA:1', planned, reserved, capacity))
for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) {
for (const section of ['planned', 'reserved', 'remaining']) {
const altered = structuredClone(offer); altered.segments[0][section][dimension]++
assert.throws(() => assertPoolBoundCapacity(altered, 'FBA:1', planned, reserved, capacity), /DOUBLE_COUNT_OR_MISSING/)
}
}
assert.throws(() => assertPoolBoundCapacity({ ...offer, canConfirm: false }, 'FBA:1', planned, reserved, capacity), /OPEN_KNOWN/)
assert.throws(() => assertPoolBoundCapacity(offer, 'FBA:OTHER', planned, reserved, capacity), /DESTINATION/)
})
test('binding check requires OPEN invalidation, replan still blocked, then explicit resume in that order', async () => {
const events = [], check = (label, condition) => assert.ok(condition, label)
let stage = 'INVALID'
const snapshots = {
INVALID: { status: 'OPEN', capacityKnown: false, canConfirm: false, blockReason: '计划容量待调度核对' },
REPLANNED: { status: 'OPEN', capacityKnown: false, canConfirm: false, blockReason: '发布承诺已变化,请调度重新核对并恢复接单' },
RESUMED: { status: 'OPEN', capacityKnown: true, canConfirm: true },
}
const result = await verifyPoolBindingRevalidation({ check,
offerDetail: async () => { events.push(`GET-${stage}`); return { offer: snapshots[stage] } },
blockedConfirm: async reason => { events.push(reason) },
replan: async () => { events.push('REPLAN'); stage = 'REPLANNED' },
resume: async current => { assert.equal(current, snapshots.REPLANNED); events.push('RESUME'); stage = 'RESUMED'; return snapshots.RESUMED },
})
assert.equal(result.canConfirm, true)
assert.deepEqual(events, ['GET-INVALID', 'BIND-INVALIDATED', 'REPLAN', 'GET-REPLANNED', 'REPLANNED-NOT-RESUMED', 'RESUME'])
})
test('closed publication or automatic resumption cannot masquerade as OPEN binding revalidation', async () => {
const check = (label, condition) => assert.ok(condition, label)
await assert.rejects(verifyPoolBindingRevalidation({ check, offerDetail: async () => ({ offer: { status: 'CLOSED', canConfirm: false } }),
replan: () => assert.fail('must stop before replan') }), /remains OPEN/)
let reads = 0
await assert.rejects(verifyPoolBindingRevalidation({ check,
offerDetail: async () => ({ offer: ++reads === 1 ? { status: 'OPEN', capacityKnown: false, canConfirm: false, blockReason: '计划容量待调度核对' }
: { status: 'OPEN', capacityKnown: true, canConfirm: true } }), replan: async () => {}, blockedConfirm: async () => {},
resume: () => assert.fail('must stop before resume') }), /cannot silently resume/)
})
test('confirmed cancellation verifies both commercial types and reuses exact successful cancellation request', async () => {
for (const businessType of ['LOCAL_LOOSEBOARD', 'US_TRUCK']) {
let current = { bookingNo: 'FB-1', offerNo: 'FO-1', customerId: '9', businessType, recordVersion: 1, status: 'DRAFT' }
let cancellationBody, cancellationCount = 0, commercialStatus = 'WAITING_INBOUND'
const offer = count => ({ capacityKnown: true, destinations: [{ destinationKey: 'FBA:1', remaining: { palletSpaces: count } }] })
const request = async (method, path, body) => {
if (method === 'POST' && path.endsWith('/confirm')) {
current = { ...current, status: 'CONFIRMED', businessOrderNo: 'BUSINESS-1', recordVersion: 2 }
return { inquiry: { ...current }, offer: offer(11) }
}
if (method === 'POST' && path.endsWith('/cancel')) {
cancellationCount++
if (cancellationBody) assert.equal(body, cancellationBody)
else cancellationBody = body
commercialStatus = 'CANCELLED'; current = { ...current, status: 'CANCELLED', recordVersion: 3 }
return { inquiry: { ...current }, offer: offer(12), replayed: cancellationCount > 1 }
}
if (path === '/commercial') return { summary: { status: commercialStatus, fleetArrangement: { bookingNo: 'FB-1' } } }
if (path.endsWith('/FO-1')) return { offer: offer(12) }
assert.fail(`unexpected ${method} ${path}`)
}
const result = await verifyPoolConfirmedCancellation({ inquiry: { ...current }, destinationKey: 'FBA:1', request,
check: (label, condition) => assert.ok(condition, label), latest: async () => ({ ...current }),
command: (row, label) => ({ recordVersion: row.recordVersion, idempotencyKey: label }), businessPath: () => '/commercial',
dispatcherToken: 'offline', label: businessType })
assert.equal(result.businessType, businessType)
assert.equal(result.status, 'CANCELLED')
assert.equal(cancellationCount, 2)
assert.equal(result.remainingPalletSpaces, 12)
}
})
@@ -0,0 +1,316 @@
import { mkdirSync, openSync, closeSync, writeFileSync, writeSync, ftruncateSync, fsyncSync, unlinkSync,
readFileSync, realpathSync } from 'node:fs'
import { resolve, dirname, join, isAbsolute, extname } from 'node:path'
import { homedir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createHash, randomUUID } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
import { parseCrossCustomerArguments } from './fleet-cross-customer-test-env.mjs'
import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs'
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
export const PRIVATE_DIRECTORY = resolve(homedir(), '.codex', 'env', 'chongchong-global')
const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam'
const PREFIX = 'platform:fleet:capacity-offer:'
const id = value => String(value ?? '')
const demand = (condition, message) => { if (!condition) throw Error(message) }
const ok = response => response?.http === 200 && id(response?.body?.code) === '200'
export function privateCredentialsPath(value, privateDirectory = PRIVATE_DIRECTORY) {
demand(typeof value === 'string' && isAbsolute(value), 'EXPLICIT_ABSOLUTE_PRIVATE_CREDENTIALS_FILE_REQUIRED')
const file = resolve(value), directory = resolve(privateDirectory)
const same = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
demand(same(dirname(file), directory) && extname(file).toLowerCase() === '.json', 'CREDENTIALS_FILE_MUST_BE_JSON_DIRECTLY_IN_PRIVATE_ENV_DIRECTORY')
return file
}
export function parseUiFixtureArguments(args) {
const ordinary = []; let credentialsFile
for (let index = 0; index < args.length; index++) {
if (args[index] !== '--private-credentials-file') { ordinary.push(args[index]); continue }
demand(credentialsFile === undefined && args[index + 1] && !args[index + 1].startsWith('--'), 'INVALID_DUPLICATE_PRIVATE_CREDENTIALS_FILE')
credentialsFile = privateCredentialsPath(args[++index])
}
const config = parseCrossCustomerArguments(ordinary)
demand(!config.execute || credentialsFile, 'EXPLICIT_ABSOLUTE_PRIVATE_CREDENTIALS_FILE_REQUIRED')
return { ...config, privateCredentialsFile: credentialsFile || null }
}
export function uiFixturePlan(config) {
return { execute: Boolean(config.execute), environment: 'TEST', runId: config.runId,
output: `reports/fleet-capacity-pool-ui-${config.runId}`, privateCredentialsFile: config.privateCredentialsFile,
purpose: 'UI fixture only: one OPEN 30-space trip with valid 18-space owned plan and 24 non-reserving drafts, two minimal sales logins, one manage login, two qualified customer logins.',
permissions: { sales: ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name), manager: [PREFIX + 'manage'] },
stopsAt: 'OPEN_AND_24_DRAFTS_READY_FOR_UI', fullAcceptance: false,
credentials: 'Exclusive private JSON creation before any writes; every generated password is persisted and fsynced there only. Reports and console redact passwords and tokens.',
boundary: 'No automatic rerun/resume, SQL mutations, deployment, pool confirmation, PDA picking, departure, receipt signature or completed-order fabrication. Retain business samples and UI accounts; disable the fixture-only PDA and logout script sessions.' }
}
/** Private directory override is for isolated offline tests, never exposed as a CLI option. */
export function createPrivateCredentialStore(file, initial, { privateDirectory = PRIVATE_DIRECTORY } = {}) {
const target = privateCredentialsPath(file, privateDirectory)
demand(realpathSync(dirname(target)) === realpathSync(privateDirectory), 'PRIVATE_DIRECTORY_RESOLUTION_MISMATCH')
const descriptor = openSync(target, 'wx', 0o600)
let closed = false
const save = value => {
demand(!closed, 'PRIVATE_CREDENTIAL_STORE_CLOSED')
const text = JSON.stringify(value, null, 2) + '\n'
writeSync(descriptor, text, 0, 'utf8'); ftruncateSync(descriptor, Buffer.byteLength(text)); fsyncSync(descriptor)
}
try { save(initial) } catch (error) { closeSync(descriptor); throw error }
return { save, close() { if (!closed) { closeSync(descriptor); closed = true } } }
}
export function publicUiEvidence(value, secrets = []) {
const stripped = item => {
if (Array.isArray(item)) return item.map(stripped)
if (item && typeof item === 'object') return Object.fromEntries(Object.entries(item).map(([key, child]) =>
[key, /^(?:base64|bytes|content)$/i.test(key) ? '[BINARY OMITTED]' : stripped(child)]))
return item
}
return stripped(redactFleetWrite(value, secrets))
}
export function uiInquiryBody(offer, customerId, index, key) {
const destination = offer.destinations?.find(row => row.destinationKey?.startsWith('FBA:'))
demand(offer.status === 'OPEN' && offer.canCreateInquiry && destination, 'OPEN_FBA_OFFER_REQUIRED')
return { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion,
customerId, businessType: index % 2 === 0 ? 'LOCAL_LOOSEBOARD' : 'US_TRUCK', deliveryMode: 'SELF_DELIVERY',
expectedPalletCount: index === 0 ? null : 1,
cargo: { destinationKey: destination.destinationKey, cargoName: `UI验收意向-${index + 1}`,
packageCount: index === 0 ? null : 1, palletSpaces: index === 0 ? null : 1, weightKg: 100, volumeCbm: 1,
priceBasis: 'PALLET', unitPrice: 30, currency: 'CNY', contactName: 'UI验收联系人', contactPhone: '19900000000', remark: '独立UI验收草稿,不占位' },
idempotencyKey: key }
}
export async function runUiFixture(config, { env = process.env, outputRoot = ROOT, readers,
prepare = createFleetCrossCustomerFixture, contextFactory = options => new AcceptanceRun(options) } = {}) {
if (!config.execute) return uiFixturePlan(config)
demand(config.environment === 'TEST' && env.CHONGCHONG_ENV === 'test' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
&& config.runId !== 'plan' && /^[1-9]\d*$/.test(config.warehouseId) && /^[1-9]\d*$/.test(config.destinationWarehouseId)
&& env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'EXPLICIT_TEST_FIXTURE_CONFIGURATION_REQUIRED')
const privateFile = privateCredentialsPath(config.privateCredentialsFile)
const endpoint = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
demand(['http:', 'https:'].includes(endpoint.protocol) && ['/api', '/api/'].includes(endpoint.pathname)
&& !endpoint.username && !endpoint.password && !endpoint.hash && !endpoint.search, 'INVALID_TEST_API_BASE')
readers ||= createTestEvidenceReaders(env)
const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']),
environment: 'test', gateway: endpoint.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })
const secretDocument = { schemaVersion: 1, environment: 'TEST', runId: config.runId, status: 'PREPARING', createdAt: new Date().toISOString(),
gatewayOrigin: endpoint.origin, accounts: [], business: {} }
const vault = createPrivateCredentialStore(privateFile, secretDocument)
const reports = join(outputRoot, 'reports'), output = join(reports, `fleet-capacity-pool-ui-${config.runId}`)
const secrets = Object.entries(env).filter(([key]) => /PASSWORD|TOKEN|SECRET/.test(key)).map(([, value]) => value).filter(Boolean)
const sessions = new Map(), kinds = new Map()
let context, picker, lock, restore, initialized = false, requestNumber = 0, inventoryNumber = 0, report
const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(publicUiEvidence(value, secrets), null, 2) + '\n')
const writePrivate = () => vault.save(secretDocument)
const remember = account => {
if (account.password) secrets.push(account.password)
const existing = secretDocument.accounts.find(row => row.username === account.username)
if (existing) Object.assign(existing, account); else secretDocument.accounts.push(account)
writePrivate()
}
try {
mkdirSync(reports, { recursive: true })
lock = openSync(join(reports, '.fleet-acceptance.lock'), 'wx')
writeFileSync(lock, JSON.stringify({ runId: config.runId, pid: process.pid, kind: 'UI_FIXTURE' }))
mkdirSync(output); initialized = true; mkdirSync(join(output, 'requests')); mkdirSync(join(output, 'write-journal')); mkdirSync(join(output, 'evidence'))
save('manifest.json', { ...uiFixturePlan(config), sourceSha256: createHash('sha256').update(readFileSync(fileURLToPath(import.meta.url))).digest('hex') })
await readers.preflight()
context = contextFactory(options)
secretDocument.marker = context.marker; writePrivate()
const proof = context.createdResources.fleetCapacityPoolUi = { marker: context.marker, status: 'PREPARING', actors: [], drafts: [], fullAcceptance: false }
for (const method of ['request', 'requestWithBody']) {
const original = context[method]
context[method] = async function (...args) {
const sequence = ++requestNumber, [verb, path, body] = args
if (verb === 'POST' && path === `${IAM}/users` && body?.username && body.password) {
remember({ kind: kinds.get(body.username) || (body.username.startsWith('pda_overseas_') ? 'FIXTURE_PDA' : 'FIXTURE_USER'),
username: body.username, password: body.password, roleIds: body.roleIds, status: 'PREPARED' })
}
save(`requests/${sequence}.json`, { stage: 'INTENT', sequence, method: verb, path, body: body instanceof FormData ? '[MULTIPART]' : body })
const response = await original.apply(this, args)
if (ok(response) && verb === 'POST' && path === `${IAM}/tenants`) {
const data = response.body.data, customer = data.tenant || data.customer || data
remember({ kind: 'CUSTOMER', customerId: id(customer.id || data.tenantId), customerName: customer.tenantName || customer.customerName,
username: data.administratorUsername, password: data.temporaryPassword, status: 'CREATED' })
}
if (ok(response) && verb === 'POST' && path === `${IAM}/users`) {
const data = response.body.data
remember({ kind: kinds.get(body.username) || (body.username.startsWith('pda_overseas_') ? 'FIXTURE_PDA' : 'FIXTURE_USER'),
username: body.username, password: body.password, membershipId: id(data.membershipId), roleIds: data.roleIds, status: 'ACTIVE' })
}
if (ok(response) && path === '/api/auth/login' && response.body.data?.accessToken) {
const auth = response.body.data
sessions.set(auth.accessToken, auth.refreshToken); secrets.push(auth.accessToken, auth.refreshToken)
}
save(`requests/${sequence}.json`, { stage: 'RESPONSE', sequence, method: verb, path, body: body instanceof FormData ? '[MULTIPART]' : body, response })
save('progress.json', { marker: context.marker, createdResources: context.createdResources, steps: context.steps })
return response
}
}
restore = guardFleetWrites(context, async entry => {
const file = openSync(join(output, 'write-journal', `${entry.sequence}.json`), 'w')
try { writeFileSync(file, JSON.stringify(publicUiEvidence(entry, secrets), null, 2) + '\n'); fsyncSync(file) } finally { closeSync(file) }
}, { guardFetch: true })
const check = (label, condition, response = null) => {
context.assert('fleet-capacity-pool-ui', label, response, Boolean(condition), '', { scenario: 'UI_FIXTURE', deploymentBlocker: true })
demand(condition, label)
}
const request = async (method, path, body, token = context.token) => {
const response = await context.request(method, path, body, token)
check(`${method} ${path}`, ok(response) && response.requestId && response.traceId, response)
return response.body.data
}
const login = await request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
context.token = login.accessToken; context.refreshToken = login.refreshToken
const access = await resolvePickerAccess(context)
check('Fixture PDA role exists', Boolean(access))
picker = await createPicker(context, 93, config.warehouseId, access)
check('Fixture PDA authenticated', Boolean(picker?.token))
const readInventory = async scope => {
const snapshot = await readers.readFleetInventory(scope); save(`evidence/inventory-${++inventoryNumber}.json`, snapshot); return snapshot
}
const fixture = await prepare(context, { execute: true, warehouseId: config.warehouseId,
destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory })
save('fixture.json', fixture)
const trip = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo), tripPath = `${API}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`
check('Existing producer created exactly two untouched customer dispatches', fixture.members.length === 2 && trip)
let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`)
check('Owned fixture vehicle has actual 30-space capacity', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2)
// Keep UI logins useful beyond the producer's short smoke-test window, before its existing six-hour delivery ETA.
detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion,
plannedDepartureAt: new Date(Date.now() + 4 * 3600000).toISOString(), expectedDriverEndAt: detail.trip.expectedDriverEndAt,
stops: detail.trip.stops, reason: 'UI验收窗口:四小时后计划发车,不执行发车' })
plan = await request('GET', `${tripPath}/capacity-plan`)
await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash,
pallets: controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })), reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' })
const permissions = await request('GET', `${IAM}/permissions`), actorTokens = []
const key = suffix => createHash('sha256').update(`${context.marker}:UI:${suffix}`).digest('hex')
for (const kind of ['SALES_A', 'SALES_B', 'MANAGER']) {
const suffix = key(kind).slice(0, 16), username = `pool_ui_${kind.toLowerCase()}_${suffix}`
const expected = kind === 'MANAGER' ? [PREFIX + 'manage'] : ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name)
const candidates = expected.map(code => permissions.filter(row => row.permissionCode === code && row.status === 'ACTIVE'))
check('Exact fixture permissions are installed', candidates.every(rows => rows.length === 1 && rows[0].id))
const role = await request('POST', `${IAM}/roles`, { roleCode: `POOL_UI_${kind}_${suffix}`.toUpperCase(), roleName: `${context.marker}-${kind}`, description: '仅本轮UI验收账号' })
proof.actors.push({ kind, roleId: id(role.id) })
await request('PUT', `${IAM}/roles/permissions`, { roleId: id(role.id), permissionIds: candidates.map(rows => id(rows[0].id)) })
kinds.set(username, kind)
const password = `Ui${randomUUID().replaceAll('-', '')}A9`; remember({ kind, username, password, roleIds: [id(role.id)], status: 'PREPARED' })
const user = await request('POST', `${IAM}/users`, { username, password, displayName: `${context.marker}-同名销售`,
phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], status: 'ACTIVE' })
proof.actors.at(-1).membershipId = id(user.membershipId)
const auth = await request('POST', '/api/auth/login', { loginId: username, password }, '')
const effective = await request('GET', `${IAM}/authorization/permissions`, undefined, auth.accessToken)
check('UI actor has only its exact minimal permissions', JSON.stringify([...effective].sort()) === JSON.stringify([...expected].sort()))
actorTokens.push(auth.accessToken)
}
detail = await request('GET', tripPath)
const draft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion,
cutoffAt: new Date(Math.min(Date.now() + 3 * 3600000, Date.parse(detail.trip.plannedDepartureAt) - 60000)).toISOString(),
contactName: 'UI验收调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, actorTokens[2])
let offer = (await request('POST', `${POOL}/${draft.offer.offerNo}/publish`, { tripRecordVersion: draft.offer.tripRecordVersion,
offerRecordVersion: draft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, actorTokens[2])).offer
check('OPEN UI offer is available with exactly 12 planned spaces', offer.status === 'OPEN' && offer.canCreateInquiry && offer.canConfirm
&& offer.destinations.length === 1 && Number(offer.destinations[0].remaining.palletSpaces) === 12)
proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo
for (let index = 0; index < 24; index++) {
const owner = index < 12 ? 0 : 1, member = fixture.members[owner]
const created = await request('POST', `${POOL}/${offer.offerNo}/inquiries`, uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`)), actorTokens[owner])
check('UI inquiry is DRAFT with no commercial order or reservation', created.inquiry.status === 'DRAFT' && !created.inquiry.businessOrderNo
&& !created.inquiry.incomingNo && !created.inquiry.dispatchNo && Number(created.offer.destinations[0].remaining.palletSpaces) === 12)
proof.drafts.push({ bookingNo: created.inquiry.bookingNo, customerId: member.customerId, owner: owner === 0 ? 'SALES_A' : 'SALES_B' })
offer = created.offer
}
const firstPage = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2])
const secondPage = await request('GET', `${POOL}/inquiries?page=2&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2])
check('Manager can exercise real page two of 24 drafts', Number(firstPage.total) === 24 && firstPage.records.length === 20
&& secondPage.records.length === 4 && new Set([...firstPage.records, ...secondPage.records].map(row => row.bookingNo)).size === 24)
for (let owner = 0; owner < 2; owner++) {
const mine = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=MINE&offerNo=${offer.offerNo}`, undefined, actorTokens[owner])
check('Each sales login sees exactly its own twelve drafts', Number(mine.total) === 12 && mine.records.length === 12
&& mine.records.every(row => proof.drafts.some(draft => draft.bookingNo === row.bookingNo && draft.owner === (owner === 0 ? 'SALES_A' : 'SALES_B'))))
}
for (const member of fixture.members) {
const account = secretDocument.accounts.find(row => row.kind === 'CUSTOMER' && row.customerId === member.customerId)
check('Customer UI credentials safely preserved', account?.username && account.password)
const auth = await request('POST', '/api/auth/login', { loginId: account.username, password: account.password }, '')
const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken)
check('Customer login belongs to this fixture customer', id(profile.id) === member.customerId)
for (const type of ['local-looseboard-orders', 'us-truck-orders']) {
const page = await request('GET', `/api/merchant/v1/last-leg/${type}?page=1&size=20`, undefined, auth.accessToken)
check('Fresh customer commercial order page provides real empty state before UI confirmation', Number(page.total) === 0 && page.records.length === 0)
}
account.status = 'ACTIVE'; writePrivate()
}
detail = await request('GET', tripPath)
check('UI fixture has not picked departed or signed cargo', detail.trip.status === 'PLANNED')
for (const member of fixture.members) {
const dispatch = await request('GET', `${API}/dispatches/${member.dispatchNo}`)
check('Original real cargo remains unpicked', Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
}
const final = await request('GET', `${POOL}/${offer.offerNo}`, undefined, actorTokens[2])
check('UI handoff keeps publication OPEN and 24 drafts untouched', final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === 24)
proof.status = 'OPEN_AND_24_DRAFTS_READY_FOR_UI'
secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, warehouseId: fixture.warehouseId,
destinationWarehouseId: config.destinationWarehouseId, cutoffAt: final.offer.cutoffAt,
plannedDepartureAt: final.offer.plannedDepartureAt, customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) }
secretDocument.status = 'READY_FOR_UI'; writePrivate()
report = { success: true, fixtureReady: true, fullAcceptance: false, proof }
} catch (error) {
report = { success: false, fixtureReady: false, fullAcceptance: false, error: publicUiEvidence(id(error.message), secrets) }
secretDocument.status = 'FAILED_RETAINED'; writePrivate()
} finally {
try {
if (context) {
const pdaAccount = secretDocument.accounts.find(row => row.kind === 'FIXTURE_PDA' && row.membershipId)
if (picker?.membershipId || pdaAccount?.membershipId) {
try {
const previousSteps = context.steps.length
await disablePicker(context, picker?.membershipId ? picker : pdaAccount, 93)
demand(context.steps.length > previousSteps && context.steps.at(-1)?.passed === true, 'PDA_DISABLE_NOT_CONFIRMED')
if (pdaAccount) { pdaAccount.status = 'DISABLED_AFTER_FIXTURE'; writePrivate() }
} catch {
if (pdaAccount) pdaAccount.status = 'DISABLE_REQUIRES_REVIEW'
report.success = false; report.cleanupError = 'PDA_DISABLE_REQUIRES_REVIEW'
}
}
let sessionsClosed = true
for (const [token, refreshToken] of sessions) {
try {
const response = await context.request('POST', '/api/auth/logout', { refreshToken }, token)
if (!ok(response) && response.http !== 401 && id(response.body?.code) !== 'A0401') sessionsClosed = false
} catch { sessionsClosed = false }
}
report.sessionsClosed = sessionsClosed; report.summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps)
report.success &&= sessionsClosed && report.summary.failed === 0
report.fixtureReady &&= report.success
report.marker = context.marker; report.createdResources = context.createdResources; report.steps = context.steps
}
if (!report.success) secretDocument.status = 'FAILED_RETAINED'
writePrivate()
report.privateCredentialsFile = privateFile; report.requestCount = requestNumber; report.inventorySnapshots = inventoryNumber
report.output = output
if (initialized) save('report.json', report)
} finally {
restore?.(); vault.close()
if (lock !== undefined) { closeSync(lock); unlinkSync(join(reports, '.fleet-acceptance.lock')) }
}
}
return report
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const config = parseUiFixtureArguments(process.argv.slice(2)), result = await runUiFixture(config)
console.log(JSON.stringify(config.execute ? { success: result.success, fixtureReady: result.fixtureReady, fullAcceptance: false,
error: result.error, summary: result.summary, output: result.output, privateCredentialsFile: result.privateCredentialsFile } : result, null, 2))
if (config.execute && !result.success) process.exitCode = 1
} catch { console.error('UI fixture preparation failed; preserve existing private credentials/report and inspect locally.'); process.exitCode = 1 }
}
@@ -0,0 +1,114 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, unlinkSync, rmdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { PRIVATE_DIRECTORY, privateCredentialsPath, parseUiFixtureArguments, uiFixturePlan,
createPrivateCredentialStore, publicUiEvidence, uiInquiryBody, runUiFixture } from './fleet-capacity-pool-ui-fixture-test-env.mjs'
const credentials = join(PRIVATE_DIRECTORY, 'offline-contract-example.json')
const execute = ['--execute', '--environment', 'TEST', '--run-id', 'ui-contract', '--warehouse-id', '10',
'--destination-warehouse-id', '11', '--private-credentials-file', credentials]
test('default and explicit plan cannot invoke network, private writes or fixture producers', async () => {
const original = globalThis.fetch
globalThis.fetch = async () => assert.fail('unexpected network')
const forbidden = () => assert.fail('unexpected dependency')
try {
for (const args of [[], ['--plan']]) {
const result = await runUiFixture(parseUiFixtureArguments(args), { env: {}, prepare: forbidden,
contextFactory: forbidden, readers: { preflight: forbidden } })
assert.equal(result.execute, false)
assert.equal(result.fullAcceptance, false)
assert.equal(result.stopsAt, 'OPEN_AND_24_DRAFTS_READY_FOR_UI')
assert.equal(result.privateCredentialsFile, null)
}
} finally { globalThis.fetch = original }
})
test('execute requires explicit TEST, fresh run, warehouse IDs and one private absolute file', () => {
const config = parseUiFixtureArguments(execute)
assert.equal(config.privateCredentialsFile, credentials)
assert.equal(config.execute, true)
for (const args of [execute.slice(0, -2), [...execute, '--private-credentials-file', credentials],
[...execute, '--resume', 'old'], execute.map(value => value === 'TEST' ? 'gray' : value),
execute.map(value => value === 'ui-contract' ? '../old' : value), ['--private-credentials-file']]) {
assert.throws(() => parseUiFixtureArguments(args))
}
})
test('private credential path is JSON directly within private env directory', () => {
assert.equal(privateCredentialsPath(credentials), resolve(credentials))
for (const file of ['password.json', join(PRIVATE_DIRECTORY, '..', 'password.json'),
join(PRIVATE_DIRECTORY, 'nested', 'password.json'), join(PRIVATE_DIRECTORY, 'password.txt')]) {
assert.throws(() => privateCredentialsPath(file))
}
})
test('private store exclusively creates, fsyncs updates and refuses overwriting existing credentials', () => {
const directory = mkdtempSync(join(tmpdir(), 'pool-ui-private-contract-')), file = join(directory, 'credentials.json')
let store
try {
store = createPrivateCredentialStore(file, { accounts: [{ password: 'offline-placeholder-long' }] }, { privateDirectory: directory })
assert.equal(JSON.parse(readFileSync(file, 'utf8')).accounts[0].password, 'offline-placeholder-long')
assert.throws(() => createPrivateCredentialStore(file, {}, { privateDirectory: directory }), { code: 'EEXIST' })
assert.equal(JSON.parse(readFileSync(file, 'utf8')).accounts[0].password, 'offline-placeholder-long')
store.save({ status: 'READY' })
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), { status: 'READY' })
store.close(); store.close()
assert.throws(() => store.save({}), /CLOSED/)
} finally { store?.close(); unlinkSync(file); rmdirSync(directory) }
})
test('public evidence removes credentials and binary while retaining business identifiers and failed responses', () => {
const publicValue = publicUiEvidence({ requestId: 'req-1', traceId: 'trace-1', body: {
code: 'A0409', message: 'login offline-secret failed', data: { temporaryPassword: 'generated', accessToken: 'token',
users: [{ username: 'private-login', password: 'nested-password' }], content: 'binary', base64: 'binary', bookingNo: 'FB-1' },
} }, ['offline-secret'])
const text = JSON.stringify(publicValue)
for (const forbidden of ['offline-secret', 'generated', 'private-login', 'nested-password', 'binary']) assert.equal(text.includes(forbidden), false)
assert.equal(publicValue.body.data.accessToken, '[REDACTED]')
assert.equal(publicValue.body.data.bookingNo, 'FB-1')
assert.equal(publicValue.body.code, 'A0409')
assert.equal(publicValue.requestId, 'req-1')
})
test('24 intended drafts preserve source versions, both business types and unknown initial quantities', () => {
const offer = { status: 'OPEN', canCreateInquiry: true, tripRecordVersion: 5, recordVersion: 7,
destinations: [{ destinationKey: 'FBA:11' }] }
const drafts = Array.from({ length: 24 }, (_, index) => uiInquiryBody(offer, index < 12 ? 'A' : 'B', index, `KEY-${index}`))
assert.equal(drafts.filter(row => row.customerId === 'A').length, 12)
assert.equal(drafts.filter(row => row.customerId === 'B').length, 12)
assert.equal(drafts.filter(row => row.businessType === 'LOCAL_LOOSEBOARD').length, 12)
assert.equal(drafts.filter(row => row.businessType === 'US_TRUCK').length, 12)
assert.equal(drafts[0].expectedPalletCount, null)
assert.equal(drafts[0].cargo.packageCount, null)
assert.equal(drafts[0].cargo.palletSpaces, null)
for (const row of drafts) {
assert.equal(row.tripRecordVersion, 5)
assert.equal(row.offerRecordVersion, 7)
assert.equal(row.deliveryMode, 'SELF_DELIVERY')
assert.equal('businessOrderNo' in row, false)
assert.equal('status' in row, false)
}
assert.throws(() => uiInquiryBody({ ...offer, status: 'CLOSED' }, 'A', 1, 'K'), /OPEN_FBA/)
assert.throws(() => uiInquiryBody({ ...offer, canCreateInquiry: false }, 'A', 1, 'K'), /OPEN_FBA/)
assert.throws(() => uiInquiryBody({ ...offer, destinations: [] }, 'A', 1, 'K'), /OPEN_FBA/)
})
test('execute rejects non-TEST and incomplete environment before invoking fixture or writing private file', async () => {
const config = parseUiFixtureArguments(execute)
for (const env of [{}, { CHONGCHONG_ENV: 'prod' }, { CHONGCHONG_ENV: 'test' }]) {
await assert.rejects(runUiFixture(config, { env, prepare: () => assert.fail('unexpected fixture') }), /CONFIGURATION_REQUIRED/)
}
})
test('plan lists minimal independent identities and explicitly excludes full acceptance or business completion', () => {
const plan = uiFixturePlan(parseUiFixtureArguments([]))
assert.equal(plan.permissions.sales.length, 4)
assert.deepEqual(plan.permissions.manager, ['platform:fleet:capacity-offer:manage'])
assert.equal(plan.fullAcceptance, false)
assert.match(plan.boundary, /No automatic rerun\/resume/)
assert.match(plan.boundary, /completed-order fabrication/)
assert.match(plan.boundary, /Retain business samples and UI accounts/)
})