feat(fleet): enforce reviewed trips and original fee coverage for dispatch

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
+3 -3
View File
@@ -7,7 +7,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 | | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 136 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 145 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -24,7 +24,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=223/135`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/145`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码 ## 平台管理员密码
@@ -40,7 +40,7 @@
| 所属库 | 保留内容 | 不包含 | | 所属库 | 保留内容 | 不包含 |
|---|---|---| |---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、370 个权限、551 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | | Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、392 个权限、573 条角色权限、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`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -6021,7 +6021,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at)
-- 系统基线 iam_number_sequence:1 行。 -- 系统基线 iam_number_sequence:1 行。
INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3)); INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3));
-- 系统基线 iam_permission:370 行(含现有废弃审核接口权限)。 -- 系统基线 iam_permission:392 行(含车次审核与外接预订独立操作权限)。
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 (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'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 (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'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 (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'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 (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'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 (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'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 (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6404,7 +6404,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type,
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0);
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0);
-- 系统基线 iam_role_permission:551 行(含平台管理员废弃审核授权)。 -- 系统基线 iam_role_permission:573 行(含平台管理员车次审核与外接预订授权)。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3));
@@ -6700,6 +6700,50 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107212,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107212,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107213,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107213,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107214,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107214,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 (5107300,0,'PLATFORM','MENU',0,'platform:overseas-master-data:driver:list','司机管理','/overseas-master-data/drivers','GET',930,'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 (5107301,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:create','新增司机',NULL,'POST',931,'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 (5107302,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:edit','编辑与重提司机档案',NULL,'PUT',932,'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 (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 (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 (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 (5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'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 (5107314,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cancel','取消车次',NULL,'POST',944,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107310,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107311,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107312,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107313,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107314,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 (5107315,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:complete','车次货物结案',NULL,'POST',945,'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 (5107316,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:release','结束车辆司机任务',NULL,'POST',946,'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 (5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'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 (5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'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 (5107330,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-create','新增外接预订',NULL,'POST',960,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107330,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 (5107331,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-edit','修改外接预订',NULL,'PUT',961,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107331,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 (5107332,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-confirm','确认外接承运',NULL,'POST',962,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107332,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 (5107333,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-cancel','取消外接预订',NULL,'POST',963,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107333,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107317,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107318,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107315,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107316,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 (5107320,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-create','新增车辆运维记录',NULL,'POST',950,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107320,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 (5107321,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-edit','更正车辆运维记录',NULL,'PUT',951,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107321,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 (5107322,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-upload','上传车辆运维照片',NULL,'POST',952,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107322,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107300,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107301,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107302,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107303,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107304,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107305,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107220,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107220,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107221,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107221,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107222,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107222,0,CURRENT_TIMESTAMP(3));
+238 -7
View File
@@ -263,7 +263,7 @@ CREATE TABLE ll_delivery_order (
seller_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '卖家姓名', seller_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '卖家姓名',
package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数', package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数',
pallet_count int NOT NULL DEFAULT '0' COMMENT '派送托盘数', pallet_count int NOT NULL DEFAULT '0' COMMENT '派送托盘数',
total_weight_kg decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总重量(千克)', total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空',
total_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总体积(立方米)', total_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总体积(立方米)',
planned_delivery_at datetime(3) DEFAULT NULL COMMENT '预计派送时间', planned_delivery_at datetime(3) DEFAULT NULL COMMENT '预计派送时间',
dispatched_at datetime(3) DEFAULT NULL COMMENT '实际派送时间', dispatched_at datetime(3) DEFAULT NULL COMMENT '实际派送时间',
@@ -365,7 +365,7 @@ CREATE TABLE ll_dispatch_item (
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照',
contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照',
package_count int NOT NULL DEFAULT '0' COMMENT '箱数', package_count int NOT NULL DEFAULT '0' COMMENT '箱数',
gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),未知为NULL,不以材积重代替',
volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)',
volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)',
source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段', source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段',
@@ -1597,9 +1597,10 @@ CREATE TABLE ll_outbound_order (
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态',
package_count int NOT NULL DEFAULT '0' COMMENT '箱数', package_count int NOT NULL DEFAULT '0' COMMENT '箱数',
pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数', pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数',
gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空',
volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)',
volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)',
fee_coverage_json JSON NULL COMMENT '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -1607,7 +1608,7 @@ CREATE TABLE ll_outbound_order (
outbound_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', outbound_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发',
lx_codes varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联一件代发领星入库单代码汇总', lx_codes varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联一件代发领星入库单代码汇总',
recipient_info varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发收件信息', recipient_info varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发收件信息',
request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建或转派送业务请求摘要,用于幂等内容校验',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_no (outbound_no),
UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key),
@@ -1625,7 +1626,7 @@ CREATE TABLE ll_outbound_pallet_item (
destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照',
destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照',
package_count int NOT NULL DEFAULT '0' COMMENT '箱数', package_count int NOT NULL DEFAULT '0' COMMENT '箱数',
gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空',
volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)',
volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)',
picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数', picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数',
@@ -2556,7 +2557,65 @@ CREATE TABLE ll_us_truck_order_status_history (
KEY idx_ll_us_truck_history (customer_id,order_id,created_at) KEY idx_ll_us_truck_history (customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车订单状态历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车订单状态历史';
CREATE TABLE ll_fleet_driver (
id BIGINT NOT NULL COMMENT '内部主键',
driver_no VARCHAR(32) NOT NULL COMMENT '司机业务编号',
warehouse_id BIGINT NULL COMMENT '所属公共仓库引用',
warehouse_code VARCHAR(64) NULL COMMENT '所属仓库编码快照',
warehouse_name VARCHAR(256) NULL COMMENT '所属仓库名称快照',
vehicle_models VARCHAR(128) NULL COMMENT '可驾驶车型描述',
languages_and_area VARCHAR(256) NULL COMMENT '语言及服务区域',
medical_expires_on DATE NULL COMMENT '体检到期日,当前不作为必需资质',
training_expires_on DATE NULL COMMENT '培训安全到期日,当前不作为必需资质',
insurance_expires_on DATE NULL COMMENT '保险到期日,当前不作为必需资质',
name VARCHAR(128) NOT NULL COMMENT '姓名',
phone VARCHAR(64) NOT NULL COMMENT '联系电话',
ownership VARCHAR(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派',
region VARCHAR(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回',
license_number VARCHAR(128) NULL COMMENT '驾驶证编号',
license_type VARCHAR(64) NULL COMMENT '驾驶证类型',
license_issued_on DATE NULL COMMENT '驾驶证签发日期',
license_expires_on DATE NULL COMMENT '驾驶证有效截止日',
license_zone VARCHAR(64) NOT NULL COMMENT '证照有效日期的IANA时区',
license_file_id BIGINT NULL COMMENT '平台驾驶证文件引用',
remark VARCHAR(1000) 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_driver_no (driver_no),
KEY idx_fleet_driver_page (active,review_status,updated_at,id),
KEY idx_fleet_driver_expiry (license_expires_on),
CONSTRAINT chk_fleet_driver_ownership CHECK (ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT chk_fleet_driver_region CHECK (region IN ('DOMESTIC','OVERSEAS')),
CONSTRAINT chk_fleet_driver_review CHECK (review_status IN ('PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_fleet_driver_dates CHECK (license_issued_on IS NULL OR license_expires_on IS NULL OR license_issued_on <= license_expires_on)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台司机档案';
CREATE TABLE ll_fleet_driver_history (
id BIGINT NOT NULL COMMENT '内部主键',
driver_id BIGINT NOT NULL COMMENT '司机内部引用',
record_version BIGINT NOT NULL COMMENT '操作后的档案版本',
action VARCHAR(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作',
reason VARCHAR(1000) NULL COMMENT '操作原因或审核意见',
license_file_id BIGINT NULL COMMENT '当时驾驶证文件引用',
snapshot_json JSON NOT NULL COMMENT '操作后不可变档案快照',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version),
KEY idx_fleet_driver_history_file (driver_id,license_file_id),
CONSTRAINT fk_fleet_driver_history_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机档案及审核历史';
CREATE TABLE ll_vehicle ( CREATE TABLE ll_vehicle (
max_pallet_spaces INT NULL COMMENT '最大标准托位,未知时为空',
max_load_kg DECIMAL(12,3) NULL COMMENT '最大载重,千克',
max_volume_cbm DECIMAL(12,3) NULL COMMENT '可用容积,立方米',
id bigint NOT NULL COMMENT '车辆档案ID', id bigint NOT NULL COMMENT '车辆档案ID',
plate_number varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车牌号', plate_number varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车牌号',
model varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车型', model varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车型',
@@ -2575,9 +2634,181 @@ CREATE TABLE ll_vehicle (
updated_at datetime(3) NOT NULL COMMENT '更新时间', updated_at datetime(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_vehicle_plate (plate_number), UNIQUE KEY uk_ll_vehicle_plate (plate_number),
KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id) KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id),
CONSTRAINT chk_ll_vehicle_capacity CHECK ((max_pallet_spaces IS NULL AND max_load_kg IS NULL AND max_volume_cbm IS NULL)
OR (max_pallet_spaces IS NOT NULL AND max_load_kg IS NOT NULL AND max_volume_cbm IS NOT NULL
AND max_pallet_spaces BETWEEN 1 AND 10000 AND max_load_kg > 0 AND max_volume_cbm > 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台车辆主数据'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台车辆主数据';
CREATE TABLE ll_fleet_vehicle_record (
id BIGINT NOT NULL COMMENT '记录内部主键',
record_no VARCHAR(32) NOT NULL COMMENT '车辆运维业务编号',
vehicle_id BIGINT NOT NULL COMMENT '所属车辆主键',
vehicle_plate VARCHAR(64) NOT NULL COMMENT '建档时车牌快照',
record_type VARCHAR(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常',
occurred_at DATETIME(3) NOT NULL COMMENT '记录发生时间(UTC)',
details_json JSON NOT NULL COMMENT '带明确单位的业务字段快照',
files_json JSON NOT NULL COMMENT '所属记录照片与上传幂等摘要',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原创建业务内容摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建操作人',
updated_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_vehicle_record_no (record_no),
UNIQUE KEY uk_vehicle_record_creation (vehicle_id, created_by, idempotency_key),
KEY idx_vehicle_record_page (vehicle_id, record_type, occurred_at, id),
CONSTRAINT fk_vehicle_record_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id),
CONSTRAINT ck_vehicle_record_type CHECK (record_type IN ('MAINTENANCE','FUEL','REPAIR','BREAKDOWN','RESCUE','EXCEPTION'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录';
CREATE TABLE ll_fleet_vehicle_record_history (
id BIGINT NOT NULL COMMENT '历史内部主键',
record_id BIGINT NOT NULL COMMENT '运维记录主键',
record_version BIGINT NOT NULL COMMENT '对应业务版本',
action VARCHAR(32) NOT NULL COMMENT '操作类型',
reason VARCHAR(1000) DEFAULT NULL COMMENT '更正原因或附件名称',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间(UTC)',
snapshot_json JSON NOT NULL COMMENT '当时完整业务快照',
PRIMARY KEY (id),
UNIQUE KEY uk_vehicle_record_revision (record_id, record_version),
CONSTRAINT fk_vehicle_record_history FOREIGN KEY (record_id) REFERENCES ll_fleet_vehicle_record(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
CREATE TABLE ll_fleet_trip (
id BIGINT NOT NULL COMMENT '内部主键',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '创建请求SHA256',
status VARCHAR(16) NOT NULL COMMENT '车次状态',
warehouse_id BIGINT NOT NULL COMMENT '起运公共仓库引用',
warehouse_code VARCHAR(64) NOT NULL COMMENT '起运仓编码快照',
warehouse_name VARCHAR(256) NOT NULL COMMENT '起运仓名称快照',
vehicle_id BIGINT NULL COMMENT '车辆档案引用',
vehicle_plate VARCHAR(64) NULL COMMENT '分配时车牌快照',
driver_id BIGINT NULL COMMENT '司机档案引用',
driver_name VARCHAR(128) NULL COMMENT '分配时司机姓名',
driver_phone VARCHAR(64) NULL COMMENT '分配时司机电话',
max_pallet_spaces INT NULL COMMENT '分配时托位容量快照',
max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克',
max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米',
planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC',
expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场',
departed_at DATETIME(3) NULL COMMENT '实际发车UTC',
completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC',
stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点',
planned_load_json JSON NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量',
load_json JSON NULL COMMENT '已确认实物装载及客户货物来源快照',
loading_review_json LONGTEXT NULL COMMENT '整车理货审核快照,空值为待审核',
departure_key VARCHAR(128) NULL COMMENT '整车发车幂等标识',
departure_hash CHAR(64) NULL COMMENT '整车发车请求摘要',
departure_result_json JSON NULL COMMENT '整车发车结果及各客户出库结果',
completion_json JSON NULL COMMENT '货物结案来源及幂等结果快照',
vehicle_release_json JSON NULL COMMENT '车辆任务结束地点状态与幂等记录',
driver_release_json JSON NULL COMMENT '司机任务结束地点与幂等记录',
remark VARCHAR(1000) 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_trip_no (trip_no),
UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key),
KEY idx_fleet_trip_page (warehouse_id,status,updated_at,id),
CONSTRAINT fk_fleet_trip_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id),
CONSTRAINT fk_fleet_trip_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id),
CONSTRAINT chk_fleet_trip_status CHECK (status IN ('PLANNED','DEPARTING','IN_TRANSIT','COMPLETED','CANCELLED')),
CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次';
CREATE TABLE ll_fleet_external_booking (
id BIGINT NOT NULL COMMENT '内部主键',
booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号',
trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
customer_id VARCHAR(128) NOT NULL COMMENT '委托客户及付款归属',
customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照',
customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照',
status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消',
cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照',
destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键',
destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称',
agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收',
idempotency_key VARCHAR(128) NOT NULL COMMENT '登记请求键',
request_hash CHAR(64) NOT NULL COMMENT '登记内容摘要',
confirm_key VARCHAR(128) NULL COMMENT '承运确认请求键',
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
incoming_hash CHAR(64) NULL COMMENT '来货登记摘要',
receipt_key VARCHAR(128) NULL COMMENT '实收确认请求键',
receipt_hash CHAR(64) NULL COMMENT '实收确认摘要',
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash 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_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
KEY idx_fleet_booking_trip(trip_id,status,id),
UNIQUE KEY uk_fleet_booking_incoming(incoming_no),
KEY idx_fleet_booking_dispatch(dispatch_no),
CONSTRAINT fk_fleet_booking_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实';
CREATE TABLE ll_fleet_resource_assignment (
resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER',
resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用',
trip_id BIGINT NOT NULL COMMENT '占用车次',
trip_no VARCHAR(32) NOT NULL COMMENT '占用车次业务编号',
PRIMARY KEY (resource_kind,resource_id),
KEY idx_fleet_resource_trip (trip_id),
CONSTRAINT fk_fleet_resource_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_resource_kind CHECK (resource_kind IN ('VEHICLE','DRIVER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次';
CREATE TABLE ll_fleet_trip_dispatch (
dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联',
dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号',
customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属',
trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用',
trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号',
package_count INT NOT NULL COMMENT '关联时待发运箱数快照',
bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人',
bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间',
review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核',
PRIMARY KEY (dispatch_id),
UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no),
KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id),
CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史';
CREATE TABLE ll_fleet_trip_history (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '车次内部引用',
record_version BIGINT NOT NULL COMMENT '操作后版本',
action VARCHAR(32) NOT NULL COMMENT '车次操作',
reason VARCHAR(1000) NULL COMMENT '操作原因',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
snapshot_json JSON NOT NULL COMMENT '操作后不可变车次快照',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version),
CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史';
CREATE TABLE ll_wallet_event_inbox ( CREATE TABLE ll_wallet_event_inbox (
event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内Finance资金结果事件ID', event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内Finance资金结果事件ID',
command_event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外资金指令事件ID', command_event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外资金指令事件ID',
@@ -3020,4 +3251,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, 136 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 145 AS initialized_tables;
+2
View File
@@ -17,6 +17,8 @@
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 |
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
| 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
| 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
@@ -7,6 +7,8 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory { enum BusinessFileCategory {
FLEET_DRIVER_LICENSE(false, FormatPolicy.LICENSE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -59,6 +61,12 @@ enum BusinessFileCategory {
} }
void validateOwner(long tenantId) { void validateOwner(long tenantId) {
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
}
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "驾驶证附件必须归属平台");
}
if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) { if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, throw new BizException(ResultCode.BAD_REQUEST,
this == OCEAN_BOOKING_DOCUMENT ? "订舱附件必须归属平台" : "海外卸柜照片必须归属平台"); this == OCEAN_BOOKING_DOCUMENT ? "订舱附件必须归属平台" : "海外卸柜照片必须归属平台");
@@ -93,7 +101,7 @@ enum BusinessFileCategory {
return Long.toString(value); return Long.toString(value);
} }
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF } enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, LICENSE, IMAGE }
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO } private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
private static final class Limits { private static final class Limits {
private static final long STANDARD = 10L * 1024 * 1024; private static final long STANDARD = 10L * 1024 * 1024;
@@ -64,6 +64,10 @@ public class BusinessFileStorage {
verifyXlsx(file); verifyXlsx(file);
extension = ".xlsx"; extension = ".xlsx";
} else { } else {
if (formatPolicy == BusinessFileCategory.FormatPolicy.IMAGE && !java.util.Set.of("image/jpeg", "image/png").contains(type))
throw bad("运维照片仅支持JPG、PNG");
if (formatPolicy == BusinessFileCategory.FormatPolicy.LICENSE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type))
throw bad("驾驶证附件仅支持PDF、JPG、PNG");
extension = TYPES.get(type); extension = TYPES.get(type);
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
verifyContent(file, type); verifyContent(file, type);
@@ -0,0 +1,89 @@
openapi: 3.0.3
info:
title: 平台司机证照内部文件契约
version: 1.0.0
description: >-
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、
businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性;
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。
上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。
security:
- InternalServiceToken: []
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
post:
summary: 上传平台司机驾驶证
parameters:
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
'200': {$ref: '#/components/responses/Metadata'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
summary: 校验归属并读取元数据
responses:
'200': {$ref: '#/components/responses/Metadata'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
summary: 校验归属并下载真实文件
responses:
'200':
description: 文件内容,业务错误按JSON code识别
content:
application/pdf: {schema: {type: string, format: binary}}
image/jpeg: {schema: {type: string, format: binary}}
image/png: {schema: {type: string, format: binary}}
'403': {description: 内部服务令牌无效}
components:
securitySchemes:
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
parameters:
TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}}
BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_DRIVER_LICENSE]}}
BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
Metadata:
description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string, nullable: true}
data:
type: object
properties:
fileId: {type: string}
tenantId: {type: string, enum: ['0']}
businessType: {type: string, enum: [FLEET_DRIVER_LICENSE]}
businessId: {type: string}
fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
@@ -0,0 +1,11 @@
openapi: 3.0.3
info:
title: 车辆运维照片归属契约
version: 1.0.0
description: >-
复用InternalBusinessFileController的内部业务文件上传、元数据和内容接口,不新增公网File路径。
businessType固定FLEET_VEHICLE_RECORD_PHOTO,tenantId固定0,businessId固定Lastleg运维记录内部主键。
只接受非空JPG/PNG,最大10MiB,同时校验MIME及真实内容签名。拒绝其他客户、记录或类别引用。
Lastleg负责车辆与记录归属、当前附件关联、权限、版本、上传幂等和不可变历史;File不读取Lastleg业务表。
上传不会代表已核验、付款或车辆恢复可派;不返回对象存储路径或匿名图片URL。
paths: {}
@@ -0,0 +1,52 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DriverLicenseFileTest {
@TempDir Path root;
@Test void licensesArePlatformOwnedWithPositiveDriverId() {
var category = BusinessFileCategory.require("FLEET_DRIVER_LICENSE");
category.validateOwner(0);
assertThatThrownBy(() -> category.validateOwner(1)).isInstanceOf(BizException.class).hasMessageContaining("平台");
assertThatThrownBy(() -> category.validateOwner(-1)).isInstanceOf(BizException.class);
assertThat(category.storageBusinessId("71")).isEqualTo("71");
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class);
}
@Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception {
var storage = storage();
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes());
var result = storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE);
assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes());
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
.isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF");
}
@Test void rejectsMoreThanTenMibBeforeReadingBytes() {
var file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(10485761L);
assertThatThrownBy(() -> storage().store(0, "71", file, BusinessFileCategory.FLEET_DRIVER_LICENSE))
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
}
private BusinessFileStorage storage() {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
}
@@ -0,0 +1,25 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
class VehicleCarePhotoTest {
@TempDir Path root;
@Test void recordPhotosArePlatformOwnedAndRequireRealImageBytes() throws Exception {
var category = BusinessFileCategory.require("FLEET_VEHICLE_RECORD_PHOTO");
category.validateOwner(0);
assertThatThrownBy(() -> category.validateOwner(7)).hasMessageContaining("平台");
assertThatThrownBy(() -> category.validateOwner(-1)).hasMessageContaining("平台");
assertThatThrownBy(() -> category.storageBusinessId("0")).hasMessageContaining("有效");
var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot();
byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10};
var result = storage.store(0, "71", new MockMultipartFile("file", "meter.png", "image/png", bytes), category);
assertThat(storage.read(result.objectKey())).isEqualTo(bytes);
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "fake.png", "image/png", "not-an-image".getBytes()), category)).hasMessageContaining("不是有效图片");
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG");
}
}
+1
View File
@@ -28,6 +28,7 @@
| 财务 OpenAPI | 五页 `../../docs/财务管理/openapi/finance-core-five-pages-v1.yaml`;人工财务 `finance-f1-manual-v1.yaml`;资金账户 `finance-wallet-v1.yaml`;柜级聚合 `src/main/resources/openapi/finance-container-settlement-v1.yaml` | | 财务 OpenAPI | 五页 `../../docs/财务管理/openapi/finance-core-five-pages-v1.yaml`;人工财务 `finance-f1-manual-v1.yaml`;资金账户 `finance-wallet-v1.yaml`;柜级聚合 `src/main/resources/openapi/finance-container-settlement-v1.yaml` |
| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java` 的 `/internal/finance/v1/business-fee-source-events`、`service/BusinessFeeSourceEventService.java`;事件模型 `../common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java` | | 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java` 的 `/internal/finance/v1/business-fee-source-events`、`service/BusinessFeeSourceEventService.java`;事件模型 `../common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java` |
| 查询模型、客户可见性与账单行入仓码 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java`;`StatementLine.inboundCode` 从对应费用来源不可变快照读取,供 Client 跳转客户隔离的入仓关系详情 | | 查询模型、客户可见性与账单行入仓码 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java`;`StatementLine.inboundCode` 从对应费用来源不可变快照读取,供 Client 跳转客户隔离的入仓关系详情 |
| 尾程引用原头程费用 | `controller/InternalDeliveryFeeCoverageController.java`、`service/DeliveryFeeCoverageQueryService.java`、`dto/DeliveryFeeCoverageModels.java`;内部`delivery-fee-coverage/sources`按客户及实际原入仓ID批量读取FIRSTLEG已审核有效应收最新版本,仅查询本库,不调用下游、不写费用或资金。OpenAPI `src/main/resources/openapi/finance-delivery-fee-coverage-v1.yaml`;覆盖何种服务由Lastleg运营针对本次货物明确确认,不能由金额或费用名称自动推断 |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` | | 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
| Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` | | Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` |
| Warehouse 供应商校验与名称快照 | `client/WarehouseSupplierClient.java`、`service/FinanceSupplierValidator.java`;供应商事实来源及类别契约见 `../warehouse/AGENTS.md`、`../warehouse/src/main/resources/openapi/supplier-v1.yaml` | | Warehouse 供应商校验与名称快照 | `client/WarehouseSupplierClient.java`、`service/FinanceSupplierValidator.java`;供应商事实来源及类别契约见 `../warehouse/AGENTS.md`、`../warehouse/src/main/resources/openapi/supplier-v1.yaml` |
@@ -0,0 +1,31 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.DeliveryFeeCoverageModels;
import com.baiyue.finance.service.DeliveryFeeCoverageQueryService;
import com.baiyue.finance.service.FinanceEventException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/internal/finance/v1/delivery-fee-coverage")
public class InternalDeliveryFeeCoverageController {
private final DeliveryFeeCoverageQueryService service;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@PostMapping("/sources")
public ApiResponse<List<DeliveryFeeCoverageModels.Fee>> sources(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody DeliveryFeeCoverageModels.Query request) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8)))
throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
return FinanceResponses.success(service.query(request));
}
}
@@ -0,0 +1,14 @@
package com.baiyue.finance.dto;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.util.List;
public final class DeliveryFeeCoverageModels {
private DeliveryFeeCoverageModels() { }
public record Query(@NotNull @Positive Long customerId,
@NotEmpty @Size(max = 2000) List<@NotNull @Positive Long> inboundIds) { }
public record Fee(long financeFeeId, long sourceFeeId, String sourceType, int sourceVersion,
long customerId, long sourceInboundId, String inboundCode, String feeItemCode, String feeItemName,
String currency, BigDecimal amount, String sourceHash, String status) { }
}
@@ -0,0 +1,40 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.DeliveryFeeCoverageModels;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
/** Finance eligibility only. The business operator must explicitly confirm which services are included. */
@Service
@RequiredArgsConstructor
public class DeliveryFeeCoverageQueryService {
private final JdbcTemplate jdbc;
@Transactional(readOnly = true)
public List<DeliveryFeeCoverageModels.Fee> query(DeliveryFeeCoverageModels.Query request) {
return new NamedParameterJdbcTemplate(jdbc).query("""
SELECT f.id,f.source_fee_id,f.source_type,f.source_fee_version,f.customer_id,f.inbound_id,
CASE WHEN NULLIF(TRIM(f.inbound_code),'') IS NOT NULL THEN f.inbound_code
WHEN f.source_type='INBOUND_FEE' THEN f.source_no ELSE NULL END AS coverage_inbound_code,
f.fee_item_code,f.fee_item_name,f.currency_code,f.amount,f.source_hash,f.status
FROM fin_fee_source f
WHERE f.customer_id=:customerId AND f.inbound_id IN (:inboundIds)
AND f.source_system='FIRSTLEG' AND f.source_type IN ('INBOUND_FEE','CONTAINER_FEE')
AND f.direction='RECEIVABLE' AND f.review_status='APPROVED'
AND f.status IN ('AVAILABLE','SETTLED')
AND NOT EXISTS (SELECT 1 FROM fin_fee_source newer
WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type
AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)
ORDER BY f.inbound_id,f.fee_item_code,f.source_fee_id
""", Map.of("customerId", request.customerId(), "inboundIds", request.inboundIds().stream().distinct().toList()),
(row, index) -> new DeliveryFeeCoverageModels.Fee(row.getLong("id"), row.getLong("source_fee_id"),
row.getString("source_type"), row.getInt("source_fee_version"), row.getLong("customer_id"), row.getLong("inbound_id"),
row.getString("coverage_inbound_code"), row.getString("fee_item_code"), row.getString("fee_item_name"),
row.getString("currency_code"), row.getBigDecimal("amount"), row.getString("source_hash"), row.getString("status")));
}
}
@@ -0,0 +1,48 @@
openapi: 3.0.3
info: { title: Delivery fee coverage sources, version: 1.0.0 }
paths:
/internal/finance/v1/delivery-fee-coverage/sources:
post:
summary: 按实际客户及头程入仓单批量读取可引用的原费用版本
description: 仅内部服务身份,Gateway不公开。只返回该客户、指定原入仓单、FIRSTLEG来源的已审核有效应收最新版本;AVAILABLE或SETTLED不代表自动涵盖任何尾程服务,服务范围由运营明确确认。查询不冻结资金、不变更账务、不新增收费。
parameters:
- { in: header, name: X-Internal-Service-Token, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [customerId, inboundIds]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
inboundIds: { type: array, minItems: 1, maxItems: 2000, items: { type: string, pattern: '^[1-9][0-9]*$' } }
responses:
'200':
description: 原费用不可变来源版本;没有符合记录返回空数组
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/FeeSource' } }
'400': { description: 请求字段无效 }
'401': { description: 内部服务令牌无效 }
components:
schemas:
FeeSource:
type: object
properties:
financeFeeId: { type: string }
sourceFeeId: { type: string }
sourceType: { type: string, enum: [INBOUND_FEE, CONTAINER_FEE] }
sourceVersion: { type: integer }
customerId: { type: string }
sourceInboundId: { type: string }
inboundCode: { type: string, nullable: true }
feeItemCode: { type: string }
feeItemName: { type: string }
currency: { type: string }
amount: { type: number }
sourceHash: { type: string }
status: { type: string, enum: [AVAILABLE, SETTLED] }
@@ -0,0 +1,32 @@
package com.baiyue.finance.controller;
import com.baiyue.finance.dto.DeliveryFeeCoverageModels;
import com.baiyue.finance.service.DeliveryFeeCoverageQueryService;
import com.baiyue.finance.service.FinanceEventException;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class InternalDeliveryFeeCoverageControllerTest {
@Test void requiresTheInternalIdentityBeforeQueryingFinancialData() {
var service = mock(DeliveryFeeCoverageQueryService.class);
var controller = new InternalDeliveryFeeCoverageController(service);
ReflectionTestUtils.setField(controller, "expectedToken", "coverage-test-token");
var query = new DeliveryFeeCoverageModels.Query(101L, List.of(11L));
assertThatThrownBy(() -> controller.sources(null, query)).isInstanceOf(FinanceEventException.class);
assertThatThrownBy(() -> controller.sources("wrong-token", query)).isInstanceOf(FinanceEventException.class);
verifyNoInteractions(service);
when(service.query(query)).thenReturn(List.of());
assertThat(controller.sources("coverage-test-token", query).successful()).isTrue();
verify(service).query(query);
}
@Test void validatesCustomerAndInboundScope() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertThat(factory.getValidator().validate(new DeliveryFeeCoverageModels.Query(0L, List.of()))).hasSize(2);
assertThat(factory.getValidator().validate(new DeliveryFeeCoverageModels.Query(101L, List.of(-1L)))).hasSize(1);
}
}
}
@@ -0,0 +1,50 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.DeliveryFeeCoverageModels;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class DeliveryFeeCoverageQueryServiceTest {
JdbcTemplate jdbc;
DeliveryFeeCoverageQueryService service;
@BeforeEach void setup() {
jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_fee_id BIGINT,source_type VARCHAR(32),source_fee_version INT,customer_id BIGINT,inbound_id BIGINT,source_no VARCHAR(64),direction VARCHAR(16),review_status VARCHAR(16),status VARCHAR(16),fee_item_code VARCHAR(64),fee_item_name VARCHAR(128),currency_code VARCHAR(3),amount DECIMAL(20,6),source_hash VARCHAR(71),inbound_code VARCHAR(64))");
service = new DeliveryFeeCoverageQueryService(jdbc);
}
@Test void returnsOnlyThisCustomersActualInboundLatestApprovedReceivables() {
insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
insert(2, 701, 2, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
insert(3, 702, 1, 202, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
insert(4, 703, 1, 101, 11, "FIRSTLEG", "PAYABLE", "APPROVED", "AVAILABLE");
insert(5, 704, 1, 101, 11, "LASTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
insert(6, 705, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "PENDING", "AVAILABLE");
insert(7, 706, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "VOIDED");
insert(8, 707, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "SUPERSEDED");
insert(9, 708, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "SETTLED");
insert(10, 709, 1, 101, 12, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
var rows = service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L, 11L)));
assertThat(rows).extracting(DeliveryFeeCoverageModels.Fee::financeFeeId).containsExactly(2L, 9L);
assertThat(rows).allSatisfy(row -> { assertThat(row.customerId()).isEqualTo(101); assertThat(row.sourceInboundId()).isEqualTo(11); assertThat(row.sourceHash()).startsWith("sha256:"); });
assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(999L)))).isEmpty();
}
@Test void aVoidedLatestVersionCannotFallBackToAnOlderActiveVersion() {
insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
insert(2, 701, 2, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "VOIDED");
assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L)))).isEmpty();
}
@Test void aContainerBusinessNumberIsNotMislabelledAsAnInboundCode() {
insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE");
jdbc.update("UPDATE fin_fee_source SET source_type='CONTAINER_FEE',source_no='MSCU1234567'");
assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L))).get(0).inboundCode()).isNull();
}
void insert(long id, long fee, int version, long customer, long inbound, String system, String direction, String review, String status) {
jdbc.update("INSERT INTO fin_fee_source VALUES(?, ?, ?, 'INBOUND_FEE', ?, ?, ?, ?, ?, ?, ?, 'FREIGHT', '原运费', 'CNY', 100, ?, NULL)",
id, system, fee, version, customer, inbound, "IN-" + inbound, direction, review, status, "sha256:" + "a".repeat(64));
}
}
@@ -74,11 +74,33 @@ VALUES
(5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0), (5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0),
(5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0), (5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0),
(5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0), (5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0),
(5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,0),
(5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,0),
(5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,0),
(5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,0),
(5107314,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cancel','取消车次',NULL,'POST',944,'ACTIVE',1,0),
(5107315,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:complete','车次货物结案',NULL,'POST',945,'ACTIVE',1,0),
(5107316,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:release','结束车辆司机任务',NULL,'POST',946,'ACTIVE',1,0),
(5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,0),
(5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,0),
(5107330,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-create','新增外接预订',NULL,'POST',960,'ACTIVE',1,0),
(5107331,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-edit','修改外接预订',NULL,'PUT',961,'ACTIVE',1,0),
(5107332,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-confirm','确认外接承运',NULL,'POST',962,'ACTIVE',1,0),
(5107333,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-cancel','取消外接预订',NULL,'POST',963,'ACTIVE',1,0),
(5107300,0,'PLATFORM','MENU',0,'platform:overseas-master-data:driver:list','司机管理','/overseas-master-data/drivers','GET',930,'ACTIVE',1,0),
(5107301,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:create','新增司机',NULL,'POST',931,'ACTIVE',1,0),
(5107302,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:edit','编辑与重提司机档案',NULL,'PUT',932,'ACTIVE',1,0),
(5107303,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:status','启停司机',NULL,'PATCH',933,'ACTIVE',1,0),
(5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,0),
(5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,0),
(5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0), (5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0),
(5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0), (5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0),
(5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0), (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0),
(5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0), (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0),
(5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0), (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0),
(5107320,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-create','新增车辆运维记录',NULL,'POST',950,'ACTIVE',1,0),
(5107321,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-edit','更正车辆运维记录',NULL,'PUT',951,'ACTIVE',1,0),
(5107322,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-upload','上传车辆运维照片',NULL,'POST',952,'ACTIVE',1,0),
(5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,0), (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,0),
(5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,0), (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,0),
(5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,0), (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,0),
@@ -32,7 +32,16 @@ class OverseasPhase4PermissionSqlContractTest {
"platform:overseas-business:looseboard:list", "platform:overseas-business:looseboard:list",
"platform:overseas-master-data:quote:list", "platform:overseas-master-data:quote:list",
"platform:overseas-master-data:exchange-rate:list", "platform:overseas-master-data:exchange-rate:list",
"platform:overseas-master-data:vehicle:list"); "platform:overseas-master-data:vehicle:list",
"platform:overseas-master-data:driver:list",
"platform:overseas-master-data:driver:create",
"platform:overseas-master-data:driver:edit",
"platform:overseas-master-data:driver:status",
"platform:overseas-master-data:driver:review",
"platform:overseas-master-data:driver:upload",
"platform:overseas-master-data:vehicle:record-create",
"platform:overseas-master-data:vehicle:record-edit",
"platform:overseas-master-data:vehicle:record-upload");
@Test @Test
void canonicalCatalogDisablesLegacyCodesAndExcludesFrozenDomains() throws Exception { void canonicalCatalogDisablesLegacyCodesAndExcludesFrozenDomains() throws Exception {
@@ -42,7 +51,7 @@ class OverseasPhase4PermissionSqlContractTest {
assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains(REQUIRED.toArray(String[]::new));
assertThat(sql).contains("'lastleg:pda:operate'"); assertThat(sql).contains("'lastleg:pda:operate'");
assertPdaPickerGrant(sql); assertPdaPickerGrant(sql);
assertThat(catalog).hasSize(59).noneMatch(code -> code.endsWith(":view")); assertThat(catalog).hasSize(68).noneMatch(code -> code.endsWith(":view"));
assertThat(sql).contains( assertThat(sql).contains(
"(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); "(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'");
assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()", assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()",
+20 -1
View File
@@ -11,7 +11,7 @@
事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/lastleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许卸柜、派送、暂扣、资金指令与费用事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/lastleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许卸柜、派送、暂扣、资金指令与费用事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。
本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 本表只保留定位入口;先沿目标代码的实际引用追踪。状态、权限、资源归属或接口边界受影响且本文件不足以判断时,再用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段;纯文案、局部重构或已知文件定位不自动增加详细索引。不得全文加载无关业务约束。
退货预报、签收、库存、合箱与退货出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程来源托盘快照:`inboundplan/`。这两类入口的补充段落也在详细索引中,按对应短词查询。 退货预报、签收、库存、合箱与退货出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程来源托盘快照:`inboundplan/`。这两类入口的补充段落也在详细索引中,按对应短词查询。
@@ -60,6 +60,14 @@
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` | | 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;OpenAPI `fleet-vehicle-records-v1.yaml`;逐车及逐类型隔离,保养加油显式单位,事件保留项目/地点/处理人;申报费用须有币种,处理完成不代表付款,当前无财务关联只返回UNLINKED。创建/上传幂等,更正保留版本及历史,不自动变更主档里程、车辆状态或财务 |
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 |
| 调度与理货审核 | `FleetTripService.reviewDispatch`、`FleetTripExecutionService.reviewLoading`;每张客户排车单先调度通过,整车确认实载后理货通过。`FleetDispatchBindingGuard.requirePickingApproved` 在新拣货/完成前核对当前单审核;已成功操作的同键重放保留原结果 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与整车PDA发车 | `fleet/trip/FleetTripExecutionService.java`、`FleetExecutionModels.java`;平台 `/fleet/trips/{tripNo}/loading`,PDA沿用 `/dispatches/{dispatchNo}/depart` 但提交车次版本并返回整车结果;`DispatchPdaWorkflowService.depart` 为车次事务内子操作,不独立对外调用 |
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` | | 测试 | `src/test/java/com/baiyue/lastleg/` |
@@ -71,13 +79,24 @@
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 - 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。
- 共享车次关联取排车单自身客户与起运仓;排车有效关联唯一,多客户不合并库存归属。`FleetDispatchBindingGuard` 在子单行锁内阻止独立编辑/移托/取消及绕过车次协调单独发车;转派送的新建请求不能覆盖车次车辆司机,已成功转换的重放继续返回既有单据。PDA详情同步返回车次统一发车阻断原因;关联与改派通过 `FleetDispatchGateway` 同事务同步尚未执行的派送快照。已拣货禁止直接解除关联或变更路线;发车前可统一换车/司机或保持路线调整时间,保留拣货事实与到达时间,重验司机任务覆盖及替换车辆对已确认装载的容量,成功后调度审核、理货审核及原装载确认失效;仅修改备注保留审核与装载。已出库/发车不允许此类修改。
- 实物装载按仓库托盘ID去重计量,客户货量与物理占位分别保存;重量/体积为明确确认的实际值,不读取计费材积重回填。路线卸货站的 `destinationKey` 必须匹配原货物目的地,同实托不同目的地先拆托。装载确认保存来源hash、版本、操作人和历史;改计划/改派/关联变化使确认失效。整车发车在同一Lastleg事务中复核所有子单、车辆占用及司机证照,调用原子单出库命令后统一提交 `IN_TRANSIT` 和幂等结果;任一失败全车回滚,`DEPARTING`只在事务内部使用。PDA列表按车次复用发车门禁查询,再按当前客户排车单审核返回canPick/pickBlockReason;客户A通过不能代替客户B通过。重新确认装载或重新调度审核使理货审核待审,审核历史不删除;过期前30天提醒与阻断分别返回。
- 车次货物结案只消费原派送单事实:已签收须有signedAt,已退回须有RETURN_COMPLETED操作记录;拒收、待退回和取消不能冒充完成。完成时间为本车最后一票实际终态时间,不取最近编辑时间;完成不改客户状态、不生成费用、不释放占用。车辆与司机分别结束实际任务并保留地点/时间,车辆状态与资源释放同事务提交;司机证照过期不阻止结束旧任务,但不恢复其新任务资格。原释放请求重放不影响资源后来已分配的新车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 - 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。
- 外接预订以父车次行锁串行确认容量;DRAFT不占位,CONFIRMED占位,CANCELLED释放,创建/确认/取消保留请求键与内容摘要。报价与记录快照进入既有车次历史。重新核对自有货计划不得占用已确认外接容量,换小车须能容纳两者;同车只换司机不额外要求先重新核对容量。CONFIRMED和RECEIVED尚未绑定实际发运时阻止整车发车;通过真实来货实测、运费确认及来源核对后转为BOUND,解除未开工排车关联回RECEIVED。报价或登记来货不冒充库存/已出库;取消未执行预订仅释放承运容量,来货及已入库货物保留。
- 外接约定运费由`fleet/trip/FleetBookingFreight`按排车来源、绑定车次、目的地和完整实收量复核;`DeliveryTransferService`在原转派送事务内发布到对应目的地派送单的`OVERSEAS_DELIVERY_FEE`,通过既有费用Outbox进入Finance。预览只读且使用dispatch:list;确认原货物/报价摘要后写入,全部外接货禁止再填派送应收,额外装车/买约仍独立。明确零约定留操作事实不发布虚构零费用。首版确认承运币种限现行费用支持的CNY/USD,原头程费用覆盖按下条处理。
- 原运费覆盖由`delivery/service/DeliveryFeeCoverageService`与`DispatchFeeCargoMapper`按实际库存组成及本次已拣数量还原,核平每条排车箱数;不可用MIN展示入仓码替代整托全部来源。`FinanceFeeCoverageClient`批量只读Finance,平台转派送按费用ID/版本/hash和当前货物/目的地摘要复核,只有全部相关货物已覆盖的服务才免新增应收,供应商应付独立。客户接口禁止自行声明覆盖。确认快照保存到`ll_outbound_order.fee_coverage_json`,原费用不复制、不抵扣金额、不生成伪零费用;详情按本派送货物过滤只读快照,之后财务更正不自动改写旧发运。普通无引用请求保持原新增收费要求。
- 派送出库取消按dispatch→outbound加锁,核对排车未拣货/发车并先解除车次关联;取消出库不代替取消排车占用。来源订单按本次转派送历史的原状态恢复,显式清空出库/派送关联并增加版本,不统一写RECEIVED。只对本派送当前已审核有效的DELIVERY_FEE应收申请作废,其他费用保留核对,完整取消原因分段保存在派送操作记录。
- `financefee/state/OverseasFeeCancellation`从不可变费用Outbox批量读取voidStatus;请求/拒绝/未知结果保留ACTIVE,只有Finance匹配APPLIED/DUPLICATE后,在投递审计同事务中落VOIDED。撤销候选必须等待原发布成功,未满足前置的PENDING撤销在LIMIT前排除,避免堵住后续投递;过期PROCESSING仍按租约恢复。费用调整使用READ_COMMITTED并在来源行锁后检查未决撤销,禁止旧快照漏检。前端与API区分待财务确认、待财务处理和确认作废,不能以物流取消或HTTP请求发出认定账务已撤销。
## 数据库表索引与注释约束 ## 数据库表索引与注释约束
| 业务域 | 表名 | 中文含义 | | 业务域 | 表名 | 中文含义 |
|---|---|---| |---|---|---|
| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 |
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | | 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 |
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | | 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、按托盘/入仓单拆分的仓位、流水、命令幂等及实收快照;同一托盘可有多条入仓单仓位,汇总数量必须与各明细核平 | | 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、按托盘/入仓单拆分的仓位、流水、命令幂等及实收快照;同一托盘可有多条入仓单仓位,汇总数量必须与各明细核平 |
@@ -2,6 +2,24 @@
## 1. 范围与生成方式 ## 1. 范围与生成方式
### 当前维护入口(2026-10-02)
现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。
- 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。
- 25条已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。
- 未审阅的新增类引用仍按原切片门禁拦截;车辆应用层的向内依赖规则对所有引用执行,不能通过逐类记录绕过。fleet继续只消费车辆应用端口/服务,不读取车辆持久化适配器。
- 主源码禁止包循环。`DeliveryDocumentSnapshot` 是DTO快照解析和摘要装配,已从 `delivery/model` 移至 `delivery/service`,消除原DTO与model互相引用;枚举及公开JSON结构不变。不为循环设长期例外。
- 离线反向测试覆盖未授权新消费者、退役类型恢复/引用、通配记录、绕过车辆应用层和新增包环。通过门禁只证明这些结构规则,不证明消除了所有存量耦合或完成部署/业务验收。
### 车次接入边界
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环、fleet不反向导入dispatch/delivery、dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去这条聚合环。
2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务流程、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在其他专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。模块内 POM 只增加由根 POM 统一版本管理的 ArchUnit 测试依赖。 本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务流程、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在其他专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。模块内 POM 只增加由根 POM 统一版本管理的 ArchUnit 测试依赖。
可重复命令: 可重复命令:
@@ -216,3 +234,5 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
- `dispatch-delivery -> dropship` 是一件代发出库的稳定业务依赖:出库应用服务按客户范围读取一件代发托盘、箱唛和 SKU 库存,并在 PDA 出库事务中完成扣减。 - `dispatch-delivery -> dropship` 是一件代发出库的稳定业务依赖:出库应用服务按客户范围读取一件代发托盘、箱唛和 SKU 库存,并在 PDA 出库事务中完成扣减。
- `tailleg-handoff -> dispatch-delivery` 是异常退回与客户回访的稳定业务依赖:异常单和回访应用服务按派送业务号读取派送单及操作记录,不引入反向兼容入口或跨服务共享模型。 - `tailleg-handoff -> dispatch-delivery` 是异常退回与客户回访的稳定业务依赖:异常单和回访应用服务按派送业务号读取派送单及操作记录,不引入反向兼容入口或跨服务共享模型。
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。 - 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。
@@ -3,6 +3,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { pathToFileURL } from 'node:url';
const moduleRoot = path.resolve(import.meta.dirname, '..'); const moduleRoot = path.resolve(import.meta.dirname, '..');
const sourceRoots = { const sourceRoots = {
@@ -12,6 +13,7 @@ const sourceRoots = {
const internalPrefix = 'com.baiyue.lastleg.'; const internalPrefix = 'com.baiyue.lastleg.';
const sliceRules = [ const sliceRules = [
['fleet', /^com\.baiyue\.lastleg\.fleet(?:\.|$)/],
['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/],
['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/],
['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/],
@@ -269,13 +271,34 @@ function renderDot(report, graph) {
return lines.join('\n'); return lines.join('\n');
} }
function checkContract(report, contractPath) { export function contractViolations(report, contract) {
const absolute = path.resolve(process.cwd(), contractPath);
const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
const allowed = new Set(contract.allowedSliceEdges ?? []); const allowed = new Set(contract.allowedSliceEdges ?? []);
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); const reviewed = new Set();
const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`); const violations = [];
if (contract.forbidPackageCycles) {
for (const cycle of report.packageSccs) violations.push(`package cycle: ${cycle.join(' -> ')}`);
}
for (const edge of contract.reviewedClassDependencies ?? []) {
if (!edge.source || !edge.target || !edge.reason?.trim() || /[*]/.test(edge.source + edge.target)) {
violations.push('reviewed dependency requires exact classes and a rationale');
continue;
}
reviewed.add(`${edge.source}->${edge.target}`);
}
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
const governed = report.classDependencies.filter(edge => !reviewed.has(`${edge.source}->${edge.target}`));
for (const edge of governed) {
const from = classesByName.get(edge.source)?.slice, to = classesByName.get(edge.target)?.slice;
if (from && to && from !== to && !allowed.has(`${from}->${to}`)) {
violations.push(`unreviewed slice dependency: ${from}->${to}: ${edge.source}->${edge.target}`);
}
}
for (const entry of report.classes) {
if ((contract.forbiddenSlices ?? []).includes(entry.slice)) violations.push(`retired slice returned: ${entry.fqcn}`);
for (const type of [entry.fqcn, ...entry.imports]) {
if ((contract.forbiddenTypePrefixes ?? []).some(prefix => type.startsWith(prefix))) violations.push(`retired security type referenced: ${entry.fqcn} -> ${type}`);
}
}
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
const actualClass = classesByName.get(fqcn); const actualClass = classesByName.get(fqcn);
if (!actualClass) violations.push(`required class missing: ${fqcn}`); if (!actualClass) violations.push(`required class missing: ${fqcn}`);
@@ -300,8 +323,11 @@ function checkContract(report, contractPath) {
} }
} }
for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) {
const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); // A reviewed class reference does not authorize any other consumer of that slice.
const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); const outgoing = [...new Set(governed.filter(edge => classesByName.get(edge.source)?.slice === slice)
.map(edge => classesByName.get(edge.target)?.slice).filter(target => target && target !== slice))].sort();
const incoming = [...new Set(governed.filter(edge => classesByName.get(edge.target)?.slice === slice)
.map(edge => classesByName.get(edge.source)?.slice).filter(source => source && source !== slice))].sort();
if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) { if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) {
violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`);
} }
@@ -309,19 +335,25 @@ function checkContract(report, contractPath) {
violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`);
} }
} }
if (violations.length > 0) { return violations;
process.stderr.write(`Lastleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
process.exitCode = 1;
}
} }
const options = parseArgs(process.argv.slice(2)); if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const report = buildReport(options.sourceSet); const options = parseArgs(process.argv.slice(2));
if (options.check) checkContract(report, options.check); const report = buildReport(options.sourceSet);
const rendered = options.format === 'json' if (options.check) {
? `${JSON.stringify(report, null, 2)}\n` const contract = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), options.check), 'utf8'));
: options.format === 'markdown' const violations = contractViolations(report, contract);
? renderMarkdown(report) if (violations.length) {
: renderDot(report, options.graph); process.stderr.write(`Lastleg dependency contract violations in ${options.check}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); process.exitCode = 1;
else process.stdout.write(rendered); }
}
const rendered = options.format === 'json'
? `${JSON.stringify(report, null, 2)}\n`
: options.format === 'markdown'
? renderMarkdown(report)
: renderDot(report, options.graph);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);
}
@@ -1,13 +1,41 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { contractViolations } from './analyze-lastleg-dependencies.mjs';
const moduleRoot = path.resolve(import.meta.dirname, '..'); const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs'); const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
test('fleet stays a business slice and its policies have no infrastructure dependencies', () => {
const report = analyze();
const classes = report.classes.filter(entry => entry.fqcn.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(classes.some(entry => entry.fqcn.endsWith('.DriverService')));
assert.ok(classes.every(entry => entry.slice === 'fleet'));
assert.equal(report.classes.find(entry => entry.fqcn === 'com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter').slice, 'dispatch-delivery');
const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
assert.equal(policyClasses.length, 3);
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['overseas-master-data', 'shared-technical']);
for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) {
assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters');
}
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
assert.equal(report.classDependencies.some(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false);
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)),
'dispatch must use fleet ports and guards, not its persistence mappers');
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(incomingEdges.length > 0);
assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only');
});
function analyze(sourceSet = 'main') { function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot, cwd: moduleRoot,
@@ -43,6 +71,7 @@ test('vehicle master data remains the reviewed low-coupling first-slice candidat
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord', 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord',
'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService', 'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService',
'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels', 'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels',
'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard',
'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository', 'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository',
]); ]);
const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix) const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix)
@@ -53,8 +82,9 @@ test('vehicle master data remains the reviewed low-coupling first-slice candidat
source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController',
target: 'com.baiyue.lastleg.security.OverseasPermissions', target: 'com.baiyue.lastleg.security.OverseasPermissions',
}]); }]);
assert.deepEqual(incoming, []); assert.ok(incoming.length > 0);
assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false); assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.')));
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith(vehiclePrefix))), false);
assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false); assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false);
assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false); assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false);
}); });
@@ -63,7 +93,7 @@ test('vehicle application only depends inward and stays free of adapter framewor
const report = analyze(); const report = analyze();
const prefix = `${vehiclePrefix}.application`; const prefix = `${vehiclePrefix}.application`;
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix));
assert.equal(applicationClasses.length, 3); assert.equal(applicationClasses.length, 4);
for (const entry of applicationClasses) { for (const entry of applicationClasses) {
assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false);
@@ -75,24 +105,49 @@ test('vehicle application only depends inward and stays free of adapter framewor
assert.deepEqual(outwardDependencies, []); assert.deepEqual(outwardDependencies, []);
}); });
test('security projection stays isolated from Lastleg business slices', () => { test('retired Lastleg security projection stays absent under the unified Auth contract', () => {
const report = analyze(); const report = analyze();
const projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection'); const projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection');
const projectionNames = new Set(projectionClasses.map(entry => entry.fqcn)); assert.deepEqual(projectionClasses, []);
assert.deepEqual(projectionClasses.map(entry => entry.fqcn).sort(), [ assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), []);
'com.baiyue.lastleg.security.controller.LastlegSecurityController',
'com.baiyue.lastleg.security.dto.LastlegSecurityDtos',
'com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror',
'com.baiyue.lastleg.security.service.LastlegSecurityProjectionService',
'com.baiyue.lastleg.security.service.LastlegSecurityValidationService',
'com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity',
]);
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), [
{ source: 'security-projection', target: 'shared-technical', count: 1 },
]);
assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []); assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []);
assert.equal(report.sliceSccs.some(component => component.includes('security-projection')), false); assert.match(readFileSync(path.join(moduleRoot, 'pom.xml'), 'utf8'), /<artifactId>baiyue-common-security<\/artifactId>/);
const businessConsumers = report.classDependencies.filter(edge => projectionNames.has(edge.target) });
&& !projectionNames.has(edge.source));
assert.deepEqual(businessConsumers, []); test('reviewed references do not allow another consumer into the same slice', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
assert.deepEqual(contractViolations(report, baseline), []);
const source = 'com.baiyue.lastleg.controller.LastlegOrderController';
const target = 'com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper';
report.classDependencies.push({ source, target });
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`)));
});
test('restoring a retired projection or importing its missing type still fails', () => {
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const report = analyze();
const service = report.classes.find(entry => entry.fqcn.endsWith('.FleetTripService'));
service.imports.push('com.baiyue.lastleg.security.service.LastlegSecurityValidationService');
assert.ok(contractViolations(report, baseline).some(message => message.includes('retired security type referenced')));
service.imports.pop();
report.classes.push({ ...service, fqcn: 'com.baiyue.lastleg.security.service.LastlegSecurityValidationService', slice: 'security-projection' });
assert.ok(contractViolations(report, baseline).some(message => message.includes('retired slice returned')));
});
test('reviewed references cannot bypass the vehicle application boundary or use wildcard grants', () => {
const report = analyze(), baseline = JSON.parse(readFileSync(contract, 'utf8'));
const source = `${vehiclePrefix}.application.VehicleApplicationService`, target = `${vehiclePrefix}.adapter.out.persistence.VehicleMapper`;
report.classDependencies.push({ source, target });
baseline.reviewedClassDependencies.push({ source, target, reason: 'invalid attempted waiver' });
assert.ok(contractViolations(report, baseline).some(message => message.includes('forbidden application dependency')));
baseline.reviewedClassDependencies.push({ source: 'com.baiyue.lastleg.*', target, reason: 'invalid wildcard' });
assert.ok(contractViolations(report, baseline).some(message => message.includes('requires exact classes')));
});
test('package cycles are rejected even when every slice edge is otherwise allowed', () => {
const report = analyze(), baseline = JSON.parse(readFileSync(contract, 'utf8'));
assert.deepEqual(report.packageSccs, []);
report.packageSccs.push(['com.baiyue.lastleg.delivery.dto', 'com.baiyue.lastleg.delivery.model']);
assert.ok(contractViolations(report, baseline).some(message => message.startsWith('package cycle:')));
}); });
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
@FeignClient(name = "finance-service", contextId = "financeFeeCoverageClient", url = "${baiyue.clients.finance.url:}")
public interface FinanceFeeCoverageClient {
@PostMapping("/internal/finance/v1/delivery-fee-coverage/sources")
ApiResponse<List<Fee>> sources(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Query query);
record Query(Long customerId, List<Long> inboundIds) { }
record Fee(Long financeFeeId, Long sourceFeeId, String sourceType, int sourceVersion,
Long customerId, Long sourceInboundId, String inboundCode, String feeItemCode, String feeItemName,
String currency, BigDecimal amount, String sourceHash, String status) { }
}
@@ -23,6 +23,14 @@ import org.springframework.web.server.ResponseStatusException;
public class DeliveryTransferController { public class DeliveryTransferController {
@Resource private DeliveryTransferService service; @Resource private DeliveryTransferService service;
@GetMapping("/dispatches/{dispatchNo}/external-freight")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
public ApiResponse<com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> externalFreight(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(service.externalFreight(dispatchNo));
}
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery") @PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
@Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse") @Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse")
@@ -116,7 +124,7 @@ public class DeliveryTransferController {
@PathVariable String outboundNo, @PathVariable String outboundNo,
@Valid @RequestBody DeliveryRequests.Cancel request) { @Valid @RequestBody DeliveryRequests.Cancel request) {
requirePlatform(user); requirePlatform(user);
service.cancelOutbound(outboundNo, request); service.cancelOutbound(outboundNo, operator(user), request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService;
import com.baiyue.lastleg.security.OverseasPermissions;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/dispatches")
public class PlatformDeliveryFeeCoverageController {
private final DeliveryFeeCoverageService service;
@GetMapping("/{dispatchNo}/fee-coverage")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
public ApiResponse<DeliveryFeeCoverageModels.Preview> preview(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return ApiResponse.success(service.preview(dispatchNo));
}
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.delivery.dto;
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
import java.util.List;
import java.time.Instant;
import jakarta.validation.constraints.*;
public final class DeliveryFeeCoverageModels {
private DeliveryFeeCoverageModels() { }
public record Cargo(Long dispatchItemId, Long warehousePalletId, String palletNo, Long orderId,
String sourceRegion, String sourceInboundId, String inboundCode, long packages, String dispatchInboundCode) { }
public record Preview(String dispatchNo, boolean sourceComplete, String blockReason, String cargoHash,
List<Cargo> cargo, List<FinanceFeeCoverageClient.Fee> feeSources) { }
public record Selection(@NotNull @Positive Long financeFeeId, @Min(1) int sourceVersion,
@NotBlank @Size(max=128) String sourceHash, @NotEmpty @Size(max=3) List<@NotBlank @Pattern(regexp="DELIVERY_FEE|LOADING_FEE|APPOINTMENT_FEE") String> services) { }
public record Reference(FinanceFeeCoverageClient.Fee source, List<String> services) { }
public record Evidence(String cargoHash, List<Cargo> cargo, List<Reference> references, String operatorId, Instant confirmedAt) { }
}
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection;
import java.util.List; import java.util.List;
import java.util.Collections; import java.util.Collections;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -30,7 +31,10 @@ public final class DeliveryRequests {
@Size(max = 128) String appointmentAccount, @Size(max = 128) String appointmentAccount,
@Size(max = 128) String sellerName, @Size(max = 128) String sellerName,
@Size(max = 1000) String remark, @Size(max = 1000) String remark,
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees) {} @NotNull @Valid @Size(max = 20) List<FeeItem> fees,
@Size(max = 64) String externalFreightHash,
@Size(max = 64) String feeCoverageCargoHash,
@Size(max = 2000) List<@NotNull @Valid Selection> feeCoverage) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType, public record FeeItem(@NotBlank @Size(max = 64) String feeType,
@NotBlank @Size(max = 128) String feeName, @NotBlank @Size(max = 128) String feeName,
@@ -42,13 +42,14 @@ public final class DeliveryResponses {
BigDecimal weightKg, BigDecimal volumeCbm) {} BigDecimal weightKg, BigDecimal volumeCbm) {}
public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency, public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency,
BigDecimal amount, String remark) {} BigDecimal amount, String remark, String reviewStatus, String sourceStatus, String voidStatus) {}
public record DeliveryOperationRow(Long id, LocalDateTime operatedAt, String operationNode, public record DeliveryOperationRow(Long id, LocalDateTime operatedAt, String operationNode,
String operatorName, String result) {} String operatorName, String result) {}
public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus, public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus,
List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations) {} List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations,
DeliveryFeeCoverageModels.Evidence feeCoverage) {}
/** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */ /** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */
public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode, public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode,
@@ -30,6 +30,7 @@ public class OutboundOrder {
private BigDecimal volumeCbm; private BigDecimal volumeCbm;
private String idempotencyKey; private String idempotencyKey;
private String requestHash; private String requestHash;
private String feeCoverageJson;
private String createdBy; private String createdBy;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -10,6 +10,8 @@ import java.util.List;
@Mapper @Mapper
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> { public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
@Select("SELECT MAX(created_at) FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} AND operation_node='RETURN_COMPLETED'")
java.time.LocalDateTime returnedAt(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id") @Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id")
List<DeliveryOperation> findByDelivery(@Param("customerId") String customerId, List<DeliveryOperation> findByDelivery(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId); @Param("deliveryOrderId") Long deliveryOrderId);
@@ -9,6 +9,8 @@ import java.util.List;
@Mapper @Mapper
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> { public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
List<DeliveryOrder> findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1") @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE") @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE")
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */
@Mapper
public interface DispatchFeeCargoMapper {
@Select("""
SELECT di.id AS dispatchItemId,di.warehouse_pallet_id AS warehousePalletId,di.pallet_no AS palletNo,
b.inbound_order_id AS orderId,o.source_region AS sourceRegion,o.source_inbound_id AS sourceInboundId,
b.inbound_code AS inboundCode,SUM(p.available_packages+COALESCE(picked.packages,0)) AS packages,di.inbound_code AS dispatchInboundCode
FROM ll_dispatch_item di
JOIN ll_inventory_position p ON p.customer_id=di.customer_id AND p.pallet_id=di.warehouse_pallet_id
AND p.overseas_warehouse_id=di.overseas_warehouse_id
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=di.customer_id
LEFT JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=di.customer_id
LEFT JOIN (SELECT pk.dispatch_item_id,pk.inventory_position_id,SUM(pk.packages) AS packages
FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id AND task.customer_id=pk.customer_id
WHERE task.dispatch_order_id=#{dispatchId} AND pk.customer_id=#{customerId}
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId}
AND (di.plan_pallet_id IS NULL OR EXISTS (
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=di.customer_id
WHERE a.pallet_id=di.plan_pallet_id AND line.order_id=b.inbound_order_id))
GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code
HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
ORDER BY di.id,b.inbound_order_id
""")
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
}
@@ -27,6 +27,7 @@ import java.util.*;
@Service @Service
public class DeliveryDocumentService { public class DeliveryDocumentService {
@Resource private DeliveryOrderMapper orders; @Resource private DeliveryOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private DeliveryDocumentRevisionMapper revisions; @Resource private DeliveryDocumentRevisionMapper revisions;
@Resource private DeliveryOperationMapper operations; @Resource private DeliveryOperationMapper operations;
@Resource private DeliveryDocumentFileGateway files; @Resource private DeliveryDocumentFileGateway files;
@@ -84,16 +85,19 @@ public class DeliveryDocumentService {
return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values); return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values);
}).toList(); }).toList();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty();
boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus());
if (autoCompleted) {
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) throw conflict("当前派送状态不允许通过签收文件完成");
// A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible.
var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo());
if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送");
}
order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim()); order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim());
order.setDocumentsJson(write(categories)); order.setDocumentsJson(write(categories));
order.setVersion(version(order) + 1); order.setVersion(version(order) + 1);
order.setUpdatedAt(now); order.setUpdatedAt(now);
boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty();
boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus());
if (autoCompleted) { if (autoCompleted) {
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) {
throw conflict("当前派送状态不允许通过签收文件完成");
}
order.setStatus("DELIVERED"); order.setStatus("DELIVERED");
order.setSignedAt(now); order.setSignedAt(now);
} }
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.delivery.model; package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import java.time.Instant;
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class DeliveryFeeCoverageService {
private final DispatchOrderMapper dispatches;
private final DispatchItemMapper items;
private final DispatchFeeCargoMapper sources;
private final FinanceFeeCoverageClient finance;
private final ObjectMapper json;
private final String token;
public DeliveryFeeCoverageService(DispatchOrderMapper dispatches, DispatchItemMapper items, DispatchFeeCargoMapper sources,
FinanceFeeCoverageClient finance, ObjectMapper json, @Value("${baiyue.security.internal-service-token}") String token) {
this.dispatches=dispatches; this.items=items; this.sources=sources; this.finance=finance; this.json=json; this.token=token;
}
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public DeliveryFeeCoverageModels.Preview preview(String dispatchNo) {
var dispatch = dispatches.findGlobalByDispatchNo(dispatchNo.trim());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
var cargo = sources.list(dispatch.getCustomerId(), dispatch.getId());
String reason = incomplete(selected, cargo);
var inboundIds = new TreeSet<Long>();
if (reason == null) for (var row : cargo) if ("FIRSTLEG".equals(row.sourceRegion())) {
try { long id = Long.parseLong(row.sourceInboundId()); if (id < 1) throw new NumberFormatException(); inboundIds.add(id); }
catch (RuntimeException invalid) { reason = "货物缺少原头程入仓单关联,请先核对来源"; break; }
}
List<FinanceFeeCoverageClient.Fee> fees = List.of();
if (reason == null && !inboundIds.isEmpty()) fees = load(dispatch.getCustomerId(), List.copyOf(inboundIds));
try {
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
json.writeValueAsString(List.of(dispatch.getDispatchNo(), dispatch.getCustomerId(), cargo,
selected.stream().map(item -> Arrays.asList(item.getId(), item.getPackageCount(), item.getOverseasWarehouseId(),
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(),
item.getDeliveryAddress(), item.getPostalCode())).toList())).getBytes(StandardCharsets.UTF_8)));
return new DeliveryFeeCoverageModels.Preview(dispatch.getDispatchNo(), reason == null, reason, hash, cargo, fees);
} catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException(error); }
}
private List<FinanceFeeCoverageClient.Fee> load(String customerId, List<Long> inboundIds) {
try {
var result = finance.sources(token, new FinanceFeeCoverageClient.Query(Long.valueOf(customerId), inboundIds));
if (result == null || !result.successful() || result.data() == null) throw unavailable();
if (result.data().stream().anyMatch(fee -> fee.financeFeeId() == null || fee.financeFeeId() < 1 || fee.sourceFeeId() == null
|| !Objects.equals(fee.customerId(), Long.valueOf(customerId)) || fee.sourceVersion() < 1
|| !inboundIds.contains(fee.sourceInboundId()) || fee.sourceHash() == null || fee.sourceHash().isBlank()
|| fee.amount() == null || fee.amount().signum() < 0 || !"CNY".equals(fee.currency())
|| !Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(fee.sourceType())
|| !Set.of("AVAILABLE", "SETTLED").contains(fee.status()))) throw unavailable();
return result.data();
} catch (RuntimeException error) { throw unavailable(); }
}
private static String incomplete(List<DispatchItem> selected, List<DeliveryFeeCoverageModels.Cargo> cargo) {
if (selected.isEmpty()) return "排车单没有待发运货物";
var totals = cargo.stream().collect(Collectors.groupingBy(DeliveryFeeCoverageModels.Cargo::dispatchItemId,
Collectors.summingLong(DeliveryFeeCoverageModels.Cargo::packages)));
var ids = selected.stream().map(DispatchItem::getId).collect(Collectors.toSet());
if (!ids.equals(totals.keySet())) return "实际货物来源尚未齐全,请先完成收货并核对实物托盘";
for (var item : selected) if (item.getPackageCount() == null || totals.get(item.getId()).longValue() != item.getPackageCount())
return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
return null;
}
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<Long> externalItems) {
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
if (evidence == null) return List.of();
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalItems)).toList();
}
}
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<Long> externalItems) {
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
var preview = preview(dispatchNo);
if (!preview.sourceComplete()) throw conflict(preview.blockReason());
if (!Objects.equals(preview.cargoHash(), request.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化,请重新核对覆盖范围");
var references = new ArrayList<DeliveryFeeCoverageModels.Reference>();
var scopes = new HashSet<String>();
for (var selection : request.feeCoverage()) {
var fee = preview.feeSources().stream().filter(row -> Objects.equals(row.financeFeeId(), selection.financeFeeId())).findFirst()
.orElseThrow(() -> conflict("原费用不属于本次实际货物,或已审核状态/有效性发生变化"));
if (fee.sourceVersion() != selection.sourceVersion() || !Objects.equals(fee.sourceHash(), selection.sourceHash()))
throw conflict("原费用版本已变化,请重新确认服务覆盖范围");
var services = new TreeSet<>(selection.services());
if (services.isEmpty() || !Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE").containsAll(services))
throw conflict("请选择本票原费用明确包含的服务");
for (var service : services) if (!scopes.add(fee.sourceInboundId() + ":" + service))
throw conflict("同一票同一服务只能引用一项原费用");
references.add(new DeliveryFeeCoverageModels.Reference(fee, List.copyOf(services)));
}
var included = new HashSet<String>();
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalItems))) included.add(service);
return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(),
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalItems));
}
private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
List<DeliveryFeeCoverageModels.Reference> references, Set<Long> externalItems) {
if ("DELIVERY_FEE".equals(service) && externalItems.contains(cargo.dispatchItemId())) return true;
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "原费用来源暂不可用,请稍后重试"); }
}
@@ -1,5 +1,9 @@
package com.baiyue.lastleg.delivery.service; package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -123,6 +127,25 @@ public class DeliveryTransferService {
@Resource private RelabelApplicationService relabelService; @Resource private RelabelApplicationService relabelService;
@Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private FleetBookingFreight bookingFreight;
@Resource private DeliveryFeeCoverageService feeCoverage;
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo));
if (dispatch == null) throw notFound("Dispatch order not found");
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
.toList());
}
@Transactional @Transactional
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
@@ -135,6 +158,8 @@ public class DeliveryTransferService {
@Transactional @Transactional
public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId, public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) { DeliveryRequests.Transfer request) {
if (request.feeCoverage() != null && !request.feeCoverage().isEmpty())
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认");
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo); DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found"); if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId()); DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
@@ -145,16 +170,20 @@ public class DeliveryTransferService {
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId, private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
DeliveryRequests.Transfer request) { DeliveryRequests.Transfer request) {
String customerId = dispatch.getCustomerId(); String customerId = dispatch.getCustomerId();
String requestHash = transferRequestHash(request);
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (replay != null) { if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
return result(replay, requireDeliveries(customerId, replay.getId()), true); return replayTransfer(replay, requestHash);
} }
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (existing != null) return result(existing, requireDeliveries(customerId, existing.getId()), true); if (existing != null) return replayTransfer(existing, requestHash);
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
throw conflict("Dispatch cannot be transferred to delivery in its current status"); throw conflict("Dispatch cannot be transferred to delivery in its current status");
} }
fleetBindings.requireMatchingAssignment(dispatch.getId(),
new FleetDispatchGateway.Assignment(dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone()),
new FleetDispatchGateway.Assignment(request.vehiclePlate(), request.driverName(), request.driverPhone()));
dispatchItems.bindReceivedPallets(customerId, dispatch.getId()); dispatchItems.bindReceivedPallets(customerId, dispatch.getId());
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId()); List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
if (items.isEmpty()) throw conflict("Dispatch has no pallets"); if (items.isEmpty()) throw conflict("Dispatch has no pallets");
@@ -162,7 +191,16 @@ public class DeliveryTransferService {
throw conflict("All dispatch pallets must be received before transfer to delivery"); throw conflict("All dispatch pallets must be received before transfer to delivery");
} }
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required"); if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
validateTransferFees(request); var freight = externalFreight(dispatch, items);
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder(); OutboundOrder outbound = new OutboundOrder();
@@ -173,10 +211,15 @@ public class DeliveryTransferService {
outbound.setLxCodes(resolveLxCodes(customerId, items)); outbound.setLxCodes(resolveLxCodes(customerId, items));
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size()); outbound.setPalletCount(items.size());
outbound.setGrossWeightKg(sum(items, DispatchItem::getGrossWeightKg)); outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg));
outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg)); outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg));
outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm)); outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm));
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
if (coverage.evidence() != null) {
try { outbound.setFeeCoverageJson(objectMapper.writeValueAsString(coverage.evidence())); }
catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法保存", exception); }
}
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
try { try {
outboundOrders.insert(outbound); outboundOrders.insert(outbound);
@@ -185,11 +228,11 @@ public class DeliveryTransferService {
// the document created by the winning transaction rather than receive a false failure. // the document created by the winning transaction rather than receive a false failure.
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) { if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true); return replayTransfer(concurrent, requestHash);
} }
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (concurrent != null) { if (concurrent != null) {
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true); return replayTransfer(concurrent, requestHash);
} }
throw conflict("Dispatch was concurrently transferred to delivery"); throw conflict("Dispatch was concurrently transferred to delivery");
} }
@@ -250,25 +293,57 @@ public class DeliveryTransferService {
delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
delivery.setPalletCount(groupItems.size()); delivery.setPalletCount(groupItems.size());
delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm)); delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm));
delivery.setTotalWeightKg(sum(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); delivery.setTotalWeightKg(sumKnownPhysicalWeight(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId); delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now); recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now);
bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
deliveries.add(delivery); deliveries.add(delivery);
} }
for (var quote : freight.quotes()) {
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
+ quote.currency() + " " + quote.amount().toPlainString(), now);
// An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee.
if (quote.amount().signum() == 0) continue;
feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT",
new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(),
null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()),
"DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null,
null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(),
quote.quantityUnit(), quote.unitPrice(), null, null));
}
DeliveryOrder feeDelivery = deliveries.get(0); DeliveryOrder feeDelivery = deliveries.get(0);
LastlegOrder firstOrder = firstTailOrder(customerId, items);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees(); List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
for (DeliveryRequests.FeeItem input : feeInputs) { for (DeliveryRequests.FeeItem input : feeInputs) {
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()), String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(), var uncovered = coverage.uncovered(feeCode);
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(), var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet());
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
: feeDelivery;
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
}
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(),
firstOrder == null ? null : firstOrder.getId(), null, firstOrder == null ? null : firstOrder.getId(), null,
firstOrder == null ? null : firstOrder.getInboundCode(), null, firstOrder == null ? null : firstOrder.getInboundCode(), null,
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()), firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()), firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()),
input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(), feeCode, input.feeName().trim(), input.direction(), input.supplierId(),
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(), input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(), input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(), input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
@@ -277,27 +352,45 @@ public class DeliveryTransferService {
return result(outbound, deliveries, false); return result(outbound, deliveries, false);
} }
private void validateTransferFees(DeliveryRequests.Transfer request) { private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
if (!requestHash.equals(outbound.getRequestHash()))
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
return result(outbound, requireDeliveries(outbound.getCustomerId(), outbound.getId()), true);
}
String transferRequestHash(DeliveryRequests.Transfer request) {
// A new command key cannot change an already transferred dispatch's business content.
com.fasterxml.jackson.databind.node.ObjectNode content = objectMapper.valueToTree(request);
content.remove("idempotencyKey");
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) {
content.remove("feeCoverage"); content.remove("feeCoverageCargoHash");
}
return hash(content);
}
private void validateTransferFees(DeliveryRequests.Transfer request, boolean requiresDeliveryFee, Set<String> covered) {
List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees(); List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees();
if (inputs.isEmpty()) throw conflict("Delivery fee is required");
Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"); Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE");
Set<String> seen = new HashSet<>(); Set<String> seen = new HashSet<>();
for (DeliveryRequests.FeeItem input : inputs) { for (DeliveryRequests.FeeItem input : inputs) {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT); String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType); if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType); if (!seen.add(input.direction() + ":" + feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if ("RECEIVABLE".equals(input.direction()) && covered.contains(feeType)) throw conflict("本次货物的该服务已由原费用覆盖,请勿重复登记应收");
if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT))) if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
throw conflict("Delivery fees only support CNY or USD"); throw conflict("Delivery fees only support CNY or USD");
if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero"); if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
} }
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required"); if (requiresDeliveryFee && !covered.contains("DELIVERY_FEE") && !seen.contains("RECEIVABLE:DELIVERY_FEE")) throw conflict("尚未有约定运费的货物须填写派送费");
if (!requiresDeliveryFee && inputs.stream().anyMatch(item -> "DELIVERY_FEE".equalsIgnoreCase(item.feeType().trim())
&& "RECEIVABLE".equals(item.direction()))) throw conflict("本排车全部货物已带入外接约定运费,请勿重复填写派送应收");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT)) if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
&& !seen.contains("LOADING_FEE")) { && !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) {
throw conflict("Loading fee is required for floor or mixed loading"); throw conflict("Loading fee is required for floor or mixed loading");
} }
if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) { if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) {
if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment"); if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment");
if (!seen.contains("APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment"); if (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
} }
} }
@@ -976,10 +1069,21 @@ public class DeliveryTransferService {
} }
@Transactional @Transactional
public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) { public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
if (observed == null) throw notFound("Outbound order not found");
// Dispatch picking/departure owns the same dispatch -> outbound lock order.
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo)); OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found"); if (outbound == null) throw notFound("Outbound order not found");
if ("CANCELLED".equals(outbound.getStatus())) return; if ("CANCELLED".equals(outbound.getStatus())) return;
if (outbound.getDispatchOrderId() != null) {
if (dispatch == null || !Objects.equals(dispatch.getId(), outbound.getDispatchOrderId())
|| !"PENDING_DEPARTURE".equals(dispatch.getStatus()) || value(dispatch.getPickedPackageCount()) > 0
|| value(dispatch.getShippedPackageCount()) > 0) throw conflict("排车已开始执行,不能直接取消出库");
fleetBindings.requireUnbound(dispatch.getId());
}
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) { if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
throw conflict("Only an unexecuted outbound order can be cancelled"); throw conflict("Only an unexecuted outbound order can be cancelled");
} }
@@ -994,6 +1098,22 @@ public class DeliveryTransferService {
throw conflict("Outbound order cannot be cancelled while delivery is in progress"); throw conflict("Outbound order cannot be cancelled while delivery is in progress");
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
for (DeliveryOrder delivery : existingDeliveries) { for (DeliveryOrder delivery : existingDeliveries) {
int voidRequests = 0;
for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) {
if ("RECEIVABLE".equals(fee.getDirection()) && "DELIVERY_FEE".equals(fee.getFeeItemCode())
&& "APPROVED".equals(fee.getReviewStatus()) && "ACTIVE".equals(fee.getSourceStatus())) {
feeSources.voidSource(fee.getSourceFeeId(), operatorId, new OverseasFeeSourceDtos.VoidRequest(
fee.getSourceVersion(), "取消出库 " + outboundNo + ";详见派送单取消原因",
"CANCEL_OUTBOUND:" + outbound.getId() + ":" + fee.getSourceFeeId()));
voidRequests++;
}
}
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND",
"出库取消;" + voidRequests + "笔未履行派送应收已申请撤销,其他费用保留待核对", now);
String reason = request.reason().trim();
for (int offset = 0; offset < reason.length(); offset += 480)
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON",
"(" + (offset / 480 + 1) + ") " + reason.substring(offset, Math.min(offset + 480, reason.length())), now);
delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1);
delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
} }
@@ -1019,9 +1139,26 @@ public class DeliveryTransferService {
return; return;
} }
if (!existingDeliveries.isEmpty()) { if (!existingDeliveries.isEmpty()) {
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList();
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId)
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); .eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) {
var order = tailOrders.lockById(customerId, selected.getId());
if (order == null || !outboundNo.equals(order.getOutboundOrderNo())) continue;
var previous = histories.selectOne(new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getCustomerId, customerId).eq(LastlegOrderStatusHistory::getOrderId, order.getId())
.in(LastlegOrderStatusHistory::getEventId, transferEvents)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt, LastlegOrderStatusHistory::getId).last("LIMIT 1"));
if (previous == null || !StringUtils.hasText(previous.getFromStatus())) throw conflict("缺少本次转派送前的订单状态记录,请先核对来源");
String from = order.getCurrentStatus();
if (tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<LastlegOrder>()
.eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo)
.set(LastlegOrder::getCurrentStatus, previous.getFromStatus())
.set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED")
.set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null)
.set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1)
throw conflict("订单关联已变化,请刷新后重试");
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
} }
} }
} }
@@ -1225,7 +1362,22 @@ public class DeliveryTransferService {
List<DeliveryResponses.DeliverySku> skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList(); List<DeliveryResponses.DeliverySku> skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList();
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus, return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(), feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList()); deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(),
feeCoverageEvidence(customerId, delivery.getOutboundNo(), items));
}
private com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence feeCoverageEvidence(String customerId, String outboundNo, List<DispatchItem> items) {
var outbound = outboundNo == null ? null : outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null || outbound.getFeeCoverageJson() == null) return null;
try {
var evidence = objectMapper.readValue(outbound.getFeeCoverageJson(), com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence.class);
var ids = items.stream().map(DispatchItem::getId).collect(Collectors.toSet());
var cargo = evidence.cargo().stream().filter(row -> ids.contains(row.dispatchItemId())).toList();
var inboundIds = cargo.stream().filter(row -> "FIRSTLEG".equals(row.sourceRegion()))
.map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::sourceInboundId).collect(Collectors.toSet());
var references = evidence.references().stream().filter(row -> inboundIds.contains(row.source().sourceInboundId().toString())).toList();
return new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence(evidence.cargoHash(), cargo, references, evidence.operatorId(), evidence.confirmedAt());
} catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); }
} }
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo, private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
@@ -1286,9 +1438,9 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3), allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4)); allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
} }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); } private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
@@ -1344,6 +1496,11 @@ public class DeliveryTransferService {
java.util.function.Function<T, BigDecimal> getter) { java.util.function.Function<T, BigDecimal> getter) {
return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
} }
private static <T> BigDecimal sumKnownPhysicalWeight(List<T> items,
java.util.function.Function<T, BigDecimal> getter) {
if (items.isEmpty() || items.stream().map(getter).anyMatch(weight -> weight == null || weight.signum() <= 0)) return null;
return items.stream().map(getter).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages, private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages,
long selectedPackages, int scale) { long selectedPackages, int scale) {
if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null
@@ -0,0 +1,183 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.fleet.trip.FleetCompletionModels;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.ZoneId;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import java.util.HashMap;
import java.util.Objects;
@Component
@Transactional(propagation = Propagation.MANDATORY)
public class DispatchFleetAdapter implements FleetDispatchGateway {
private final DispatchOrderMapper orders;
private final DispatchPdaTaskMapper tasks;
private final OutboundOrderMapper outbounds;
private final DeliveryOrderMapper deliveries;
private final DeliveryOperationMapper deliveryOperations;
private final DispatchItemMapper items;
private final DispatchPdaPickMapper picks;
private final DispatchPdaWorkflowService workflow;
public DispatchFleetAdapter(DispatchOrderMapper orders, DispatchPdaTaskMapper tasks,
OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries, DispatchItemMapper items,
DispatchPdaPickMapper picks, DispatchPdaWorkflowService workflow, DeliveryOperationMapper deliveryOperations) {
this.orders = orders; this.tasks = tasks; this.outbounds = outbounds; this.deliveries = deliveries;
this.items = items; this.picks = picks; this.workflow = workflow;
this.deliveryOperations = deliveryOperations;
}
@Override
public Snapshot lockForPlanning(String dispatchNo) {
return lockBeforeDeparture(dispatchNo, false);
}
@Override
public Snapshot lockForAssignment(String dispatchNo) {
return lockBeforeDeparture(dispatchNo, true);
}
private Snapshot lockBeforeDeparture(String dispatchNo, boolean allowPicking) {
var dispatch = orders.lockGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var allowedStatuses = allowPicking ? java.util.Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART") : java.util.Set.of("PENDING_DEPARTURE");
if (dispatch.getStatus() == null || !allowedStatuses.contains(dispatch.getStatus())
|| (!allowPicking && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0)
throw conflict("排车单 " + dispatchNo + " 已开始执行,不能修改车次关联或改派");
// Keep the same dispatch -> outbound -> PDA task lock order as departure.
var outbound = outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId());
if (outbound != null && !"WAIT_OUTBOUND".equals(outbound.getStatus()))
throw conflict("排车单 " + dispatchNo + " 的出库状态不允许修改关联");
var task = tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId());
var allowedTaskStatuses = allowPicking ? java.util.Set.of("PDA_PENDING", "PICKING", "COMPLETED") : java.util.Set.of("PDA_PENDING");
if (task != null && (task.getStatus() == null || !allowedTaskStatuses.contains(task.getStatus())))
throw conflict("排车单 " + dispatchNo + " 已开始拣货,不能修改车次关联或改派");
if (task != null && !Objects.equals(task.getWarehouseId(), dispatch.getOverseasWarehouseId()))
throw conflict("拣货任务所属仓不一致");
if (task == null && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
throw conflict("排车单缺少已拣货的作业依据");
if (dispatch.getPackageCount() == null || dispatch.getPackageCount() <= 0)
throw conflict("排车单 " + dispatchNo + " 没有待发运货物");
return new Snapshot(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), dispatch.getOverseasWarehouseId(), dispatch.getPackageCount());
}
@Override
public void assign(Snapshot dispatch, Assignment assignment) {
var now = LocalDateTime.now();
// Explicit SET is needed when unbinding: MyBatis entity updates omit null fields.
if (orders.update(null, new LambdaUpdateWrapper<DispatchOrder>().eq(DispatchOrder::getId, dispatch.dispatchId())
.eq(DispatchOrder::getCustomerId, dispatch.customerId()).in(DispatchOrder::getStatus, "PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART")
.set(DispatchOrder::getVehiclePlate, assignment.vehiclePlate()).set(DispatchOrder::getDriverName, assignment.driverName())
.set(DispatchOrder::getDriverPhone, assignment.driverPhone()).set(DispatchOrder::getUpdatedAt, now)) != 1)
throw conflict("排车单已变化,请刷新后重试");
var outbound = outbounds.lockByDispatch(dispatch.customerId(), dispatch.dispatchId());
if (outbound == null) return;
for (var delivery : deliveries.lockByOutbound(dispatch.customerId(), outbound.getId())) {
if (!"WAIT_DELIVERY".equals(delivery.getStatus()) || delivery.getDispatchedAt() != null || delivery.getSignedAt() != null)
throw conflict("派送单 " + delivery.getDeliveryNo() + " 已执行,不能覆盖车辆司机");
if (deliveries.update(null, new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId, delivery.getId())
.eq(DeliveryOrder::getCustomerId, dispatch.customerId()).eq(DeliveryOrder::getVersion, delivery.getVersion())
.set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName())
.set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, delivery.getVersion() + 1)
.set(DeliveryOrder::getUpdatedAt, now)) != 1) throw conflict("派送单已变化,请刷新后重试");
}
}
@Override
public FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock) {
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var task = lock ? tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : tasks.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
Map<Long, Long> picked = new HashMap<>();
if (task != null) for (var pick : picks.listTask(dispatch.getCustomerId(), task.getId())) picked.merge(pick.getDispatchItemId(), pick.getPackages(), Math::addExact);
var cargo = selected.stream().map(item -> new FleetExecutionModels.Cargo(dispatchNo, dispatch.getCustomerId(), item.getId().toString(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0),
destination(item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()),
"PRIVATE".equals(item.getDestinationType()) ? item.getDeliveryAddress() : first(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()))).toList();
String reason = null;
if (!"READY_TO_DEPART".equals(dispatch.getStatus()) || task == null || !"COMPLETED".equals(task.getStatus())) reason = "请完成拣货并核平全部箱数";
else if (!Objects.equals(dispatch.getOverseasWarehouseId(), task.getWarehouseId())) reason = "拣货任务所属仓不一致";
else if (selected.isEmpty() || selected.stream().anyMatch(item -> item.getPackageCount() == null || item.getPackageCount() <= 0
|| picked.getOrDefault(item.getId(), 0L).longValue() != item.getPackageCount().longValue())) reason = "每条货物须与实际拣货箱数核平";
else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物";
else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单";
else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认";
return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason);
}
@Override
public FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator,
String idempotencyKey, String remark, Instant departedAt) {
var result = workflow.depart(warehouseId, dispatchNo, operator, new DispatchPdaRequests.Depart(idempotencyKey, remark),
LocalDateTime.ofInstant(departedAt, ZoneId.systemDefault()));
return new FleetExecutionModels.DepartedDispatch(result.dispatchNo(), result.shippedPackages());
}
private static String destination(String type, String code, String fba, String address, String postalCode) {
return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode);
}
@Override public java.util.List<SourceCargo> sourceCargo(String dispatchNo) {
var dispatch=orders.findGlobalByDispatchNo(dispatchNo);
if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");
return items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()).stream().map(item->new SourceCargo(item.getInboundCode(),
item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),Objects.requireNonNullElse(item.getPackageCount(),0),
destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()))).toList();
}
@Override
public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) {
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var rows = outbound == null ? java.util.List.<DeliveryOrder>of() : lock
? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId());
var outcomes = rows.stream().map(row -> new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(),
"PRIVATE".equals(row.getDestinationType()) ? row.getDeliveryAddress() : first(row.getDestinationWarehouseCode(), row.getDestinationWarehouseName()),
originallySigned(row) ? "DELIVERED" : row.getStatus(), Objects.requireNonNullElse(row.getPackageCount(), 0),
originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null)).toList();
String reason = null;
if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) reason = "排车出库尚未完成";
else if (outcomes.isEmpty()) reason = "缺少对应派送单";
else if (outcomes.stream().mapToLong(FleetCompletionModels.Outcome::packages).sum() != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0)
|| !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) reason = "派送单数量未覆盖本次全部实发货物";
else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null))
reason = "所有派送单须已签收,或完成真实退回入仓";
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason);
}
// Existing delivery commands persist LocalDateTime.now(); convert in that server zone, never the browser zone.
private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); }
private static boolean originallySigned(DeliveryOrder order) {
return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus());
}
private static String first(String a, String b) { return a != null && !a.isBlank() ? a : b == null || b.isBlank() ? null : b; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -9,6 +9,11 @@ import java.util.List;
@Mapper @Mapper
public interface DispatchCandidateMapper { public interface DispatchCandidateMapper {
// Billing volume weight is never a substitute for physical cargo mass.
String SOURCE_GROSS_WEIGHT =
"COALESCE((SELECT CASE WHEN COUNT(*)&gt;0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)&gt;0 " +
"THEN SUM(d.gross_weight_kg) END FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1))";
@Select("<script>" + @Select("<script>" +
"SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " + "SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " + "<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
@@ -29,14 +34,11 @@ public interface DispatchCandidateMapper {
"MIN(l.order_id) AS orderId," + "MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," + "MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," + "MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
"COALESCE(NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)&gt;0 THEN " + "CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," + " &gt;0 AND (COALESCE(l.volume_cbm,0)&lt;=0 OR COALESCE(a.allocated_volume_cbm,0)&gt;0) THEN 1 ELSE 0 END)=1 THEN " +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " + "NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)&gt;0 THEN " + SOURCE_GROSS_WEIGHT +
"* COALESCE(a.allocated_volume_cbm,0) / l.volume_cbm ELSE " + " * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," + " / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
"/ NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0)," +
"MIN(o.volume_weight_kg)) AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," + "MIN(ip.container_no) AS containerNo," +
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " + "(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
@@ -142,14 +144,11 @@ public interface DispatchCandidateMapper {
"MIN(l.order_id) AS orderId," + "MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," + "MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," + "MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
"COALESCE(NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)&gt;0 THEN " + "CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," + " &gt;0 AND (COALESCE(l.volume_cbm,0)&lt;=0 OR COALESCE(a.allocated_volume_cbm,0)&gt;0) THEN 1 ELSE 0 END)=1 THEN " +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " + "NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)&gt;0 THEN " + SOURCE_GROSS_WEIGHT +
"* COALESCE(a.allocated_volume_cbm,0) / l.volume_cbm ELSE " + " * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," + " / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
"/ NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0)," +
"MIN(o.volume_weight_kg)) AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," + "MIN(ip.container_no) AS containerNo," +
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " + "(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
@@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -19,19 +21,24 @@ import java.util.List;
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class DispatchPdaController { public class DispatchPdaController {
@Resource private DispatchPdaWorkflowService service; @Resource private DispatchPdaWorkflowService service;
@Resource private FleetTripExecutionService trips;
@GetMapping @GetMapping
public ApiResponse<List<DispatchPdaResponses.Task>> list(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<List<DispatchPdaResponses.Task>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String status) { @RequestParam(required = false) String status) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.list(String.valueOf(identity.overseasWarehouseId()), status)); String warehouse = String.valueOf(identity.overseasWarehouseId());
var tasks = service.list(warehouse, status);
var gates = trips.gates(warehouse, tasks.stream().map(DispatchPdaResponses.Task::dispatchNo).toList());
return ApiResponse.success(tasks.stream().map(task -> task.withGate(gates.get(task.dispatchNo()))).toList());
} }
@GetMapping("/{dispatchNo}") @GetMapping("/{dispatchNo}")
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) { @PathVariable String dispatchNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.task(String.valueOf(identity.overseasWarehouseId()), dispatchNo)); String warehouse = String.valueOf(identity.overseasWarehouseId());
return ApiResponse.success(service.task(warehouse, dispatchNo).withGate(trips.gate(warehouse, dispatchNo)));
} }
@PostMapping("/{dispatchNo}/items/{itemId}/pick") @PostMapping("/{dispatchNo}/items/{itemId}/pick")
@@ -54,11 +61,11 @@ public class DispatchPdaController {
} }
@PostMapping("/{dispatchNo}/depart") @PostMapping("/{dispatchNo}/depart")
public ApiResponse<DispatchPdaResponses.Departed> depart(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<FleetExecutionModels.Departed> depart(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, @PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.Depart request) { @Valid @RequestBody FleetExecutionModels.Depart request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo, return ApiResponse.success(trips.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo,
identity.operatorKey(), request)); identity.operatorKey(), request));
} }
} }
@@ -1,11 +1,18 @@
package com.baiyue.lastleg.dispatch.pda.dto; package com.baiyue.lastleg.dispatch.pda.dto;
import java.util.List; import java.util.List;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
public final class DispatchPdaResponses { public final class DispatchPdaResponses {
private DispatchPdaResponses() {} private DispatchPdaResponses() {}
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
boolean canDepart, String departBlockReason, List<Item> items) {} boolean canDepart, String departBlockReason, List<Item> items, String tripNo, Long tripRecordVersion, int tripDispatchCount, String driverWarning,
boolean canPick, String pickBlockReason) {
public Task withGate(FleetExecutionModels.Gate gate) {
return new Task(id, taskNo, dispatchNo, vehiclePlate, warehouseId, status, gate.canDepart(), gate.blockReason(), items,
gate.tripNo(), gate.recordVersion(), gate.dispatchCount(), gate.driverWarning(), gate.canPick(), gate.pickBlockReason());
}
}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages, int scheduledPackages,
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.dispatch.pda.service; package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
@@ -36,6 +38,7 @@ import java.util.stream.Collectors;
@Service @Service
public class DispatchPdaWorkflowService { public class DispatchPdaWorkflowService {
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private DispatchOrderMapper orders; @Resource private DispatchOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPalletItems; @Resource private OutboundPalletItemMapper outboundPalletItems;
@@ -100,6 +103,7 @@ public class DispatchPdaWorkflowService {
} }
requirePdaStatus(order); requirePdaStatus(order);
requireVehicle(order); requireVehicle(order);
fleetBindings.requirePickingApproved(order.getId());
items.bindReceivedPallets(customerId, order.getId()); items.bindReceivedPallets(customerId, order.getId());
if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for picking"); throw conflict("Dispatch PDA task is not available for picking");
@@ -169,6 +173,7 @@ public class DispatchPdaWorkflowService {
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay(); if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order); requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
fleetBindings.requirePickingApproved(order.getId());
if (task != null && "COMPLETED".equals(task.getStatus())) { if (task != null && "COMPLETED".equals(task.getStatus())) {
List<DispatchItem> completedItems = items.findByDispatchOrder(customerId, order.getId()); List<DispatchItem> completedItems = items.findByDispatchOrder(customerId, order.getId());
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
@@ -204,15 +209,16 @@ public class DispatchPdaWorkflowService {
} }
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
@Transactional @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId, public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId,
DispatchPdaRequests.Depart request) { DispatchPdaRequests.Depart request, LocalDateTime departedAt) {
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
String customerId = found.getCustomerId(); String customerId = found.getCustomerId();
DispatchOrder order = orders.lockById(customerId, found.getId()); DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found"); if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request); DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay(); if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
fleetBindings.requireCoordinatedDeparture(order.getId());
requireVehicle(order); requireVehicle(order);
OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId()); OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId());
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
@@ -232,7 +238,7 @@ public class DispatchPdaWorkflowService {
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId()); List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = Objects.requireNonNull(departedAt, "departedAt");
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
if (outboundPalletItems != null) { if (outboundPalletItems != null) {
for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) { for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) {
@@ -292,7 +298,8 @@ public class DispatchPdaWorkflowService {
OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId()); OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId());
String departBlockReason = departBlockReason(order, task, source, outbound); String departBlockReason = departBlockReason(order, task, source, outbound);
return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(),
task.getWarehouseId(), task.getStatus(), departBlockReason == null, departBlockReason, values); task.getWarehouseId(), "DEPARTED".equals(order.getStatus()) ? "DEPARTED" : task.getStatus(),
departBlockReason == null, departBlockReason, values, null, null, 0, null, false, "请先关联车次并通过调度审核");
} }
private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source, private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source,
@@ -26,6 +26,7 @@ import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
@@ -65,6 +66,7 @@ public class DispatchService {
@Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private LastlegOrderMapper tailOrders; @Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundOrderMapper outboundOrders;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private RelabelApplicationMapper relabelApplications; @Resource private RelabelApplicationMapper relabelApplications;
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
@@ -410,7 +412,7 @@ public class DispatchService {
item.setContactName(candidate.contactName()); item.setContactName(candidate.contactName());
item.setContactPhone(candidate.contactPhone()); item.setContactPhone(candidate.contactPhone());
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO)); item.setGrossWeightKg(candidate.grossWeightKg());
item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO));
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
item.setSourceStage(candidate.sourceStage()); item.setSourceStage(candidate.sourceStage());
@@ -459,7 +461,7 @@ public class DispatchService {
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO)); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
@@ -528,6 +530,7 @@ public class DispatchService {
public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) { public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
fleetBindings.requireUnbound(dispatch.getId());
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) { if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, throw new ResponseStatusException(HttpStatus.CONFLICT,
"Only a pending dispatch can be edited"); "Only a pending dispatch can be edited");
@@ -585,6 +588,7 @@ public class DispatchService {
} }
private void requirePalletEditingAllowed(DispatchOrder dispatch) { private void requirePalletEditingAllowed(DispatchOrder dispatch) {
fleetBindings.requireUnbound(dispatch.getId());
if (!PENDING_DEPARTURE.equals(dispatch.getStatus()) if (!PENDING_DEPARTURE.equals(dispatch.getStatus())
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) { || Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets"); throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets");
@@ -598,6 +602,7 @@ public class DispatchService {
public void cancel(String dispatchNo) { public void cancel(String dispatchNo) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
fleetBindings.requireUnbound(dispatch.getId());
if (CANCELLED.equals(dispatch.getStatus())) return; if (CANCELLED.equals(dispatch.getStatus())) return;
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
@@ -58,6 +58,7 @@ public class OverseasFeeSource {
private String reviewStatus; private String reviewStatus;
private String publicationStatus; private String publicationStatus;
private String sourceStatus; private String sourceStatus;
@TableField(exist = false) private String voidStatus;
private String reviewReason; private String reviewReason;
private String reviewerId; private String reviewerId;
private LocalDateTime reviewedAt; private LocalDateTime reviewedAt;
@@ -15,6 +15,9 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class OverseasFeeOutboxRepository { public class OverseasFeeOutboxRepository {
private final LastlegEventOutboxMapper mapper; private final LastlegEventOutboxMapper mapper;
private final com.baiyue.lastleg.financefee.state.OverseasFeeCancellation cancellation;
public boolean prerequisitePublished(LastlegEventOutbox row) { return cancellation.prerequisitePublished(row); }
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime stale) { public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime stale) {
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>() return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
@@ -22,6 +25,12 @@ public class OverseasFeeOutboxRepository {
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
.and(eligible -> eligible.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.or().isNull(LastlegEventOutbox::getEventKey).or().notLikeLeft(LastlegEventOutbox::getEventKey, ":VOIDED")
.or().apply("EXISTS (SELECT 1 FROM ll_event_outbox prerequisite WHERE prerequisite.event_key IN ("
+ "CONCAT(SUBSTRING(ll_event_outbox.event_key,1,LENGTH(ll_event_outbox.event_key)-6),'PUBLISHED'),"
+ "CONCAT(SUBSTRING(ll_event_outbox.event_key,1,LENGTH(ll_event_outbox.event_key)-6),'ADJUSTED')) "
+ "AND prerequisite.status='PUBLISHED')"))
.orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit)); .orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit));
} }
public boolean claim(long id, String worker, LocalDateTime now) { public boolean claim(long id, String worker, LocalDateTime now) {
@@ -47,9 +56,12 @@ public class OverseasFeeOutboxRepository {
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>().eq(LastlegEventOutbox::getId, id) return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>().eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)); .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker));
} }
@org.springframework.transaction.annotation.Transactional
public int published(long id, String worker, LocalDateTime now) { public int published(long id, String worker, LocalDateTime now) {
return mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) int changed = mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now)
.set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null)); .set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null));
if (changed == 1) cancellation.acknowledge(mapper.selectById(id));
return changed;
} }
public int dead(long id, String worker, int failures, String error) { public int dead(long id, String worker, int failures, String error) {
return mapper.update(null, claimed(id, worker, "DEAD") return mapper.update(null, claimed(id, worker, "DEAD")
@@ -54,6 +54,7 @@ public class OverseasFeeOutboxRelay implements EventDeliveryProducer {
failures, next, "投递租约超时,消费结果未知")); failures, next, "投递租约超时,消费结果未知"));
continue; // Recovery always waits for the new backoff window; never sends in this pass. continue; // Recovery always waits for the new backoff window; never sends in this pass.
} }
if (!repository.prerequisitePublished(candidate)) continue;
String worker = UUID.randomUUID().toString(); String worker = UUID.randomUUID().toString();
String type = Stream.FEE.type(candidate.getEventType()); String type = Stream.FEE.type(candidate.getEventType());
if (!journal.begin(worker, Stream.FEE.service, type, String.valueOf(candidate.getId()), if (!journal.begin(worker, Stream.FEE.service, type, String.valueOf(candidate.getId()),
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -41,31 +42,43 @@ public class OverseasFeeSourceService {
private final OverseasFeeOutboxService outbox; private final OverseasFeeOutboxService outbox;
private final OverseasFeeCurrencyConverter currencyConverter; private final OverseasFeeCurrencyConverter currencyConverter;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final OverseasFeeCancellation cancellation;
public List<OverseasFeeSource> list(String customerId, String businessType, String businessNo, public List<OverseasFeeSource> list(String customerId, String businessType, String businessNo,
Long containerBookingId) { Long containerBookingId) {
return mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>() return cancellation.describe(mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(text(customerId) != null, OverseasFeeSource::getCustomerId, text(customerId)) .eq(text(customerId) != null, OverseasFeeSource::getCustomerId, text(customerId))
.eq(text(businessType) != null, OverseasFeeSource::getBusinessType, text(businessType)) .eq(text(businessType) != null, OverseasFeeSource::getBusinessType, text(businessType))
.like(text(businessNo) != null, OverseasFeeSource::getBusinessNo, text(businessNo)) .like(text(businessNo) != null, OverseasFeeSource::getBusinessNo, text(businessNo))
.eq(containerBookingId != null, OverseasFeeSource::getContainerBookingId, containerBookingId) .eq(containerBookingId != null, OverseasFeeSource::getContainerBookingId, containerBookingId)
.inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id") .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")
.orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId)); .orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId)));
} }
public List<OverseasFeeSource> listForBusiness(String customerId, String businessType, long businessId) { public List<OverseasFeeSource> listForBusiness(String customerId, String businessType, long businessId) {
return mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>() return cancellation.describe(mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(OverseasFeeSource::getCustomerId, customerId) .eq(OverseasFeeSource::getCustomerId, customerId)
.eq(OverseasFeeSource::getBusinessType, businessType) .eq(OverseasFeeSource::getBusinessType, businessType)
.eq(OverseasFeeSource::getBusinessId, businessId) .eq(OverseasFeeSource::getBusinessId, businessId)
.inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id") .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")
.orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId)); .orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId)));
} }
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) { public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
return businessGuard.listContainerCustomers(containerBookingId); return businessGuard.listContainerCustomers(containerBookingId);
} }
public void requireNoActiveDeliveryReceivables(String customerId, List<String> inboundCodes) {
if (inboundCodes.isEmpty()) return;
long existing = mapper.selectCount(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(OverseasFeeSource::getCustomerId, customerId).eq(OverseasFeeSource::getBusinessType, "DELIVERY_ORDER")
.eq(OverseasFeeSource::getDirection, "RECEIVABLE").eq(OverseasFeeSource::getFeeItemCode, "DELIVERY_FEE")
.in(OverseasFeeSource::getInboundCode, inboundCodes).ne(OverseasFeeSource::getSourceStatus, "VOIDED")
.and(state -> state.eq(OverseasFeeSource::getReviewStatus, "APPROVED").or().gt(OverseasFeeSource::getSourceVersion, 1))
.inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id"));
if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送");
}
@Transactional @Transactional
public OverseasFeeSource create(String operatorId, String key, SaveRequest request) { public OverseasFeeSource create(String operatorId, String key, SaveRequest request) {
OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request); OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request);
@@ -125,11 +138,12 @@ public class OverseasFeeSourceService {
return value; return value;
} }
@Transactional @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public OverseasFeeSource adjust(long sourceFeeId, String operatorId, AdjustRequest request) { public OverseasFeeSource adjust(long sourceFeeId, String operatorId, AdjustRequest request) {
OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request); OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId()); if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource old = lock(sourceFeeId); OverseasFeeSource old = lock(sourceFeeId);
if (cancellation.requested(old)) throw conflict("该费用已申请作废,请先处理财务结果");
if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus()) if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus())
|| old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整"); || old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整");
validate(request.fee()); businessGuard.requireOwned(request.fee()); validate(request.fee()); businessGuard.requireOwned(request.fee());
@@ -157,10 +171,15 @@ public class OverseasFeeSourceService {
OverseasFeeSource value = lock(sourceFeeId); OverseasFeeSource value = lock(sourceFeeId);
if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()) if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())
|| value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废"); || value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废");
value.setSourceStatus("VOIDED"); value.setReviewReason(request.reason().trim()); if (cancellation.requested(value)) return cancellation.describe(List.of(value)).get(0);
value.setReviewReason(request.reason().trim());
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
outbox.publish(value, "VOIDED"); var pending = new OverseasFeeSource();
org.springframework.beans.BeanUtils.copyProperties(value, pending);
pending.setSourceStatus("VOIDED");
outbox.publish(pending, "VOIDED");
command(operatorId, "VOID", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion()); command(operatorId, "VOID", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion());
value.setVoidStatus("PENDING_FINANCE");
return value; return value;
} }
@@ -233,7 +252,7 @@ public class OverseasFeeSourceService {
private OverseasFeeSource current(long sourceFeeId) { private OverseasFeeSource current(long sourceFeeId) {
OverseasFeeSource value = mapper.selectOne(new LambdaQueryWrapper<OverseasFeeSource>() OverseasFeeSource value = mapper.selectOne(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(OverseasFeeSource::getSourceFeeId, sourceFeeId).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1")); .eq(OverseasFeeSource::getSourceFeeId, sourceFeeId).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1"));
if (value == null) throw notFound(); return value; if (value == null) throw notFound(); return cancellation.describe(List.of(value)).get(0);
} }
private OverseasFeeSource lock(long sourceFeeId) { OverseasFeeSource value = mapper.lockCurrent(sourceFeeId); if (value == null) throw notFound(); return value; } private OverseasFeeSource lock(long sourceFeeId) { OverseasFeeSource value = mapper.lockCurrent(sourceFeeId); if (value == null) throw notFound(); return value; }
private OverseasFeeCommand replay(String operator, String action, String key, Object request) { private OverseasFeeCommand replay(String operator, String action, String key, Object request) {
@@ -0,0 +1,80 @@
package com.baiyue.lastleg.financefee.state;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.stream.Collectors;
/** A request is not a financial void. Only the matched durable consumer ACK confirms it. */
@Component
@RequiredArgsConstructor
public class OverseasFeeCancellation {
private final LastlegEventOutboxMapper events;
private final OverseasFeeSourceMapper fees;
private final ObjectMapper json;
public static String eventKey(OverseasFeeSource fee, String action) {
return EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:" + fee.getSourceType() + ":"
+ fee.getSourceFeeId() + ":" + fee.getSourceVersion() + ":" + action;
}
public boolean requested(OverseasFeeSource fee) { return events.findByEventKey(eventKey(fee, "VOIDED")) != null; }
public List<OverseasFeeSource> describe(List<OverseasFeeSource> values) {
if (values.isEmpty()) return values;
var keys = values.stream().flatMap(value -> java.util.stream.Stream.of(eventKey(value, "VOIDED"),
eventKey(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED"))).toList();
var byKey = events.selectList(new LambdaQueryWrapper<LastlegEventOutbox>().in(LastlegEventOutbox::getEventKey, keys))
.stream().collect(Collectors.toMap(LastlegEventOutbox::getEventKey, row -> row));
for (var value : values) {
var request = byKey.get(eventKey(value, "VOIDED"));
var publish = byKey.get(eventKey(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED"));
value.setVoidStatus("VOIDED".equals(value.getSourceStatus()) ? "CONFIRMED" : request == null ? "NOT_REQUESTED"
: "DEAD".equals(request.getStatus()) || publish == null || "DEAD".equals(publish.getStatus())
? "FINANCE_ATTENTION" : "PENDING_FINANCE");
}
return values;
}
public boolean prerequisitePublished(LastlegEventOutbox request) {
if (request.getEventKey() == null || !request.getEventKey().endsWith(":VOIDED")) return true;
String prefix = request.getEventKey().substring(0, request.getEventKey().length() - "VOIDED".length());
return events.selectCount(new LambdaQueryWrapper<LastlegEventOutbox>()
.in(LastlegEventOutbox::getEventKey, List.of(prefix + "PUBLISHED", prefix + "ADJUSTED"))
.eq(LastlegEventOutbox::getStatus, "PUBLISHED")) > 0;
}
/** Invoked inside the same transaction as the lease-owner CAS and delivery journal completion. */
@Transactional(propagation = Propagation.MANDATORY)
public void acknowledge(LastlegEventOutbox event) {
if (event.getEventKey() == null || !event.getEventKey().endsWith(":VOIDED")) return;
try {
var envelope = json.readValue(event.getPayloadJson(), EventEnvelope.class).requireValid();
var payload = json.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class);
if (!"VOIDED".equals(payload.action()) || !"LASTLEG".equals(payload.sourceSystem()))
throw new IllegalStateException("费用作废事件与来源不一致");
var source = fees.lockCurrent(Long.parseLong(payload.sourceFeeId()));
if (source == null || !Objects.equals(source.getSourceVersion(), payload.sourceVersion())
|| !Objects.equals(source.getCustomerId(), payload.customerId())
|| !Objects.equals(source.getSourceType(), payload.sourceType())
|| !eventKey(source, "VOIDED").equals(event.getEventKey()) || !"ACTIVE".equals(source.getSourceStatus()))
throw new IllegalStateException("财务已确认的作废来源与当前业务版本不一致");
source.setSourceStatus("VOIDED"); source.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));
if (fees.updateById(source) != 1) throw new IllegalStateException("费用作废确认未能落账");
} catch (com.fasterxml.jackson.core.JsonProcessingException error) {
throw new IllegalStateException("费用作废事件无法解析", error);
}
}
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.customer;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name = "iam-service", contextId = "fleetCustomerClient", url = "${baiyue.clients.iam.url:}")
public interface FleetCustomerClient {
@GetMapping("/internal/iam/finance-customers")
ApiResponse<PageResult<Customer>> customers(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("page") long page, @RequestParam("size") long size, @RequestParam("keyword") String keyword,
@RequestParam("status") String status, @RequestParam("customerId") Long customerId);
@GetMapping("/internal/iam/tenants/{customerId}/order-eligibility")
ApiResponse<Eligibility> eligibility(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("customerId") long customerId);
record Customer(Long id, String tenantCode, String tenantName, String status, String contactName, String contactPhone) { }
record Eligibility(boolean eligible, String tenantStatus, String reason) { }
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.fleet.customer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Component
public class FleetCustomerDirectory {
public record Customer(String id, String code, String name, String contactName, String contactPhone) { }
private final FleetCustomerClient client;
private final String token;
public FleetCustomerDirectory(FleetCustomerClient client, @Value("${baiyue.security.internal-service-token}") String token) {
this.client = client; this.token = token;
}
public List<Customer> options(String keyword) { return load(keyword, null); }
public Customer required(long id) {
var customer = load(null, id).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户"));
try {
var result = client.eligibility(token, id);
if (result == null || !result.successful() || result.data() == null) throw unavailable();
if (!result.data().eligible()) throw new ResponseStatusException(HttpStatus.CONFLICT, "客户尚不具备下单资格,请先完成客户资料审核");
} catch (ResponseStatusException error) { throw error; }
catch (RuntimeException error) { throw unavailable(); }
return customer;
}
private List<Customer> load(String keyword, Long id) {
try {
var result = client.customers(token, 1, 30, keyword == null || keyword.isBlank() ? null : keyword.trim(), "ACTIVE", id);
if (result == null || !result.successful() || result.data() == null) throw unavailable();
return result.data().records().stream().filter(row -> row.id() != null && row.id() > 0 && "ACTIVE".equals(row.status()))
.map(row -> new Customer(row.id().toString(), row.tenantCode(), row.tenantName(), row.contactName(), row.contactPhone())).toList();
} catch (ResponseStatusException error) { throw error; }
catch (RuntimeException error) { throw unavailable(); }
}
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试"); }
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.fleet.domain;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
/** Re-evaluated by the server when planning, changing or departing a trip. */
public final class DriverQualificationPolicy {
private DriverQualificationPolicy() { }
public enum ReviewStatus { PENDING, APPROVED, REJECTED }
public enum Reason {
ELIGIBLE, INACTIVE, MISSING_LICENSE, MISSING_EXPIRY, PENDING_REVIEW, REJECTED,
EXPIRED, MISSING_TRIP_END, INVALID_TRIP_END, EXPIRES_DURING_TRIP
}
public record Driver(boolean active, ReviewStatus reviewStatus, boolean hasLicense,
LocalDate licenseExpiresOn, ZoneId licenseZone) {
public Driver {
Objects.requireNonNull(reviewStatus, "reviewStatus");
Objects.requireNonNull(licenseZone, "licenseZone");
}
}
public record Qualification(boolean canDispatch, boolean expiringSoon, Reason reason,
LocalDate licenseExpiresOn) { }
public static Qualification evaluate(Driver driver, Instant now, Instant expectedEnd) {
Objects.requireNonNull(driver, "driver");
Objects.requireNonNull(now, "now");
LocalDate today = now.atZone(driver.licenseZone()).toLocalDate();
LocalDate expiry = driver.licenseExpiresOn();
long days = expiry == null ? Long.MAX_VALUE : ChronoUnit.DAYS.between(today, expiry);
boolean expiring = days >= 0 && days <= 30;
Reason reason;
if (!driver.active()) reason = Reason.INACTIVE;
else if (!driver.hasLicense()) reason = Reason.MISSING_LICENSE;
else if (expiry == null) reason = Reason.MISSING_EXPIRY;
else if (driver.reviewStatus() == ReviewStatus.REJECTED) reason = Reason.REJECTED;
else if (driver.reviewStatus() != ReviewStatus.APPROVED) reason = Reason.PENDING_REVIEW;
else if (days < 0) reason = Reason.EXPIRED;
else if (expectedEnd == null) reason = Reason.MISSING_TRIP_END;
else if (!expectedEnd.isAfter(now)) reason = Reason.INVALID_TRIP_END;
// A date-only licence is valid through that calendar day in its recorded zone.
else if (!expectedEnd.isBefore(expiry.plusDays(1).atStartOfDay(driver.licenseZone()).toInstant())) {
reason = Reason.EXPIRES_DURING_TRIP;
} else reason = Reason.ELIGIBLE;
return new Qualification(reason == Reason.ELIGIBLE, expiring, reason, expiry);
}
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.fleet.domain;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Pure capacity calculation; the caller must lock the trip before confirming reservations. */
public final class TripCapacityPolicy {
private TripCapacityPolicy() { }
public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) {
public Load {
if (palletSpaces < 0 || weightKg == null || volumeCbm == null
|| weightKg.signum() < 0 || volumeCbm.signum() < 0) {
throw new IllegalArgumentException("托位、重量和体积须为非负数");
}
}
public Load plus(Load other) {
return new Load(Math.addExact(palletSpaces, other.palletSpaces),
weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm));
}
}
public record Booking(String bookingNo, int pickupIndex, int deliveryIndex, Load load) {
public Booking {
if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号");
if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后");
Objects.requireNonNull(load, "load");
if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) {
throw new IllegalArgumentException("承运货物须有有效托位、重量和体积");
}
}
}
public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { }
public static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings) {
Objects.requireNonNull(capacity, "capacity");
Objects.requireNonNull(bookings, "bookings");
if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点");
if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
throw new IllegalArgumentException("车辆容量必须大于零");
}
var numbers = new HashSet<String>();
for (Booking booking : bookings) {
Objects.requireNonNull(booking, "booking");
if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配");
if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线");
}
List<Segment> segments = new ArrayList<>();
for (int index = 0; index < stopCount - 1; index++) {
Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO);
for (Booking booking : bookings) {
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load());
}
if (occupied.palletSpaces() > capacity.palletSpaces()
|| occupied.weightKg().compareTo(capacity.weightKg()) > 0
|| occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) {
throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制");
}
Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(),
capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm()));
segments.add(new Segment(index, index + 1, occupied, remaining));
}
return List.copyOf(segments);
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.fleet.domain;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Operating-cost allocation, not customer billing. One currency per calculation. */
public final class TripCostAllocationPolicy {
private TripCostAllocationPolicy() { }
public record Share(String bookingNo, long palletSpaces) {
public Share {
if (bookingNo == null || bookingNo.isBlank() || palletSpaces <= 0) {
throw new IllegalArgumentException("成本分摊须有承运编号及正数托位");
}
}
}
public record Allocation(String bookingNo, BigDecimal amount) { }
private record Fraction(String bookingNo, BigInteger cents, BigInteger remainder) { }
public static List<Allocation> allocate(BigDecimal total, String currency, List<Share> shares) {
Objects.requireNonNull(total, "total");
Objects.requireNonNull(shares, "shares");
if (!("USD".equals(currency) || "CNY".equals(currency))) throw new IllegalArgumentException("不支持的分摊币种");
if (total.signum() < 0) throw new IllegalArgumentException("直接成本不能为负数");
BigInteger cents;
try { cents = total.setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).toBigIntegerExact(); }
catch (ArithmeticException e) { throw new IllegalArgumentException("金额最多两位小数", e); }
if (shares.isEmpty()) throw new IllegalArgumentException("没有可分摊的实运货物");
var numbers = new HashSet<String>();
BigInteger spaces = BigInteger.ZERO;
for (Share share : shares) {
if (!numbers.add(share.bookingNo())) throw new IllegalArgumentException("成本分摊承运编号重复");
spaces = spaces.add(BigInteger.valueOf(share.palletSpaces()));
}
List<Fraction> fractions = new ArrayList<>();
BigInteger assigned = BigInteger.ZERO;
for (Share share : shares) {
BigInteger[] result = cents.multiply(BigInteger.valueOf(share.palletSpaces())).divideAndRemainder(spaces);
fractions.add(new Fraction(share.bookingNo(), result[0], result[1]));
assigned = assigned.add(result[0]);
}
// Largest remainder with a stable business-number tie-break conserves every cent.
fractions.sort(Comparator.comparing(Fraction::remainder).reversed().thenComparing(Fraction::bookingNo));
int extraCents = cents.subtract(assigned).intValueExact();
List<Allocation> result = new ArrayList<>();
for (int i = 0; i < fractions.size(); i++) {
Fraction fraction = fractions.get(i);
BigInteger amount = fraction.cents().add(i < extraCents ? BigInteger.ONE : BigInteger.ZERO);
result.add(new Allocation(fraction.bookingNo(), new BigDecimal(amount, 2)));
}
result.sort(Comparator.comparing(Allocation::bookingNo));
return List.copyOf(result);
}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_driver")
public class DriverEntity {
@TableId(type = IdType.INPUT) private Long id;
private String driverNo;
private String name;
private String phone;
private String ownership;
private String region;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private Long warehouseId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String warehouseCode;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String warehouseName;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String vehicleModels;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String languagesAndArea;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate medicalExpiresOn;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate trainingExpiresOn;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate insuranceExpiresOn;
private Boolean active;
private String reviewStatus;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String licenseNumber;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String licenseType;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate licenseIssuedOn;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate licenseExpiresOn;
private String licenseZone;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private Long licenseFileId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private Long recordVersion;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.driver;
import org.springframework.web.multipart.MultipartFile;
public interface DriverFileGateway {
DriverModels.File upload(long driverId, String operatorId, MultipartFile file);
DriverModels.File metadata(long driverId, long fileId);
byte[] download(long driverId, long fileId);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_driver_history")
public class DriverHistoryEntity {
@TableId(type = IdType.INPUT) private Long id;
private Long driverId;
private Long recordVersion;
private String action;
private String reason;
private Long licenseFileId;
private String snapshotJson;
private String operatorId;
private LocalDateTime occurredAt;
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DriverHistoryMapper extends BaseMapper<DriverHistoryEntity> { }
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import java.time.LocalDate;
import java.util.List;
@Mapper
public interface DriverMapper extends BaseMapper<DriverEntity> {
@Select("SELECT * FROM ll_fleet_driver WHERE id = #{id} FOR UPDATE")
DriverEntity lockById(@Param("id") long id);
@Select("SELECT DISTINCT license_zone FROM ll_fleet_driver")
List<String> licenseZones();
@Select("SELECT license_zone, license_expires_on, ownership, COUNT(*) AS driver_count FROM ll_fleet_driver ${ew.customSqlSegment} GROUP BY license_zone, license_expires_on, ownership")
List<DateGroup> dateGroups(@Param(Constants.WRAPPER) Wrapper<DriverEntity> query);
record DateGroup(String licenseZone, LocalDate licenseExpiresOn, String ownership, long driverCount) { }
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
public final class DriverModels {
private DriverModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, Boolean active, String reviewStatus,
String ownership, String region, String licenseState,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Draft(@NotBlank @Size(max = 128) String name,
@NotBlank @Size(max = 64) String phone,
@NotBlank @Pattern(regexp = "OWNED|DISPATCHED") String ownership,
@NotBlank @Pattern(regexp = "DOMESTIC|OVERSEAS") String region,
@Size(max = 128) String licenseNumber, @Size(max = 64) String licenseType,
LocalDate licenseIssuedOn, LocalDate licenseExpiresOn,
@NotBlank @Size(max = 64) String licenseZone,
@Size(max = 1000) String remark, @Valid Profile profile) { }
public record Profile(@Positive Long warehouseId, @Size(max = 128) String vehicleModels,
@Size(max = 256) String languagesAndArea, LocalDate medicalExpiresOn,
LocalDate trainingExpiresOn, LocalDate insuranceExpiresOn) { }
public record WarehouseOption(String id, String code, String name, String region) { }
public record Summary(long total, long owned, long dispatched, long expiringSoon, long expired) { }
public record DriverPage(java.util.List<Response> records, long page, long size, long total, long pages, Summary summary) { }
public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details) { }
public record Submit(@NotNull @Min(0) Long recordVersion) { }
public record ChangeStatus(@NotNull @Min(0) Long recordVersion, @NotNull Boolean active,
@NotBlank @Size(max = 1000) String reason) { }
public record Review(@NotNull @Min(0) Long recordVersion,
@NotBlank @Pattern(regexp = "APPROVED|REJECTED") String decision,
@Size(max = 1000) String reason) { }
public record Response(String id, String driverNo, Draft details, boolean active, String reviewStatus,
String licenseFileId, long recordVersion, String updatedBy, LocalDateTime updatedAt,
DriverQualificationPolicy.Qualification qualification, String licenseState,
String warehouseCode, String warehouseName) { }
public record History(long recordVersion, String action, String reason, String operatorId,
LocalDateTime occurredAt, Response snapshot) { }
public record File(String fileId, String fileName, String contentType, long sizeBytes) { }
public record Download(File metadata, byte[] content) { }
}
@@ -0,0 +1,346 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@Service
public class DriverService {
private final DriverMapper drivers;
private final DriverHistoryMapper history;
private final DriverFileGateway files;
private final ObjectMapper json;
private final Clock clock;
private final FleetWarehouseDirectory warehouses;
@Autowired
public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) {
this(drivers, history, files, json, Clock.systemUTC(), warehouses);
}
DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, Clock clock, FleetWarehouseDirectory warehouses) {
this.drivers = drivers;
this.history = history;
this.files = files;
this.json = json;
this.clock = clock;
this.warehouses = warehouses;
}
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public DriverModels.DriverPage page(DriverModels.Query query) {
Instant now = clock.instant();
var filter = filter(query, now);
var summary = summary(drivers.dateGroups(filter), now);
filter.orderByDesc(DriverEntity::getUpdatedAt, DriverEntity::getId);
var page = drivers.selectPage(new Page<>(query.page(), query.size()), filter);
return new DriverModels.DriverPage(page.getRecords().stream().map(row -> response(row, now, query.expectedEnd())).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary);
}
private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) {
String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态");
String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属");
String region = optionalChoice(query.region(), Set.of("DOMESTIC", "OVERSEAS"), "区域");
String keyword = trim(query.keyword());
var filter = new LambdaQueryWrapper<DriverEntity>()
.eq(query.active() != null, DriverEntity::getActive, query.active())
.eq(review != null, DriverEntity::getReviewStatus, review)
.eq(ownership != null, DriverEntity::getOwnership, ownership)
.eq(region != null, DriverEntity::getRegion, region);
if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword)
.or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword));
String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期");
if ("MISSING".equals(state)) filter.isNull(DriverEntity::getLicenseExpiresOn);
else if (state != null) {
List<String> zones = drivers.licenseZones();
if (zones.isEmpty()) filter.eq(DriverEntity::getId, -1L);
else filter.and(group -> {
for (String zone : zones) {
LocalDate today = now.atZone(ZoneId.of(zone)).toLocalDate();
group.or(part -> {
part.eq(DriverEntity::getLicenseZone, zone);
if ("EXPIRED".equals(state)) part.lt(DriverEntity::getLicenseExpiresOn, today);
else if ("EXPIRING".equals(state)) part.between(DriverEntity::getLicenseExpiresOn, today, today.plusDays(30));
else part.gt(DriverEntity::getLicenseExpiresOn, today.plusDays(30));
});
}
});
}
return filter;
}
private DriverModels.Summary summary(List<DriverMapper.DateGroup> groups, Instant now) {
long total = 0, owned = 0, dispatched = 0, expiring = 0, expired = 0;
for (var group : groups) {
long count = group.driverCount(); total += count;
if ("OWNED".equals(group.ownership())) owned += count; else dispatched += count;
String state = licenseState(group.licenseExpiresOn(), group.licenseZone(), now);
if ("EXPIRING".equals(state)) expiring += count;
if ("EXPIRED".equals(state)) expired += count;
}
return new DriverModels.Summary(total, owned, dispatched, expiring, expired);
}
public List<DriverModels.WarehouseOption> warehouseOptions(String region) { return warehouses.options(region); }
public DriverModels.Response detail(long id, Instant expectedEnd) {
return response(required(id), clock.instant(), expectedEnd);
}
@Transactional
public DriverModels.Response create(String operator, DriverModels.Draft draft) {
validate(draft);
var row = new DriverEntity();
row.setId(IdWorker.getId());
row.setDriverNo("DR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(java.util.Locale.ROOT));
assign(row, draft);
row.setActive(true);
row.setReviewStatus("PENDING");
row.setRecordVersion(0L);
row.setCreatedBy(operator);
row.setCreatedAt(now());
row.setUpdatedBy(operator);
row.setUpdatedAt(row.getCreatedAt());
if (drivers.insert(row) != 1) throw conflict();
record(row, "CREATE", null, operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response update(long id, String operator, DriverModels.Update request) {
validate(request.details());
DriverEntity row = locked(id, request.recordVersion());
DriverModels.Draft next = request.details();
boolean credentialChanged = !Objects.equals(row.getName(), trim(next.name()))
|| !Objects.equals(row.getLicenseNumber(), trim(next.licenseNumber()))
|| !Objects.equals(row.getLicenseType(), trim(next.licenseType()))
|| !Objects.equals(row.getLicenseIssuedOn(), next.licenseIssuedOn())
|| !Objects.equals(row.getLicenseExpiresOn(), next.licenseExpiresOn())
|| !Objects.equals(row.getLicenseZone(), next.licenseZone());
assign(row, next);
if (credentialChanged) row.setReviewStatus("PENDING");
persist(row, request.recordVersion(), "UPDATE", credentialChanged ? "证照或身份信息变化,重新待审" : null, operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response changeStatus(long id, String operator, DriverModels.ChangeStatus request) {
DriverEntity row = locked(id, request.recordVersion());
if (trim(request.reason()) == null) throw bad("启停原因不能为空");
row.setActive(request.active());
persist(row, request.recordVersion(), request.active() ? "ACTIVATE" : "DEACTIVATE", trim(request.reason()), operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response submit(long id, String operator, DriverModels.Submit request) {
DriverEntity row = locked(id, request.recordVersion());
if (!"REJECTED".equals(row.getReviewStatus())) throw bad("仅已打回的司机档案需要重新提交");
requireLicense(row);
files.metadata(id, row.getLicenseFileId());
row.setReviewStatus("PENDING");
persist(row, request.recordVersion(), "RESUBMIT", "重新提交审核", operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response review(long id, String operator, DriverModels.Review request) {
DriverEntity row = locked(id, request.recordVersion());
if (!"PENDING".equals(row.getReviewStatus())) throw bad("仅待审核司机可通过或打回;修改证照后重新提交审核");
String decision = optionalChoice(request.decision(), Set.of("APPROVED", "REJECTED"), "审核结果");
if (decision == null) throw bad("审核结果不能为空");
if ("REJECTED".equals(decision) && trim(request.reason()) == null) throw bad("打回原因不能为空");
if ("APPROVED".equals(decision)) {
requireLicense(row);
if (row.getLicenseIssuedOn() != null && row.getLicenseIssuedOn().isAfter(LocalDate.now(clock.withZone(ZoneId.of(row.getLicenseZone())))))
throw bad("驾驶证尚未生效");
if (row.getLicenseExpiresOn().isBefore(LocalDate.now(clock.withZone(ZoneId.of(row.getLicenseZone())))))
throw bad("驾驶证已过期,不能通过审核");
files.metadata(id, row.getLicenseFileId());
}
row.setReviewStatus(decision);
persist(row, request.recordVersion(), decision, trim(request.reason()), operator);
return response(row, clock.instant(), null);
}
@Transactional
public DriverModels.Response uploadLicense(long id, long version, String operator, MultipartFile file) {
DriverEntity row = locked(id, version);
DriverModels.File uploaded = files.upload(id, operator, file);
row.setLicenseFileId(Long.parseLong(uploaded.fileId()));
row.setReviewStatus("PENDING");
persist(row, version, "UPLOAD_LICENSE", "驾驶证附件更新,重新待审", operator);
return response(row, clock.instant(), null);
}
public PageResult<DriverModels.History> history(long id, int page, int size) {
required(id);
var result = history.selectPage(new Page<>(page, size), new LambdaQueryWrapper<DriverHistoryEntity>()
.eq(DriverHistoryEntity::getDriverId, id).orderByDesc(DriverHistoryEntity::getRecordVersion));
List<DriverModels.History> records = result.getRecords().stream().map(event -> {
try {
return new DriverModels.History(event.getRecordVersion(), event.getAction(), event.getReason(), event.getOperatorId(),
event.getOccurredAt(), json.readValue(event.getSnapshotJson(), DriverModels.Response.class));
} catch (JsonProcessingException e) { throw new IllegalStateException("司机历史快照读取失败", e); }
}).toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
public DriverModels.Download download(long id, long fileId) {
DriverEntity row = required(id);
if (!Objects.equals(row.getLicenseFileId(), fileId) && history.selectCount(new LambdaQueryWrapper<DriverHistoryEntity>()
.eq(DriverHistoryEntity::getDriverId, id).eq(DriverHistoryEntity::getLicenseFileId, fileId)) == 0)
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "驾驶证附件未关联该司机");
var metadata = files.metadata(id, fileId);
return new DriverModels.Download(metadata, files.download(id, fileId));
}
/** Called by trip commands inside their transaction, so a concurrent credential edit cannot bypass the gate. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public DriverModels.Response requireDispatchable(long id, Instant expectedEnd) {
DriverEntity row = drivers.lockById(id);
if (row == null) throw notFound();
var result = response(row, clock.instant(), expectedEnd);
if (!result.qualification().canDispatch()) throw bad(blockReason(result.qualification().reason()));
files.metadata(id, row.getLicenseFileId());
return result;
}
private DriverEntity required(long id) {
DriverEntity row = drivers.selectById(id);
if (row == null) throw notFound();
return row;
}
private DriverEntity locked(long id, long version) {
DriverEntity row = drivers.lockById(id);
if (row == null) throw notFound();
if (!Objects.equals(row.getRecordVersion(), version)) throw conflict();
return row;
}
private void persist(DriverEntity row, long version, String action, String reason, String operator) {
row.setRecordVersion(version + 1);
row.setUpdatedBy(operator);
row.setUpdatedAt(now());
if (drivers.update(row, new LambdaUpdateWrapper<DriverEntity>().eq(DriverEntity::getId, row.getId())
.eq(DriverEntity::getRecordVersion, version)) != 1) throw conflict();
record(row, action, reason, operator);
}
private void record(DriverEntity row, String action, String reason, String operator) {
var event = new DriverHistoryEntity();
event.setId(IdWorker.getId());
event.setDriverId(row.getId());
event.setRecordVersion(row.getRecordVersion());
event.setAction(action);
event.setReason(reason);
event.setLicenseFileId(row.getLicenseFileId());
event.setOperatorId(operator);
event.setOccurredAt(row.getUpdatedAt());
try { event.setSnapshotJson(json.writeValueAsString(response(row, clock.instant(), null))); }
catch (JsonProcessingException e) { throw new IllegalStateException("司机历史快照写入失败", e); }
if (history.insert(event) != 1) throw conflict();
}
private DriverModels.Response response(DriverEntity row, Instant now, Instant expectedEnd) {
var details = new DriverModels.Draft(row.getName(), row.getPhone(), row.getOwnership(), row.getRegion(),
row.getLicenseNumber(), row.getLicenseType(), row.getLicenseIssuedOn(), row.getLicenseExpiresOn(), row.getLicenseZone(), row.getRemark(),
new DriverModels.Profile(row.getWarehouseId(), row.getVehicleModels(), row.getLanguagesAndArea(), row.getMedicalExpiresOn(), row.getTrainingExpiresOn(), row.getInsuranceExpiresOn()));
var qualification = DriverQualificationPolicy.evaluate(new DriverQualificationPolicy.Driver(Boolean.TRUE.equals(row.getActive()),
DriverQualificationPolicy.ReviewStatus.valueOf(row.getReviewStatus()), row.getLicenseFileId() != null,
row.getLicenseExpiresOn(), ZoneId.of(row.getLicenseZone())), now, expectedEnd);
return new DriverModels.Response(row.getId().toString(), row.getDriverNo(), details, Boolean.TRUE.equals(row.getActive()),
row.getReviewStatus(), row.getLicenseFileId() == null ? null : row.getLicenseFileId().toString(),
row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt(), qualification,
licenseState(row.getLicenseExpiresOn(), row.getLicenseZone(), now), row.getWarehouseCode(), row.getWarehouseName());
}
private static void validate(DriverModels.Draft draft) {
if (trim(draft.name()) == null || trim(draft.phone()) == null) throw bad("司机姓名和电话不能为空");
if (optionalChoice(draft.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属") == null
|| optionalChoice(draft.region(), Set.of("DOMESTIC", "OVERSEAS"), "区域") == null) throw bad("司机归属和区域不能为空");
try { ZoneId.of(draft.licenseZone()); }
catch (DateTimeException | NullPointerException e) { throw bad("请选择有效的证照时区"); }
for (LocalDate date : new LocalDate[]{draft.licenseIssuedOn(), draft.licenseExpiresOn()}) {
if (date != null && (date.getYear() < 1900 || date.getYear() > 9998)) throw bad("证照日期超出允许范围");
}
if (draft.profile() != null) for (LocalDate date : new LocalDate[]{draft.profile().medicalExpiresOn(), draft.profile().trainingExpiresOn(), draft.profile().insuranceExpiresOn()}) {
if (date != null && (date.getYear() < 1900 || date.getYear() > 9998)) throw bad("其他资质日期超出允许范围");
}
if (draft.licenseIssuedOn() != null && draft.licenseExpiresOn() != null && draft.licenseIssuedOn().isAfter(draft.licenseExpiresOn()))
throw bad("驾驶证到期日期不能早于签发日期");
}
private void assign(DriverEntity row, DriverModels.Draft draft) {
row.setName(trim(draft.name())); row.setPhone(trim(draft.phone()));
row.setOwnership(draft.ownership()); row.setRegion(draft.region());
row.setLicenseNumber(trim(draft.licenseNumber())); row.setLicenseType(trim(draft.licenseType()));
row.setLicenseIssuedOn(draft.licenseIssuedOn()); row.setLicenseExpiresOn(draft.licenseExpiresOn());
row.setLicenseZone(draft.licenseZone()); row.setRemark(trim(draft.remark()));
var profile = draft.profile();
var warehouse = profile == null || profile.warehouseId() == null ? null : warehouses.required(profile.warehouseId(), draft.region());
row.setWarehouseId(warehouse == null ? null : Long.valueOf(warehouse.id()));
row.setWarehouseCode(warehouse == null ? null : warehouse.code());
row.setWarehouseName(warehouse == null ? null : warehouse.name());
row.setVehicleModels(profile == null ? null : trim(profile.vehicleModels()));
row.setLanguagesAndArea(profile == null ? null : trim(profile.languagesAndArea()));
row.setMedicalExpiresOn(profile == null ? null : profile.medicalExpiresOn());
row.setTrainingExpiresOn(profile == null ? null : profile.trainingExpiresOn());
row.setInsuranceExpiresOn(profile == null ? null : profile.insuranceExpiresOn());
}
private static String licenseState(LocalDate expiry, String zone, Instant now) {
if (expiry == null) return "MISSING";
LocalDate today = now.atZone(ZoneId.of(zone)).toLocalDate();
if (expiry.isBefore(today)) return "EXPIRED";
return expiry.isAfter(today.plusDays(30)) ? "VALID" : "EXPIRING";
}
private LocalDateTime now() { return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); }
private static void requireLicense(DriverEntity row) {
if (row.getLicenseFileId() == null || trim(row.getLicenseNumber()) == null || trim(row.getLicenseType()) == null
|| row.getLicenseExpiresOn() == null) throw bad("请补齐驾驶证编号、类型、有效期及附件");
}
private static String blockReason(DriverQualificationPolicy.Reason reason) {
return switch (reason) {
case INACTIVE -> "司机已停用";
case MISSING_LICENSE -> "请上传驾驶证附件";
case MISSING_EXPIRY -> "请补齐驾驶证到期日期";
case PENDING_REVIEW -> "司机证照尚未审核通过";
case REJECTED -> "司机证照已被打回";
case EXPIRED -> "驾驶证已过期";
case MISSING_TRIP_END -> "请填写司机预计任务结束时间";
case INVALID_TRIP_END -> "司机预计任务结束时间必须晚于当前时间";
case EXPIRES_DURING_TRIP -> "驾驶证将在预计任务结束前到期,请更换司机或调整计划";
case ELIGIBLE -> "司机可派车";
};
}
private static String trim(String text) { return text == null || text.isBlank() ? null : text.trim(); }
private static String optionalChoice(String value, Set<String> allowed, String label) {
if (trim(value) == null) return null;
if (!allowed.contains(value)) throw bad(label + "不合法");
return value;
}
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "司机档案不存在"); }
private static ResponseStatusException conflict() { return new ResponseStatusException(HttpStatus.CONFLICT, "司机档案已被修改,请刷新后重试"); }
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "fleetWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}")
public interface FleetWarehouseClient {
@GetMapping("/internal/warehouses/order-catalog")
ApiResponse<List<Warehouse>> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token);
record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { }
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.fleet.driver;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
@Component
public class FleetWarehouseDirectory {
private final FleetWarehouseClient client;
private final String token;
public FleetWarehouseDirectory(FleetWarehouseClient client, @Value("${baiyue.security.internal-service-token:}") String token) {
this.client = client; this.token = token;
}
public List<DriverModels.WarehouseOption> options(String region) {
if (!Set.of("DOMESTIC", "OVERSEAS").contains(region == null ? "" : region))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择国内或海外区域");
try {
var result = client.activeWarehouses(token);
if (result == null || !result.successful() || result.data() == null) throw unavailable();
return result.data().stream().filter(row -> row != null && row.id() != null && row.id() > 0
&& row.warehouseCode() != null && !row.warehouseCode().isBlank() && row.warehouseName() != null && !row.warehouseName().isBlank()
&& ("DOMESTIC".equals(region) ? "DOMESTIC".equals(row.warehouseType())
: Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(row.warehouseType() == null ? "" : row.warehouseType())))
.map(row -> new DriverModels.WarehouseOption(row.id().toString(), row.warehouseCode(), row.warehouseName(), region)).toList();
} catch (ResponseStatusException e) { throw e; }
catch (RuntimeException e) { throw unavailable(); }
}
public DriverModels.WarehouseOption required(long id, String region) {
return options(region).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "所属仓库不存在、已停用或与区域不符"));
}
private static ResponseStatusException unavailable() {
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库目录暂不可用,请重试");
}
}
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpDriverFileGateway implements DriverFileGateway {
static final String TYPE = "FLEET_DRIVER_LICENSE";
static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Set<String> CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}";
private final RestClient client;
private final String token;
public HttpDriverFileGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.client = factory.build(builder, baseUrl, properties.getCustomerVisitFile());
this.token = token;
}
@Override
public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType()));
} catch (IOException e) { throw bad("无法读取驾驶证附件"); }
Envelope result = client.post().uri(PATH, driverId).header("X-Internal-Service-Token", token)
.header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
.body(Envelope.class);
return verified(result, driverId, null);
}
@Override
public DriverModels.File metadata(long driverId, long fileId) {
Envelope result = client.get().uri(PATH + "/{file}", driverId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
.body(Envelope.class);
return verified(result, driverId, fileId);
}
@Override
public byte[] download(long driverId, long fileId) {
byte[] content = client.get().uri(PATH + "/{file}/content", driverId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(byte[].class);
if (content == null || content.length == 0 || content.length > MAX_SIZE) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
static DriverModels.File verified(Envelope result, long driverId, Long fileId) {
if (result == null) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
if (!"200".equals(result.code())) throw unavailable(result.code() != null && result.code().startsWith("A04")
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE);
Metadata data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|| data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前司机或不是有效驾驶证附件");
return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
private static ResponseStatusException unavailable(HttpStatusCode status) {
return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"驾驶证文件不可用或归属校验失败");
}
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,119 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/drivers")
@Tag(name = "Fleet drivers / 司机管理")
public class PlatformDriverController {
private final DriverService service;
public PlatformDriverController(DriverService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<DriverModels.DriverPage> page(@Valid DriverModels.Query query) {
return ApiResponse.success(service.page(query));
}
@GetMapping("/warehouse-options")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<java.util.List<DriverModels.WarehouseOption>> warehouseOptions(@RequestParam String region) {
return ApiResponse.success(service.warehouseOptions(region));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<DriverModels.Response> detail(@PathVariable @Min(1) long id,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) {
return ApiResponse.success(service.detail(id, expectedEnd));
}
@GetMapping("/{id}/qualification")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<DriverQualificationPolicy.Qualification> qualification(@PathVariable @Min(1) long id,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) {
return ApiResponse.success(service.detail(id, expectedEnd).qualification());
}
@GetMapping("/{id}/history")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ApiResponse<PageResult<DriverModels.History>> history(@PathVariable @Min(1) long id,
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) {
return ApiResponse.success(service.history(id, page, size));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:create')")
public ApiResponse<DriverModels.Response> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody DriverModels.Draft draft) {
return ApiResponse.success(service.create(operator(user), draft));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:edit')")
public ApiResponse<DriverModels.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@Valid @RequestBody DriverModels.Update request) {
return ApiResponse.success(service.update(id, operator(user), request));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:status')")
public ApiResponse<DriverModels.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@Valid @RequestBody DriverModels.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(id, operator(user), request));
}
@PostMapping("/{id}/review")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:review')")
public ApiResponse<DriverModels.Response> review(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@Valid @RequestBody DriverModels.Review request) {
return ApiResponse.success(service.review(id, operator(user), request));
}
@PostMapping("/{id}/submit")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:edit')")
public ApiResponse<DriverModels.Response> submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@Valid @RequestBody DriverModels.Submit request) {
return ApiResponse.success(service.submit(id, operator(user), request));
}
@PostMapping(value = "/{id}/license", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:upload')")
public ApiResponse<DriverModels.Response> upload(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id,
@RequestParam @Min(0) long recordVersion, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadLicense(id, recordVersion, operator(user), file));
}
@GetMapping("/{id}/license/{fileId}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')")
public ResponseEntity<byte[]> download(@PathVariable @Min(1) long id, @PathVariable @Min(1) long fileId) {
var file = service.download(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.metadata().fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpVehicleCareFileGateway implements VehicleCareFileGateway {
private static final String TYPE = "FLEET_VEHICLE_RECORD_PHOTO";
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_VEHICLE_RECORD_PHOTO/{record}";
private static final long MAX_SIZE = 10L * 1024 * 1024;
private final RestClient client;
private final String token;
public HttpVehicleCareFileGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token;
}
@Override public VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("运维照片须为非空 JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try { body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType())); }
catch (IOException error) { throw bad("无法读取运维照片"); }
var result = client.post().uri(PATH, recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class);
return verified(result, recordId, null);
}
@Override public VehicleCareModels.File metadata(long recordId, long fileId) {
return verified(client.get().uri(PATH + "/{file}", recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), recordId, fileId);
}
@Override public byte[] download(long recordId, long fileId) {
var response = client.get().uri(PATH + "/{file}/content", recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class);
return imageContent(response);
}
static byte[] imageContent(ResponseEntity<byte[]> response) {
byte[] content = response.getBody();
if (content == null || content.length == 0 || content.length > MAX_SIZE
|| !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
static VehicleCareModels.File verified(Envelope result, long recordId, Long fileId) {
if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
var data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !TYPE.equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前运维记录或不是有效照片");
return new VehicleCareModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "运维照片不可用或归属校验失败"); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records")
public class PlatformVehicleCareController {
private final VehicleCareService service;
public PlatformVehicleCareController(VehicleCareService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<PageResult<VehicleCareModels.Response>> page(@PathVariable @Min(1) long vehicleId, @Valid VehicleCareModels.Query query) {
return ApiResponse.success(service.page(vehicleId, query));
}
@GetMapping("/{recordNo}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<VehicleCareModels.Response> detail(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo) {
return ApiResponse.success(service.detail(vehicleId, recordNo));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-create')")
public ApiResponse<VehicleCareModels.Response> create(@PathVariable @Min(1) long vehicleId, @AuthenticationPrincipal AuthenticatedUser user,
@RequestBody @Valid VehicleCareModels.Create request) {
return ApiResponse.success(service.create(vehicleId, operator(user), request));
}
@PutMapping("/{recordNo}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-edit')")
public ApiResponse<VehicleCareModels.Response> update(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid VehicleCareModels.Update request) {
return ApiResponse.success(service.update(vehicleId, recordNo, operator(user), request));
}
@PostMapping(value = "/{recordNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-upload')")
public ApiResponse<VehicleCareModels.Response> upload(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long recordVersion,
@RequestParam @NotBlank @Size(max = 128) String idempotencyKey, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(vehicleId, recordNo, recordVersion, idempotencyKey, operator(user), file));
}
@GetMapping("/{recordNo}/files/{fileId}")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ResponseEntity<byte[]> download(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo, @PathVariable @Min(1) long fileId) {
var file = service.download(vehicleId, recordNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.metadata().fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
}
@GetMapping("/{recordNo}/history")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<PageResult<VehicleCareModels.History>> history(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return ApiResponse.success(service.history(vehicleId, recordNo, page, size));
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_vehicle_record")
public class VehicleCareEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String recordNo;
private Long vehicleId;
private String vehiclePlate;
private String recordType;
private LocalDateTime occurredAt;
private String detailsJson;
private String filesJson;
private String idempotencyKey;
private String requestHash;
private Long recordVersion;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.springframework.web.multipart.MultipartFile;
public interface VehicleCareFileGateway {
VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file);
VehicleCareModels.File metadata(long recordId, long fileId);
byte[] download(long recordId, long fileId);
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_vehicle_record_history")
public class VehicleCareHistory {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long recordId;
private Long recordVersion;
private String action;
private String reason;
private String operatorId;
private LocalDateTime occurredAt;
private String snapshotJson;
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VehicleCareHistoryMapper extends BaseMapper<VehicleCareHistory> { }
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
@Mapper
public interface VehicleCareMapper extends BaseMapper<VehicleCareEntity> {
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo}")
VehicleCareEntity find(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo} FOR UPDATE")
VehicleCareEntity lock(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE")
VehicleCareEntity lockCreated(@Param("vehicleId") long vehicleId, @Param("operator") String operator, @Param("key") String key);
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.maintenance;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class VehicleCareModels {
private VehicleCareModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size,
@Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Draft(@NotBlank @Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType,
@NotNull Instant occurredAt,
@Pattern(regexp = "KM|MI") String distanceUnit,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal odometer,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal lastServiceOdometer,
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal nextServiceOdometer,
@DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 3) BigDecimal fuelQuantity,
@Pattern(regexp = "L|US_GAL|IMP_GAL") String fuelUnit,
@Pattern(regexp = "PENDING|VERIFIED") String verificationStatus,
@Size(max = 1000) String remark, @Valid Incident incident) { }
public record Incident(@NotBlank @Size(max = 2000) String description,
@Size(max = 256) String item, @Size(max = 512) String place,
@Size(max = 128) String handler,
@NotBlank @Pattern(regexp = "PENDING|IN_PROGRESS|COMPLETED") String processingStatus,
@Pattern(regexp = "ROUTE|TIMEOUT|DELIVERY|VEHICLE_BREAKDOWN") String exceptionType,
@DecimalMin("0") @Digits(integer = 12, fraction = 2) BigDecimal reportedCost,
@Pattern(regexp = "[A-Z]{3}") String currency) { }
public record Create(@NotBlank @Size(max = 128) String idempotencyKey, @NotNull @Valid Draft details) { }
public record Update(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason,
@NotNull @Valid Draft details) { }
public record File(String fileId, String fileName, String contentType, long sizeBytes) { }
public record Attached(String uploadKey, String sha256, File file) { }
public record Response(String recordNo, String vehicleId, String vehiclePlate, Draft details,
List<File> files, long recordVersion, String updatedBy, Instant updatedAt, String paymentStatus) { }
public record History(long recordVersion, String action, String reason, String operatorId,
Instant occurredAt, Response snapshot) { }
public record Download(File metadata, byte[] content) { }
}
@@ -0,0 +1,181 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.*;
import java.util.*;
@Service
public class VehicleCareService {
private final VehicleCareMapper records;
private final VehicleCareHistoryMapper histories;
private final VehicleRepository vehicles;
private final VehicleCareFileGateway files;
private final ObjectMapper json;
public VehicleCareService(VehicleCareMapper records, VehicleCareHistoryMapper histories,
VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json) {
this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json;
}
public PageResult<VehicleCareModels.Response> page(long vehicleId, VehicleCareModels.Query query) {
vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing);
var page = records.selectPage(new Page<>(query.page(), query.size()), new LambdaQueryWrapper<VehicleCareEntity>()
.eq(VehicleCareEntity::getVehicleId, vehicleId)
.eq(query.recordType() != null, VehicleCareEntity::getRecordType, query.recordType())
.orderByDesc(VehicleCareEntity::getOccurredAt, VehicleCareEntity::getId));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); }
@Transactional
public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) {
// Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response.
var vehicle = vehicles.lockById(vehicleId).orElseThrow(VehicleCareService::missing);
String fingerprint = digest(write(request.details()).getBytes(StandardCharsets.UTF_8));
var prior = records.lockCreated(vehicleId, operator, request.idempotencyKey());
if (prior != null) {
if (!prior.getRequestHash().equals(fingerprint)) throw conflict("请求标识已用于其他运维记录内容");
return response(prior);
}
validate(request.details());
var row = new VehicleCareEntity(); row.setId(IdWorker.getId());
row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType());
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]");
row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L);
row.setCreatedBy(operator); row.setUpdatedBy(operator); row.setCreatedAt(utc(Instant.now())); row.setUpdatedAt(row.getCreatedAt());
if (records.insert(row) != 1) throw conflict("运维记录保存失败");
record(row, "CREATE", null, operator);
return response(row);
}
@Transactional
public VehicleCareModels.Response update(long vehicleId, String number, String operator, VehicleCareModels.Update request) {
var row = required(vehicleId, number, true); version(row, request.recordVersion()); validate(request.details());
if (!row.getRecordType().equals(request.details().recordType())) throw conflict("已保存记录不能改变运维类型");
if (request.reason() == null || request.reason().isBlank()) throw bad("请填写更正原因");
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details()));
persist(row, request.recordVersion(), "UPDATE", request.reason(), operator);
return response(row);
}
@Transactional
public VehicleCareModels.Response upload(long vehicleId, String number, long expectedVersion, String uploadKey, String operator, MultipartFile file) {
var row = required(vehicleId, number, true);
if (uploadKey == null || uploadKey.isBlank() || uploadKey.length() > 128) throw bad("附件请求标识无效");
if (file == null || file.isEmpty() || file.getSize() > 10L * 1024 * 1024
|| !Set.of("image/jpeg", "image/png").contains(Objects.toString(file.getContentType(), ""))) throw bad("请选择非空 JPG 或 PNG 图片,最大 10MiB");
String fingerprint;
try { fingerprint = digest(file.getBytes()); } catch (IOException error) { throw bad("无法读取附件"); }
var attached = new ArrayList<>(attached(row));
var replay = attached.stream().filter(item -> item.uploadKey().equals(uploadKey)).findFirst();
if (replay.isPresent()) {
if (!replay.get().sha256().equals(fingerprint)) throw conflict("附件请求标识已用于不同图片");
return response(row);
}
version(row, expectedVersion);
if (attached.size() >= 20) throw bad("每条记录最多保存 20 张图片");
var uploaded = files.upload(row.getId(), operator, file);
attached.add(new VehicleCareModels.Attached(uploadKey, fingerprint, uploaded)); row.setFilesJson(write(attached));
persist(row, expectedVersion, "UPLOAD", uploaded.fileName(), operator);
return response(row);
}
public VehicleCareModels.Download download(long vehicleId, String number, long fileId) {
var row = required(vehicleId, number, false);
if (attached(row).stream().noneMatch(item -> item.file().fileId().equals(Long.toString(fileId)))) throw missing();
var metadata = files.metadata(row.getId(), fileId);
return new VehicleCareModels.Download(metadata, files.download(row.getId(), fileId));
}
public PageResult<VehicleCareModels.History> history(long vehicleId, String number, int page, int size) {
var row = required(vehicleId, number, false);
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<VehicleCareHistory>()
.eq(VehicleCareHistory::getRecordId, row.getId()).orderByDesc(VehicleCareHistory::getRecordVersion));
return new PageResult<>(result.getRecords().stream().map(entry -> new VehicleCareModels.History(entry.getRecordVersion(),
entry.getAction(), entry.getReason(), entry.getOperatorId(), entry.getOccurredAt().toInstant(ZoneOffset.UTC),
read(entry.getSnapshotJson(), VehicleCareModels.Response.class))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
private void persist(VehicleCareEntity row, long expected, String action, String reason, String operator) {
row.setRecordVersion(expected + 1); row.setUpdatedBy(operator); row.setUpdatedAt(utc(Instant.now()));
if (records.update(row, new LambdaUpdateWrapper<VehicleCareEntity>().eq(VehicleCareEntity::getId, row.getId())
.eq(VehicleCareEntity::getRecordVersion, expected)) != 1) throw conflict("记录已变化,请刷新后重试");
record(row, action, reason, operator);
}
private void record(VehicleCareEntity row, String action, String reason, String operator) {
var history = new VehicleCareHistory(); history.setId(IdWorker.getId()); history.setRecordId(row.getId());
history.setRecordVersion(row.getRecordVersion()); history.setAction(action); history.setReason(reason);
history.setOperatorId(operator); history.setOccurredAt(row.getUpdatedAt()); history.setSnapshotJson(write(response(row)));
if (histories.insert(history) != 1) throw conflict("记录历史保存失败");
}
private VehicleCareModels.Response response(VehicleCareEntity row) {
return new VehicleCareModels.Response(row.getRecordNo(), row.getVehicleId().toString(), row.getVehiclePlate(),
read(row.getDetailsJson(), VehicleCareModels.Draft.class), attached(row).stream().map(VehicleCareModels.Attached::file).toList(),
row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt().toInstant(ZoneOffset.UTC),
Set.of("REPAIR", "RESCUE").contains(row.getRecordType()) ? "UNLINKED" : null);
}
private VehicleCareEntity required(long vehicleId, String number, boolean lock) {
var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number);
if (row == null) throw missing(); return row;
}
private static void version(VehicleCareEntity row, long expected) { if (row.getRecordVersion() != expected) throw conflict("记录已变化,请刷新后重试"); }
static void validate(VehicleCareModels.Draft value) {
if (value.occurredAt() == null || value.occurredAt().isAfter(Instant.now())) throw bad("记录时间不能晚于当前时间");
if (value.occurredAt().atOffset(ZoneOffset.UTC).getYear() < 1900) throw bad("记录时间须为 1900 年及以后");
if (Set.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION").contains(Objects.toString(value.recordType(), ""))) {
validateIncident(value); return;
}
if (value.incident() != null) throw bad("保养加油不能混入维修、故障、救援或异常字段");
if (value.odometer() == null || value.odometer().signum() < 0 || !Set.of("KM", "MI").contains(Objects.toString(value.distanceUnit(), ""))) throw bad("请填写有效里程及单位");
if (!Set.of("PENDING", "VERIFIED").contains(Objects.toString(value.verificationStatus(), ""))) throw bad("核验状态无效");
if ("MAINTENANCE".equals(value.recordType())) {
if (value.lastServiceOdometer() == null || value.nextServiceOdometer() == null || value.lastServiceOdometer().signum() < 0
|| value.lastServiceOdometer().compareTo(value.odometer()) > 0 || value.nextServiceOdometer().compareTo(value.lastServiceOdometer()) <= 0)
throw bad("上次保养里程须不大于记录时里程,下次保养里程须大于上次保养里程");
if (value.fuelQuantity() != null || value.fuelUnit() != null) throw bad("保养记录不能包含加油字段");
} else if ("FUEL".equals(value.recordType())) {
if (value.fuelQuantity() == null || value.fuelQuantity().signum() <= 0 || value.fuelUnit() == null
|| !Set.of("L", "US_GAL", "IMP_GAL").contains(value.fuelUnit())) throw bad("请填写正数加油量并明确升、美制加仑或英制加仑");
if (value.lastServiceOdometer() != null || value.nextServiceOdometer() != null) throw bad("加油记录不能包含保养里程字段");
} else throw bad("记录类型无效");
}
private static void validateIncident(VehicleCareModels.Draft value) {
if (value.distanceUnit() != null || value.odometer() != null || value.lastServiceOdometer() != null || value.nextServiceOdometer() != null
|| value.fuelQuantity() != null || value.fuelUnit() != null || value.verificationStatus() != null) throw bad("处理记录不能混入保养、加油或核验字段");
var incident = value.incident();
if (incident == null || blank(incident.description())) throw bad("请填写事件描述");
if (!Set.of("PENDING", "IN_PROGRESS", "COMPLETED").contains(Objects.toString(incident.processingStatus(), ""))) throw bad("处理状态无效");
if (!"PENDING".equals(incident.processingStatus()) && blank(incident.handler())) throw bad("处理中或已完成记录须填写处理人");
boolean costRecord = Set.of("REPAIR", "RESCUE").contains(value.recordType());
if (costRecord && (blank(incident.item()) || blank(incident.place()))) throw bad("请填写维修或救援项目及地点");
if (!costRecord && (incident.item() != null || incident.place() != null || incident.reportedCost() != null || incident.currency() != null))
throw bad("故障和异常记录不接受维修救援费用字段");
if ("EXCEPTION".equals(value.recordType())) {
if (!Set.of("ROUTE", "TIMEOUT", "DELIVERY", "VEHICLE_BREAKDOWN").contains(Objects.toString(incident.exceptionType(), ""))) throw bad("请选择异常类型");
} else if (incident.exceptionType() != null) throw bad("非异常记录不能设置异常类型");
if (incident.reportedCost() != null) {
if (incident.reportedCost().signum() < 0 || blank(incident.currency())) throw bad("费用须为非负数并填写币种");
try { Currency.getInstance(incident.currency()); } catch (IllegalArgumentException error) { throw bad("费用币种无效"); }
} else if (incident.currency() != null) throw bad("未登记费用时不能填写币种");
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private List<VehicleCareModels.Attached> attached(VehicleCareEntity row) {
try { return json.readValue(row.getFilesJson(), new TypeReference<>() { }); } catch (JsonProcessingException error) { throw new IllegalStateException(error); }
}
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
private static String digest(byte[] value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); }
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆或当前车辆的记录不存在"); }
private static ResponseStatusException bad(String reason) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason); }
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data @TableName("ll_fleet_external_booking")
public class FleetBookingEntity {
@TableId(type=IdType.INPUT) private Long id;
private String bookingNo;
private Long tripId;
private String tripNo;
private String customerId;
private String customerCode;
private String customerName;
private String status;
private String cargoJson;
private String destinationKey;
private String destinationLabel;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private BigDecimal agreedAmount;
private String idempotencyKey;
private String requestHash;
private String confirmKey;
private String confirmHash;
private String cancelKey;
private String cancelHash;
private String incomingNo;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String dispatchNo;
private String receiptJson;
private String incomingKey;
private String incomingHash;
private String receiptKey;
private String receiptHash;
private String bindingKey;
private String bindingHash;
private Long recordVersion;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,90 @@
package com.baiyue.lastleg.fleet.trip;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
/** Confirmed commercial facts only; delivery owns fee publication in its transaction. */
@Component
public class FleetBookingFreight {
private final FleetBookingMapper bookings;
private final FleetTripDispatchMapper bindings;
private final FleetBookingReservations reservations;
private final ObjectMapper json;
public FleetBookingFreight(FleetBookingMapper bookings, FleetTripDispatchMapper bindings,
FleetBookingReservations reservations, ObjectMapper json) {
this.bookings = bookings; this.bindings = bindings; this.reservations = reservations; this.json = json;
}
public record Quote(String bookingNo, long recordVersion, String incomingNo, String inboundCode,
String customerName, String destinationKey, String currency, BigDecimal amount,
BigDecimal quantity, String quantityUnit, BigDecimal unitPrice) { }
public record Preview(String snapshotHash, boolean requiresDeliveryFee, List<Quote> quotes,
@com.fasterxml.jackson.annotation.JsonIgnore List<String> priorInboundCodes) { }
/** Caller serializes transfer with the dispatch lock. Never lock the parent trip here. */
public Preview preview(long dispatchId, String dispatchNo, String customerId,
List<FleetDispatchGateway.SourceCargo> sources) {
var quotes = new ArrayList<Quote>();
var priorInboundCodes = new TreeSet<String>();
var grouped = new TreeMap<String, List<FleetDispatchGateway.SourceCargo>>();
boolean requiresFee = false;
for (var source : sources) {
if (source.inboundCode() == null || source.inboundCode().isBlank()) requiresFee = true;
else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source);
}
for (var entry : grouped.entrySet()) {
var booking = bookings.incomingForFreight(entry.getKey());
if (booking == null) { requiresFee = true; continue; }
if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致");
var accepted = reservations.receipt(booking);
if (accepted != null && accepted.source().inboundCode() != null && !accepted.source().inboundCode().isBlank())
priorInboundCodes.add(accepted.source().inboundCode());
if ("CANCELLED".equals(booking.getStatus())) { requiresFee = true; continue; }
var binding = bindings.findByDispatchNo(dispatchNo);
if (!"BOUND".equals(booking.getStatus()) || !customerId.equals(booking.getCustomerId())
|| !dispatchNo.equals(booking.getDispatchNo()) || binding == null
|| !Objects.equals(binding.getDispatchId(), dispatchId)
|| !Objects.equals(binding.getTripId(), booking.getTripId()))
throw conflict("外接货须先在承诺车次中完成实收确认和排车关联");
if (accepted == null) throw conflict("外接货缺少实收与报价确认");
if (accepted.source().inboundCode() == null || accepted.source().inboundCode().isBlank())
throw conflict("外接实收缺少实际入仓码,请先核对来货来源");
var actual = new TreeMap<String, Integer>();
for (var source : entry.getValue()) {
if (source.warehousePalletId() == null || !Objects.equals(booking.getDestinationKey(), source.destinationKey()))
throw conflict("外接发运来源或目的地已变化,请重新核对");
actual.merge(source.warehousePalletId(), source.packages(), Math::addExact);
}
var confirmed = new TreeMap<String, Integer>();
for (var pallet : accepted.source().pallets()) confirmed.merge(pallet.warehousePalletId(), pallet.packages(), Math::addExact);
if (!actual.equals(confirmed)) throw conflict("外接发运数量与已确认实收不一致");
var cargo = reservations.cargo(booking);
BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE;
if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null
|| quantity.multiply(cargo.unitPrice()).compareTo(booking.getAgreedAmount()) != 0)
throw conflict("外接约定运费与实收计价依据不一致");
if (!Set.of("CNY", "USD").contains(cargo.currency())) throw conflict("外接运费入账仅支持CNY或USD,请先更正约定币种");
quotes.add(new Quote(booking.getBookingNo(), booking.getRecordVersion(), booking.getIncomingNo(),
accepted.source().inboundCode(), booking.getCustomerName(), booking.getDestinationKey(), cargo.currency(),
booking.getAgreedAmount(), quantity, "PALLET".equals(cargo.priceBasis()) ? "托位" : "单", cargo.unitPrice()));
}
if (sources.isEmpty()) requiresFee = true;
try {
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
json.writeValueAsString(List.of(dispatchNo, customerId, sources.stream().sorted(Comparator
.comparing(FleetDispatchGateway.SourceCargo::inboundCode, Comparator.nullsFirst(String::compareTo))
.thenComparing(FleetDispatchGateway.SourceCargo::warehousePalletId, Comparator.nullsFirst(String::compareTo))
.thenComparing(FleetDispatchGateway.SourceCargo::destinationKey, Comparator.nullsFirst(String::compareTo))
.thenComparingInt(FleetDispatchGateway.SourceCargo::packages)).toList(), quotes)).getBytes(StandardCharsets.UTF_8)));
return new Preview(hash, requiresFee, List.copyOf(quotes), List.copyOf(priorInboundCodes));
} catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) {
throw new IllegalStateException(error);
}
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.trip;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** The transfer slice owns coming-goods documents and physical receiving facts. */
public interface FleetBookingIncomingGateway {
record Incoming(String customerId,String warehouseId,String warehouseName,String bookingNo,FleetBookingModels.Cargo cargo,
LocalDateTime estimatedArrivalAt,String deliveryInstructions,String privateAddress,String postalCode,String recipientName,String recipientPhone) { }
record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { }
record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status,
String receiptHash,List<Pallet> pallets,int receivedPackages) { }
Evidence create(String operator, Incoming incoming);
Evidence inspect(String customerId,String incomingNo,boolean lock);
Evidence confirm(String customerId,String incomingNo,String operator,String key);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> {
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'")
FleetBookingEntity incoming(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}")
FleetBookingEntity incomingForFreight(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} ORDER BY id")
List<FleetBookingEntity> list(@Param("tripId") long tripId);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND created_by=#{operator} AND idempotency_key=#{key}")
FleetBookingEntity created(@Param("tripId") long tripId,@Param("operator") String operator,@Param("key") String key);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND booking_no=#{number} FOR UPDATE")
FleetBookingEntity lock(@Param("tripId") long tripId,@Param("number") String number);
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.fleet.trip;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class FleetBookingModels {
private FleetBookingModels() { }
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,
@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,
@Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { }
public record Create(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Positive Long customerId,
@NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo) { }
public record Command(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { }
public record StartIncoming(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
Instant estimatedArrivalAt,@Size(max=1000) String deliveryInstructions,@Size(max=1000) String privateAddress,
@Size(max=32) String postalCode,@Size(max=128) String recipientName,@Size(max=64) String recipientPhone,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces) { }
public record AcceptReceipt(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
@NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets,
@NotNull @DecimalMin("0.00") @Digits(integer=10,fraction=2) BigDecimal unitPrice,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record BindDispatch(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(max=64) String dispatchNo,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record IncomingPreview(long tripRecordVersion,long recordVersion,FleetBookingIncomingGateway.Evidence incoming) { }
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,
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt) { }
public record Result(long tripRecordVersion, Response booking, boolean replayed) { }
public record ListResult(long tripRecordVersion, List<Response> records) { }
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.time.ZoneOffset;
import java.util.*;
/** Read under the parent trip lock whenever changing reservations or capacity. */
@Component
public class FleetBookingReservations {
private final FleetBookingMapper bookings;
private final ObjectMapper 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 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.Response response(FleetBookingEntity row) {
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));
}
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
Long excludedId, FleetBookingEntity addition) {
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
for (var pallet:own.pallets()) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(),
new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm())));
for(var row:bookings.list(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row);
if(addition!=null) add(loads,stops,addition);
try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); }
catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());}
}
public void requireReassignmentFits(FleetTripEntity trip, Integer spaces, java.math.BigDecimal weight, java.math.BigDecimal volume) {
if(!hasConfirmed(trip.getId())) return;
if(trip.getPlannedLoadJson()==null) throw conflict("已有外接承运,请先重新核对计划容量再改派");
var own=read(trip.getPlannedLoadJson(),FleetExecutionModels.PlannedLoad.class);
for(var segment:segments(trip,own,null,null)) if(segment.occupied().palletSpaces()>spaces
||segment.occupied().weightKg().compareTo(weight)>0||segment.occupied().volumeCbm().compareTo(volume)>0)
throw conflict("替换车辆无法容纳自有货及已确认外接预订");
}
public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);}
private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());}
public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List<FleetDispatchGateway.SourceCargo> sources,String operator){
var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode));
for(var entry:bySource.entrySet()){
var row=bookings.incoming(entry.getKey());if(row==null)continue;
if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订");
if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车");
var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认");
var expected=new TreeMap<String,Integer>();
for(var pallet:accepted.source().pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact);
var selected=new TreeMap<String,Integer>();
for(var item:entry.getValue()){
if(item.warehousePalletId()==null||!Objects.equals(item.destinationKey(),row.getDestinationKey()))throw conflict("排车货物托盘或目的地与外接实收不一致");
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact);
}
if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物");
row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,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)){
row.setStatus("RECEIVED");row.setDispatchNo(null);touch(row,operator);
}
}
private void touch(FleetBookingEntity row,String operator){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(java.time.LocalDateTime.now(ZoneOffset.UTC));if(bookings.updateById(row)!=1)throw conflict("外接关联已变化");}
public void requireRoute(FleetTripEntity trip,List<FleetTripModels.Stop> stops) {
for(var row:bookings.list(trip.getId())) if((unbound(row)||"BOUND".equals(row.getStatus())) && index(stops,row.getDestinationKey())<1)
throw conflict("路线仍须包含已确认外接预订的卸货站");
}
private void add(List<TripCapacityPolicy.Booking> loads,List<FleetTripModels.Stop> stops,FleetBookingEntity row){
var cargo=cargo(row); int index=index(stops,row.getDestinationKey());
if(index<1) throw conflict("外接预订卸货站不在当前路线");
if(cargo.palletSpaces()==null||cargo.weightKg()==null||cargo.volumeCbm()==null) throw conflict("确认承运前请补齐预计托位、重量和体积");
try {loads.add(new TripCapacityPolicy.Booking(row.getBookingNo(),0,index,new TripCapacityPolicy.Load(cargo.palletSpaces(),cargo.weightKg(),cargo.volumeCbm())));}
catch(IllegalArgumentException error){throw conflict(error.getMessage());}
}
public List<FleetTripModels.Stop> stops(FleetTripEntity trip){
try{return json.readValue(trip.getStopsJson(),new TypeReference<>(){});}catch(JsonProcessingException error){throw new IllegalStateException(error);}
}
public static int index(List<FleetTripModels.Stop> stops,String key){for(int i=1;i<stops.size();i++)if(Objects.equals(stops.get(i).destinationKey(),key))return i;return -1;}
private <T>T read(String value,Class<T> type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException(error);}}
private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);}
}
@@ -0,0 +1,175 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
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.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Currency;
import java.util.HexFormat;
import java.util.Objects;
@Service
public class FleetBookingService {
private final FleetTripMapper trips;
private final FleetBookingMapper bookings;
private final FleetBookingReservations reservations;
private final FleetTripService history;
private final FleetTripExecutionService execution;
private final FleetCustomerDirectory customers;
private final ObjectMapper json;
private final FleetBookingIncomingGateway incoming;
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming){
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
}
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));}
@Transactional
public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){
var trip=trip(tripNo,true);String hash=hash(request);
var existing=bookings.created(trip.getId(),operator,request.idempotencyKey());
if(existing!=null){if(!hash.equals(existing.getRequestHash()))throw conflict("同一登记请求不能变更客户或货物内容");return result(trip,existing,true);}
requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId());
var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo);
row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT");
apply(trip,row,request.cargo());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L);
row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt());
bookings.insert(row);history.recordExecution(trip,request.tripRecordVersion(),"BOOKING_CREATE","登记外接询价",operator);
return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result update(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());
if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留");
apply(trip,row,request.cargo());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
}
@Transactional
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);
if(Objects.equals(row.getConfirmKey(),request.idempotencyKey())){if(!hash.equals(row.getConfirmHash()))throw conflict("承运确认请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"DRAFT".equals(row.getStatus()))throw conflict("仅询价中的外接货可以确认承运");
customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row);
if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种");
if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种");
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先核对本车自有货计划容量,再确认外接承运");
reservations.segments(trip,plan.capacityPlan(),null,row);
row.setStatus("CONFIRMED");row.setConfirmKey(request.idempotencyKey());row.setConfirmHash(hash);
save(trip,row,operator,"BOOKING_CONFIRM",reason(request.reason(),"确认外接承运并预留容量"));return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result cancel(String tripNo,String bookingNo,String operator,FleetBookingModels.Command 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);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!java.util.Set.of("DRAFT","CONFIRMED","RECEIVED").contains(row.getStatus()))throw conflict("请先解除尚未开工的排车关联,再取消预订;已拣货须走现场撤回流程");
row.setStatus("CANCELLED");row.setCancelKey(request.idempotencyKey());row.setCancelHash(hash);
save(trip,row,operator,"BOOKING_CANCEL",reason(request.reason(),"取消未执行外接预订并释放容量"));return result(trip,row,false);
}
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingModels.IncomingPreview incoming(String tripNo,String bookingNo){
var trip=trip(tripNo,false);var row=bookings.list(trip.getId()).stream().filter(value->value.getBookingNo().equals(bookingNo)).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"外接预订不存在"));
return new FleetBookingModels.IncomingPreview(trip.getRecordVersion(),row.getRecordVersion(),row.getIncomingNo()==null?null:incoming.inspect(row.getCustomerId(),row.getIncomingNo(),false));
}
@Transactional
public FleetBookingModels.Result startIncoming(String tripNo,String bookingNo,String operator,FleetBookingModels.StartIncoming request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getIncomingKey(),request.idempotencyKey())){if(!hash.equals(row.getIncomingHash()))throw conflict("来货登记请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作");
customers.required(Long.parseLong(row.getCustomerId()));
var arrival=request.estimatedArrivalAt()==null?trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).minusSeconds(7200):request.estimatedArrivalAt();
var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row),
LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),request.deliveryInstructions(),request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone()));
requireEvidence(trip,row,evidence);
row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash);
save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false);
}
@Transactional
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);
if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货");
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
if(!java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(evidence.status())||evidence.pallets().isEmpty())throw conflict("仓库尚未完成实收及测量");
if(!Objects.equals(evidence.receiptHash(),request.receiptHash()))throw conflict("实收数据已变化,请刷新后重新核对");
var spaces=new java.util.HashMap<String,Integer>();
for(var entry:request.pallets())if(entry.palletSpaces()<1||spaces.put(entry.warehousePalletId(),entry.palletSpaces())!=null)throw conflict("请为每个实际托盘填写一次占用托位");
var physical=evidence.pallets().stream().map(FleetBookingIncomingGateway.Pallet::warehousePalletId).collect(java.util.stream.Collectors.toSet());
if(!spaces.keySet().equals(physical)||physical.contains(null))throw conflict("托位确认须与实际收货托盘一致");
BigDecimal weight=BigDecimal.ZERO,volume=BigDecimal.ZERO;int count=0;
for(var pallet:evidence.pallets()){
if(pallet.packages()<=0||pallet.weightKg()==null||pallet.volumeCbm()==null||pallet.weightKg().signum()<=0||pallet.volumeCbm().signum()<=0)throw conflict("实际托盘缺少有效箱数、重量或体积");
weight=weight.add(pallet.weightKg());volume=volume.add(pallet.volumeCbm());count=Math.addExact(count,pallet.packages());
}
int totalSpaces=spaces.values().stream().reduce(0,Math::addExact);var old=reservations.cargo(row);
apply(trip,row,new FleetBookingModels.Cargo(old.destinationKey(),old.cargoName(),count,totalSpaces,weight,volume,old.priceBasis(),request.unitPrice(),old.currency(),old.contactName(),old.contactPhone(),old.remark()));
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先重新核对自有货容量计划,再确认外接实收");
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT");
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.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result bindDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.BindDispatch request){
var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费");
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo));
var result=booking(trip,bookingNo);
if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物");
return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false);
}
private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){
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("来货单引用不一致");
}
private void apply(FleetTripEntity trip,FleetBookingEntity row,FleetBookingModels.Cargo input){
var stops=reservations.stops(trip);int index=FleetBookingReservations.index(stops,input.destinationKey());
if(index<1)throw conflict("请选择本车已有卸货站,外接货首版从本起运仓装车");
String basis=input.priceBasis()==null?"PALLET":input.priceBasis();String currency=trim(input.currency());
if(!basis.equals("PALLET")&&!basis.equals("ORDER"))throw bad("请选择按托或整单报价");
if(currency!=null){currency=currency.toUpperCase(java.util.Locale.ROOT);try{Currency.getInstance(currency);}catch(IllegalArgumentException error){throw bad("币种无效");}}
var cargo=new FleetBookingModels.Cargo(input.destinationKey(),trim(input.cargoName()),input.packageCount(),input.palletSpaces(),input.weightKg(),input.volumeCbm(),
basis,input.unitPrice(),currency,trim(input.contactName()),trim(input.contactPhone()),trim(input.remark()));
row.setCargoJson(write(cargo));row.setDestinationKey(cargo.destinationKey());row.setDestinationLabel(stops.get(index).name());
BigDecimal amount=null;
if(input.unitPrice()!=null && (basis.equals("ORDER")||input.palletSpaces()!=null)) amount=input.unitPrice().multiply(BigDecimal.valueOf(basis.equals("ORDER")?1:input.palletSpaces())).setScale(2);
if(amount!=null && amount.precision()>16)throw bad("约定金额超出支持范围");
row.setAgreedAmount(amount);
}
private void save(FleetTripEntity trip,FleetBookingEntity row,String operator,String action,String reason){
row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(now());
if(bookings.updateById(row)!=1)throw conflict("外接预订已变化,请刷新");
history.recordExecution(trip,trip.getRecordVersion(),action,reason,operator);
}
private FleetBookingModels.Result result(FleetTripEntity trip,FleetBookingEntity row,boolean replayed){return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),replayed);}
private FleetTripEntity trip(String number,boolean lock){var value=lock?trips.lockByNo(number):trips.findByNo(number);if(value==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");return value;}
private FleetBookingEntity booking(FleetTripEntity trip,String number){var row=bookings.lock(trip.getId(),number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前车次无此外接预订");return row;}
private static void requireTrip(FleetTripEntity trip,long version){if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);}
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);}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.fleet.trip;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.util.List;
public final class FleetCompletionModels {
private FleetCompletionModels() { }
public record Outcome(String dispatchNo, String deliveryNo, String customerId, String destination,
String status, int packages, Instant terminalAt) { }
public record DispatchCompletion(String dispatchNo, String warehouseId, List<Outcome> outcomes, String blockReason) { }
public record Inspection(String tripNo, long recordVersion, boolean canComplete, String blockReason, List<Outcome> outcomes) { }
public record Complete(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) { }
public record Completed(String idempotencyKey, String requestHash, Instant completedAt, Instant recordedAt,
String operatorId, List<Outcome> outcomes) { }
public record Release(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "VEHICLE|DRIVER") String resourceKind,
@NotNull @PastOrPresent Instant actualEndAt, @NotBlank @Size(max = 500) String endLocation,
@Pattern(regexp = "IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus,
@NotBlank @Size(min = 8, max = 128) String idempotencyKey, @NotBlank @Size(max = 1000) String reason) { }
public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String endLocation,
String vehicleStatus, String operatorId, Instant recordedAt) { }
public record ReleaseInfo(Instant actualEndAt, String endLocation, String vehicleStatus) { }
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.fleet.trip;
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;
@Service
public class FleetDispatchBindingGuard {
private final FleetTripDispatchMapper bindings;
private final com.fasterxml.jackson.databind.ObjectMapper json;
public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, com.fasterxml.jackson.databind.ObjectMapper json) { this.bindings = bindings; this.json = json; }
/** Dispatch -> task -> binding locks; never acquire the parent trip lock from a PDA pick. */
@Transactional(propagation = Propagation.MANDATORY)
public void requirePickingApproved(long dispatchId) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "请先关联车次并通过调度审核");
var review = FleetTripReview.read(json, binding.getReviewJson());
if (!"APPROVED".equals(review.status())) throw new ResponseStatusException(HttpStatus.CONFLICT,
"REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货");
}
@Transactional(propagation = Propagation.MANDATORY)
public void requireMatchingAssignment(long dispatchId, FleetDispatchGateway.Assignment actual, FleetDispatchGateway.Assignment requested) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding != null && (changed(requested.vehiclePlate(), actual.vehiclePlate()) || changed(requested.driverName(), actual.driverName())
|| changed(requested.driverPhone(), actual.driverPhone())))
throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆和司机由车次 " + binding.getTripNo() + " 统一维护");
}
private static boolean changed(String requested, String actual) {
return requested != null && !requested.isBlank() && !requested.trim().equals(actual);
}
/** Must be called after locking the customer dispatch, which serializes binding and mutations. */
@Transactional(propagation = Propagation.MANDATORY)
public void requireUnbound(long dispatchId) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding != null) throw new ResponseStatusException(HttpStatus.CONFLICT,
"排车单已关联车次 " + binding.getTripNo() + ",请先在车次中解除关联");
}
@Transactional(propagation = Propagation.MANDATORY)
public void requireCoordinatedDeparture(long dispatchId) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding == null || !"DEPARTING".equals(bindings.tripStatus(dispatchId)))
throw new ResponseStatusException(HttpStatus.CONFLICT, "请通过车次统一发车");
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.fleet.trip;
import java.time.Instant;
/** Customer dispatches remain owned by dispatch; fleet only orchestrates their physical trip. */
public interface FleetDispatchGateway {
record Snapshot(long dispatchId, String dispatchNo, String customerId, String warehouseId, int packageCount) { }
record SourceCargo(String inboundCode,String warehousePalletId,String palletNo,int packages,String destinationKey) { }
java.util.List<SourceCargo> sourceCargo(String dispatchNo);
record Assignment(String vehiclePlate, String driverName, String driverPhone) { }
static String destinationKey(String type,String code,String fba,String address,String postalCode) {
if(!"PRIVATE".equals(type)){String value=code==null||code.isBlank()?fba:code;return value==null||value.isBlank()?null:"FBA:"+value.trim().toUpperCase(java.util.Locale.ROOT);}
if(address==null||address.isBlank())return null;
String key=address.strip().replaceAll("\\s+"," ").toUpperCase(java.util.Locale.ROOT)+"|"+java.util.Objects.requireNonNullElse(postalCode,"").strip().toUpperCase(java.util.Locale.ROOT);
try{return "PRIVATE:"+java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(key.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
catch(java.security.NoSuchAlgorithmException error){throw new IllegalStateException(error);}
}
/** Locks the dispatch and rejects any picking or departure already performed. */
Snapshot lockForPlanning(String dispatchNo);
/** Allows pre-departure resource/time changes without changing cargo or recorded picking. */
Snapshot lockForAssignment(String dispatchNo);
/** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */
void assign(Snapshot dispatch, Assignment assignment);
FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock);
FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock);
FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator,
String idempotencyKey, String remark, Instant departedAt);
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class FleetExecutionModels {
private FleetExecutionModels() { }
public record Cargo(String dispatchNo, String customerId, String dispatchItemId, String warehousePalletId,
String palletNo, int packages, String destinationKey, String destinationLabel) { }
public record Manifest(String dispatchNo, String warehouseId, String vehiclePlate, String driverName, String driverPhone,
List<Cargo> cargo, String blockReason) { }
public record Measurement(@NotBlank String warehousePalletId, @Min(1) int palletSpaces,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal weightKg,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal volumeCbm,
@Min(1) int deliveryIndex) { }
public record Confirm(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "[0-9a-f]{64}") String manifestHash,
@NotEmpty @Size(max = 1000) List<@NotNull @Valid Measurement> pallets,
@NotBlank @Size(max = 1000) String reason) { }
public record ConfirmedLoad(String manifestHash, List<Cargo> cargo, List<Measurement> pallets,
List<TripCapacityPolicy.Segment> segments, String operatorId, Instant confirmedAt) { }
public record Loading(String tripNo, long recordVersion, List<Cargo> cargo, String manifestHash,
ConfirmedLoad confirmedLoad, String blockReason) { }
public record PlannedLoad(String manifestHash, List<Cargo> cargo, List<Measurement> pallets,
List<TripCapacityPolicy.Segment> segments, String operatorId, Instant confirmedAt) { }
public record Planning(String tripNo, long recordVersion, List<Cargo> cargo, String manifestHash,
PlannedLoad capacityPlan, boolean planValid, String blockReason, List<TripCapacityPolicy.Segment> availableSegments) { }
public record Depart(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey,
@Size(max = 1000) String remark) { }
public record DepartedDispatch(String dispatchNo, long shippedPackages) { }
public record Departed(String tripNo, String status, Instant departedAt, long shippedPackages,
List<DepartedDispatch> dispatches, boolean replayed) {
public Departed withReplay() { return new Departed(tripNo, status, departedAt, shippedPackages, dispatches, true); }
}
public record Gate(String tripNo, Long recordVersion, int dispatchCount, boolean canDepart, String blockReason, String driverWarning,
boolean canPick, String pickBlockReason) {
public Gate withPicking(boolean allowed, String reason) { return new Gate(tripNo, recordVersion, dispatchCount, canDepart, blockReason, driverWarning, allowed, reason); }
}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.trip;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FleetResourceMapper {
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}")
Assignment find(@Param("kind") String kind, @Param("id") long id);
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id")
List<Assignment> byTrip(@Param("tripId") long tripId);
@Insert("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES(#{kind},#{id},#{tripId},#{tripNo})")
int reserve(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId, @Param("tripNo") String tripNo);
@Delete("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} AND trip_id=#{tripId}")
int release(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId);
record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { }
}
@@ -0,0 +1,144 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class FleetTripCompletionService {
private final FleetTripMapper trips;
private final FleetTripDispatchMapper bindings;
private final FleetResourceMapper resources;
private final FleetDispatchGateway dispatches;
private final FleetTripService history;
private final VehicleRepository vehicles;
private final VehicleApplicationService vehicleService;
private final ObjectMapper json;
public FleetTripCompletionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
FleetDispatchGateway dispatches, FleetTripService history, VehicleRepository vehicles,
VehicleApplicationService vehicleService, ObjectMapper json) {
this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches;
this.history = history; this.vehicles = vehicles; this.vehicleService = vehicleService; this.json = json;
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetCompletionModels.Inspection inspection(String tripNo) { return inspect(required(tripNo, false), false); }
@Transactional
public FleetTripModels.Detail complete(String tripNo, String operator, FleetCompletionModels.Complete request) {
var trip = required(tripNo, true);
String hash = hash(write(request));
if (trip.getCompletionJson() != null) {
var prior = read(trip.getCompletionJson(), FleetCompletionModels.Completed.class);
if (!prior.idempotencyKey().equals(request.idempotencyKey()) || !prior.requestHash().equals(hash))
throw conflict("车次已结案,请核对原结案请求");
return history.detail(tripNo);
}
requireVersion(trip, request.recordVersion());
var inspection = inspect(trip, true);
if (!inspection.canComplete()) throw conflict(inspection.blockReason());
Instant completedAt = inspection.outcomes().stream().map(FleetCompletionModels.Outcome::terminalAt).max(Instant::compareTo).orElseThrow();
trip.setStatus("COMPLETED"); trip.setCompletedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC));
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(), hash, completedAt, Instant.now(), operator, inspection.outcomes())));
// Completion freezes cargo outcomes. It deliberately does not release a driver still returning.
history.recordExecution(trip, request.recordVersion(), "COMPLETE", request.reason(), operator);
return history.detail(tripNo);
}
@Transactional
public FleetTripModels.Detail release(String tripNo, String operator, FleetCompletionModels.Release request) {
var trip = required(tripNo, true);
if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务");
boolean vehicle = "VEHICLE".equals(request.resourceKind());
String previous = vehicle ? trip.getVehicleReleaseJson() : trip.getDriverReleaseJson();
String hash = hash(write(request));
if (previous != null) {
var released = read(previous, FleetCompletionModels.Released.class);
if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(hash))
throw conflict("该资源任务已结束,请核对原结束请求");
return history.detail(tripNo);
}
requireVersion(trip, request.recordVersion());
if (!"COMPLETED".equals(trip.getStatus()) || trip.getCompletionJson() == null || trip.getCompletedAt() == null)
throw conflict("请先核对全部货物并完成车次结案");
Instant now = Instant.now();
if (request.actualEndAt() == null || request.actualEndAt().isAfter(now)
|| request.actualEndAt().isBefore(trip.getCompletedAt().toInstant(ZoneOffset.UTC))) throw bad("实际任务结束时间须不早于本车货物履约完成且不晚于当前时间");
if (request.endLocation() == null || request.endLocation().isBlank()) throw bad("请填写实际任务结束地点");
if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus())))
throw bad("请确认车辆结束任务后的实际状态");
if (!vehicle && request.vehicleStatus() != null) throw bad("结束司机任务不能修改车辆状态");
if (vehicle) {
var current = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在"));
requireRelease("VEHICLE", trip.getVehicleId(), trip.getId());
vehicleService.changeStatus(trip.getVehicleId(), operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), current.recordVersion()));
} else {
// A now-expired or disabled driver may end their task; this never re-enables their qualification.
requireRelease("DRIVER", trip.getDriverId(), trip.getId());
}
var released = new FleetCompletionModels.Released(request.idempotencyKey(), hash, request.actualEndAt(),
request.endLocation().trim(), request.vehicleStatus(), operator, now);
if (vehicle) trip.setVehicleReleaseJson(write(released)); else trip.setDriverReleaseJson(write(released));
history.recordExecution(trip, request.recordVersion(), vehicle ? "RELEASE_VEHICLE" : "RELEASE_DRIVER", request.reason(), operator);
return history.detail(tripNo);
}
private FleetCompletionModels.Inspection inspect(FleetTripEntity trip, boolean lock) {
if (trip.getCompletionJson() != null) {
var prior = read(trip.getCompletionJson(), FleetCompletionModels.Completed.class);
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), false, "车次货物已结案", prior.outcomes());
}
var completions = bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.completion(binding.getDispatchNo(), lock)).toList();
var outcomes = completions.stream().flatMap(completion -> completion.outcomes().stream()).toList();
String reason = null;
if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null) reason = "仅已发车的车次可以结案";
else if (completions.isEmpty()) reason = "缺少客户执行单据,不能结案";
else {
for (var completion : completions) {
if (!trip.getWarehouseId().toString().equals(completion.warehouseId())) { reason = "客户执行单据起运仓与车次不一致"; break; }
if (completion.blockReason() != null) { reason = completion.dispatchNo() + ":" + completion.blockReason(); break; }
}
Instant departure = trip.getDepartedAt().toInstant(ZoneOffset.UTC), now = Instant.now();
if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.terminalAt() == null
|| outcome.terminalAt().isBefore(departure) || outcome.terminalAt().isAfter(now))) reason = "签收或退回时间须在发车之后且不晚于当前时间,请核对原单";
}
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), reason == null, reason, outcomes);
}
private void requireRelease(String kind, Long resourceId, long tripId) {
if (resourceId == null || resources.release(kind, resourceId, tripId) != 1) throw conflict("资源占用与车次不一致,结束任务未完成");
}
private FleetTripEntity required(String tripNo, boolean lock) {
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
return trip;
}
private static void requireVersion(FleetTripEntity trip, long version) {
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
}
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
private static String hash(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_trip_dispatch")
public class FleetTripDispatch {
@TableId(type = IdType.INPUT)
private Long dispatchId;
private String dispatchNo;
private String customerId;
private Long tripId;
private String tripNo;
private Integer packageCount;
private String boundBy;
private LocalDateTime boundAt;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String reviewJson;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FleetTripDispatchMapper extends BaseMapper<FleetTripDispatch> {
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE trip_id=#{tripId} ORDER BY dispatch_id")
List<FleetTripDispatch> listByTrip(@Param("tripId") long tripId);
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no=#{dispatchNo}")
FleetTripDispatch findByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("<script>SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no IN <foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach></script>")
List<FleetTripDispatch> findByDispatchNos(@Param("numbers") List<String> numbers);
// Locking read sees a concurrent binding committed while the caller waited for the dispatch row.
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=#{dispatchId} FOR UPDATE")
FleetTripDispatch lockByDispatch(@Param("dispatchId") long dispatchId);
@Select("SELECT t.status FROM ll_fleet_trip_dispatch b JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE b.dispatch_id=#{dispatchId}")
String tripStatus(@Param("dispatchId") long dispatchId);
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_trip")
public class FleetTripEntity {
@TableId(type = IdType.INPUT) private Long id;
private String tripNo;
private String idempotencyKey;
private String requestHash;
private String status;
private Long warehouseId;
private String warehouseCode;
private String warehouseName;
private Long vehicleId;
private String vehiclePlate;
private Long driverId;
private String driverName;
private String driverPhone;
private Integer maxPalletSpaces;
private BigDecimal maxLoadKg;
private BigDecimal maxVolumeCbm;
private LocalDateTime plannedDepartureAt;
private LocalDateTime expectedDriverEndAt;
private LocalDateTime departedAt;
private LocalDateTime completedAt;
private String stopsJson;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String plannedLoadJson;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String loadJson;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String loadingReviewJson;
private String departureKey;
private String departureHash;
private String departureResultJson;
private String completionJson;
private String vehicleReleaseJson;
private String driverReleaseJson;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String remark;
private Long recordVersion;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,318 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.baiyue.lastleg.fleet.driver.DriverModels;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
@Service
public class FleetTripExecutionService {
private final FleetTripMapper trips;
private final FleetTripDispatchMapper bindings;
private final FleetResourceMapper resources;
private final FleetDispatchGateway dispatches;
private final FleetTripService history;
private final DriverService drivers;
private final VehicleRepository vehicles;
private final ObjectMapper json;
private final FleetBookingReservations bookingReservations;
public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers,
VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) {
this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches;
this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations;
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Planning capacityPlan(String tripNo) {
var trip = requireTrip(tripNo, false);
var cargo = cargo(manifests(trip, false));
return planning(trip, cargo);
}
@Transactional
public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) {
var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion());
var cargo = cargo(manifests(trip, true));
String reason = planningBlockReason(trip, cargo);
if (reason != null) throw conflict(reason);
String fingerprint = hash(write(cargo));
if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对计划容量");
var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList();
var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets), operator, Instant.now());
bookingReservations.segments(trip, plan, null, null);
trip.setPlannedLoadJson(write(plan));
history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator);
return planning(trip, cargo);
}
private FleetExecutionModels.Planning planning(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
String fingerprint = hash(write(cargo));
var plan = trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class);
boolean valid = plan != null && plan.manifestHash().equals(fingerprint);
return new FleetExecutionModels.Planning(trip.getTripNo(), trip.getRecordVersion(), cargo, fingerprint, plan, valid, planningBlockReason(trip, cargo), valid ? bookingReservations.segments(trip, plan, null, null) : List.of());
}
private String planningBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量";
if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积";
if (cargo.isEmpty()) return "请先关联本车自有货排车单";
if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null))
return "请先明确每票货物的实物托盘和卸货目的地";
return null;
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Loading loading(String tripNo) {
var trip = requireTrip(tripNo, false);
var manifest = manifests(trip, false);
var cargo = cargo(manifest);
return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, hash(write(cargo)), load(trip), blockReason(trip, manifest));
}
@Transactional
public FleetExecutionModels.Loading confirmLoad(String tripNo, String operator, FleetExecutionModels.Confirm request) {
var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion());
var manifest = manifests(trip, true);
String block = blockReason(trip, manifest);
if (block != null) throw conflict(block);
var cargo = cargo(manifest); String fingerprint = hash(write(cargo));
if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对装载");
var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList();
var segments = capacity(trip, cargo, pallets);
var confirmed = new FleetExecutionModels.ConfirmedLoad(fingerprint, cargo, pallets, segments, operator, Instant.now());
trip.setLoadJson(write(confirmed));
history.recordExecution(trip, request.recordVersion(), "CONFIRM_LOAD", request.reason(), operator);
return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, fingerprint, confirmed, null);
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) return missingGate();
return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding);
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public Map<String, FleetExecutionModels.Gate> gates(String warehouseId, List<String> numbers) {
if (numbers.isEmpty()) return Map.of();
var result = new HashMap<String, FleetExecutionModels.Gate>();
numbers.forEach(number -> result.put(number, missingGate()));
var byTrip = new HashMap<String, FleetExecutionModels.Gate>();
for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate(
byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding));
return result;
}
private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) {
var trip = requireTrip(tripNo, false);
requireWarehouse(trip, warehouseId);
var manifest = manifests(trip, false);
String reason = blockReason(trip, manifest);
String warning = null;
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason == null) {
var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对";
else reason = driverBlockReason(trip, driver);
if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险";
}
boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null;
return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning,
canPick, canPick ? null : "当前车次不允许继续拣货");
}
private static FleetExecutionModels.Gate missingGate() {
return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核");
}
private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) {
if (!gate.canPick()) return gate;
var review = FleetTripReview.read(json, binding.getReviewJson());
String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status())
? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货";
return gate.withPicking(reason == null, reason);
}
private String loadingReviewBlockReason(FleetTripEntity trip) {
var review = FleetTripReview.read(json, trip.getLoadingReviewJson());
return "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "理货审核已打回:" + review.reason() : "请先通过理货审核再发车";
}
@Transactional
public FleetTripModels.Detail reviewLoading(String tripNo, String operator, FleetTripModels.Review request) {
var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion());
String reason = FleetTripReview.reason(request);
if ("APPROVED".equals(request.decision())) {
if (trip.getDriverId() == null) throw conflict("请先分配司机");
var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
String driverBlock = driverBlockReason(trip, driver);
if (driverBlock != null) throw conflict(driverBlock);
var manifest = manifests(trip, true);
String block = blockReason(trip, manifest);
if (block == null) block = loadBlockReason(trip, cargo(manifest));
if (block != null) throw conflict(block);
}
trip.setLoadingReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, Instant.now(), request.recordVersion())));
history.recordExecution(trip, request.recordVersion(), "LOADING_REVIEW", reason, operator);
return history.detail(tripNo);
}
@Transactional
public FleetExecutionModels.Departed depart(String warehouseId, String dispatchNo, String operator, FleetExecutionModels.Depart request) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw conflict("请先在平台将排车单关联车次");
var trip = requireTrip(binding.getTripNo(), true);
requireWarehouse(trip, warehouseId);
// The binding may have changed while waiting for the trip lock. Never ship a stale parent.
var members = bindings.listByTrip(trip.getId());
if (members.stream().noneMatch(member -> member.getDispatchNo().equals(dispatchNo))) throw conflict("排车单所属车次已变化,请刷新");
String requestHash = hash(write(request));
if (trip.getDepartureKey() != null) {
if (!trip.getDepartureKey().equals(request.idempotencyKey()) || !trip.getDepartureHash().equals(requestHash))
throw conflict("车次已发车,发车请求标识或内容不一致");
return read(trip.getDepartureResultJson(), FleetExecutionModels.Departed.class).withReplay();
}
requireVersionAndPlan(trip, request.recordVersion());
// Resource lock order matches trip assignment: trip -> vehicle -> driver -> customer dispatches.
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在"));
if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())
|| !Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate()) || !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces())
|| !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg()) || !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm()))
throw conflict("车辆状态或容量已变化,请重新确认车次");
requireResource("VEHICLE", trip.getVehicleId(), trip.getId()); requireResource("DRIVER", trip.getDriverId(), trip.getId());
var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
String driverBlock = driverBlockReason(trip, driver);
if (driverBlock != null) throw conflict(driverBlock);
var manifest = manifests(trip, true);
String reason = blockReason(trip, manifest);
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason != null) throw conflict(reason);
Instant now = Instant.now();
trip.setStatus("DEPARTING");
if (trips.updateById(trip) != 1) throw conflict("车次状态已变化");
var results = new ArrayList<FleetExecutionModels.DepartedDispatch>();
for (var member : members) results.add(dispatches.depart(warehouseId, member.getDispatchNo(), operator,
trip.getTripNo() + ":" + member.getDispatchNo(), request.remark(), now));
trip.setStatus("IN_TRANSIT"); trip.setDepartedAt(LocalDateTime.ofInstant(now, ZoneOffset.UTC));
var result = new FleetExecutionModels.Departed(trip.getTripNo(), "IN_TRANSIT", now,
results.stream().mapToLong(FleetExecutionModels.DepartedDispatch::shippedPackages).sum(), List.copyOf(results), false);
trip.setDepartureKey(request.idempotencyKey()); trip.setDepartureHash(requestHash); trip.setDepartureResultJson(write(result));
history.recordExecution(trip, request.recordVersion(), "DEPART", request.remark(), operator);
return result;
}
private List<FleetExecutionModels.Manifest> manifests(FleetTripEntity trip, boolean lock) {
return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList();
}
private static List<FleetExecutionModels.Cargo> cargo(List<FleetExecutionModels.Manifest> manifests) {
return manifests.stream().flatMap(manifest -> manifest.cargo().stream())
.sorted(Comparator.comparing(FleetExecutionModels.Cargo::dispatchNo).thenComparing(FleetExecutionModels.Cargo::dispatchItemId)).toList();
}
private String blockReason(FleetTripEntity trip, List<FleetExecutionModels.Manifest> manifests) {
if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划";
if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机";
if (manifests.isEmpty()) return "车次尚未关联排车单";
if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联";
for (var binding : bindings.listByTrip(trip.getId())) {
if (!FleetTripReview.approved(json, binding.getReviewJson())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核";
}
for (var manifest : manifests) {
if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致";
if (!Objects.equals(trip.getVehiclePlate(), manifest.vehiclePlate()) || !Objects.equals(trip.getDriverName(), manifest.driverName())
|| !Objects.equals(trip.getDriverPhone(), manifest.driverPhone())) return "排车单 " + manifest.dispatchNo() + " 的车辆司机与车次不一致";
if (manifest.blockReason() != null) return manifest.dispatchNo() + ":" + manifest.blockReason();
}
return null;
}
private String loadBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
var load = load(trip);
if (load == null) return "请在平台确认本车实际装载";
if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认";
try { capacity(trip, cargo, load.pallets()); return null; }
catch (ResponseStatusException error) { return error.getReason(); }
}
private List<TripCapacityPolicy.Segment> capacity(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo,
List<FleetExecutionModels.Measurement> pallets) {
List<FleetTripModels.Stop> stops;
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
var destinations = new HashMap<String, String>(); var palletNumbers = new HashMap<String, String>();
if (cargo.isEmpty()) throw conflict("没有待发运货物");
for (var item : cargo) {
if (item.warehousePalletId() == null || item.palletNo() == null || item.palletNo().isBlank() || item.destinationKey() == null || item.packages() <= 0)
throw conflict("货物实物托盘、数量或目的地尚未确认");
String previous = destinations.putIfAbsent(item.warehousePalletId(), item.destinationKey());
if (previous != null && !previous.equals(item.destinationKey())) throw conflict("同一实物托盘包含不同卸货目的地,请先拆托");
String oldNumber = palletNumbers.putIfAbsent(item.warehousePalletId(), item.palletNo());
if (oldNumber != null && !oldNumber.equals(item.palletNo())) throw conflict("实物托盘编号不一致,请核对");
}
var measured = new HashSet<String>(); var bookings = new ArrayList<TripCapacityPolicy.Booking>();
try {
for (var pallet : pallets) {
if (!measured.add(pallet.warehousePalletId())) throw conflict("同一实物托盘不能重复计量");
if (!destinations.containsKey(pallet.warehousePalletId())) throw conflict("装载包含不属于本车的实物托盘");
if (pallet.deliveryIndex() < 1 || pallet.deliveryIndex() >= stops.size()
|| !destinations.get(pallet.warehousePalletId()).equals(stops.get(pallet.deliveryIndex()).destinationKey()))
throw conflict("托盘卸货站与货物目的地不一致");
bookings.add(new TripCapacityPolicy.Booking(pallet.warehousePalletId(), 0, pallet.deliveryIndex(),
new TripCapacityPolicy.Load(pallet.palletSpaces(), pallet.weightKg(), pallet.volumeCbm())));
}
if (!measured.equals(destinations.keySet())) throw conflict("请确认每个实物托盘的托位、实际重量及体积");
if (trip.getMaxPalletSpaces() == null || trip.getMaxLoadKg() == null || trip.getMaxVolumeCbm() == null)
throw conflict("车辆容量未确认");
return TripCapacityPolicy.calculate(stops.size(), new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()), bookings);
} catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); }
}
private void requireResource(String kind, long id, long tripId) {
var claim = resources.find(kind, id);
if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化");
}
private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) {
if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案";
if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照";
return null;
}
private FleetTripEntity requireTrip(String tripNo, boolean lock) {
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
return trip;
}
private static void requireWarehouse(FleetTripEntity trip, String warehouseId) {
if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次");
}
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
}
private static boolean equalNumber(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; }
private FleetExecutionModels.ConfirmedLoad load(FleetTripEntity trip) { return trip.getLoadJson() == null ? null : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class); }
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
private static String hash(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_fleet_trip_history")
public class FleetTripHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long tripId;
private Long recordVersion;
private String action;
private String reason;
private String operatorId;
private LocalDateTime occurredAt;
private String snapshotJson;
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FleetTripHistoryMapper extends BaseMapper<FleetTripHistory> { }
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
FleetTripEntity findByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key}")
FleetTripEntity findCreated(@Param("operator") String operator, @Param("key") String key);
@Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE")
FleetTripEntity lockCreated(@Param("operator") String operator, @Param("key") String key);
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class FleetTripModels {
private FleetTripModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword,
String status, @Positive Long warehouseId) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Stop(@NotBlank @Size(max = 128) String name, @Size(max = 500) String address,
@Size(max = 160) String destinationKey) { }
public record Create(@NotNull @Positive Long warehouseId, @NotNull Instant plannedDepartureAt,
@NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops,
@Size(max = 1000) String remark, @NotBlank @Size(min = 8, max = 128) String idempotencyKey) { }
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
@NotNull @Positive Long driverId, @NotBlank @Size(max = 1000) String reason) { }
public record Plan(@NotNull @Min(0) Long recordVersion, @NotNull Instant plannedDepartureAt,
@NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops,
@Size(max = 1000) String remark, @NotBlank @Size(max = 1000) String reason) { }
public record Cancel(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason) { }
public record BindingCommand(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 64) String dispatchNo,
@NotBlank @Size(max = 1000) String reason) { }
public record Review(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "APPROVED|REJECTED") String decision,
@Size(max = 1000) String reason) { }
public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) {
public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); }
}
public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review) { }
public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName,
String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone,
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm,
Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<Stop> stops, String remark, long recordVersion, Instant updatedAt,
FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { }
public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List<DispatchBinding> dispatches, ReviewState loadingReview) { }
public record AuditSnapshot(Response trip, List<DispatchBinding> dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad,
FleetCompletionModels.Completed completion, ReviewState loadingReview,
FleetExecutionModels.PlannedLoad capacityPlan, List<FleetBookingModels.Response> externalBookings) { }
public record History(long recordVersion, String action, String reason, String operatorId, Instant occurredAt, AuditSnapshot snapshot) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.fleet.trip;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
final class FleetTripReview {
private FleetTripReview() { }
static FleetTripModels.ReviewState read(ObjectMapper json, String value) {
if (value == null) return FleetTripModels.ReviewState.pending();
try { return json.readValue(value, FleetTripModels.ReviewState.class); }
catch (JsonProcessingException error) { throw new IllegalStateException("审核记录读取失败", error); }
}
static String reason(FleetTripModels.Review request) {
if (!"APPROVED".equals(request.decision()) && !"REJECTED".equals(request.decision()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择审核通过或打回");
String reason = request.reason() == null ? "" : request.reason().trim();
if ("REJECTED".equals(request.decision()) && reason.isEmpty())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请填写打回原因");
return reason.isEmpty() ? "审核通过" : reason;
}
static boolean approved(ObjectMapper json, String value) { return "APPROVED".equals(read(json, value).status()); }
}
@@ -0,0 +1,336 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.*;
import java.util.*;
@Service
public class FleetTripService {
private final FleetTripMapper trips;
private final FleetResourceMapper resources;
private final FleetTripHistoryMapper histories;
private final FleetTripDispatchMapper bindings;
private final FleetDispatchGateway dispatches;
private final VehicleRepository vehicles;
private final DriverService drivers;
private final FleetWarehouseDirectory warehouses;
private final ObjectMapper json;
private final Clock clock;
private final FleetBookingReservations bookingReservations;
@Autowired
public FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) {
this(trips, resources, histories, vehicles, drivers, warehouses, json, bindings, dispatches, bookingReservations, Clock.systemUTC());
}
FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) {
this.trips = trips; this.resources = resources; this.histories = histories; this.vehicles = vehicles;
this.drivers = drivers; this.warehouses = warehouses; this.json = json; this.clock = clock;
this.bindings = bindings; this.dispatches = dispatches; this.bookingReservations = bookingReservations;
}
public PageResult<FleetTripModels.Response> page(FleetTripModels.Query query) {
if (query.status() != null && !query.status().isBlank() && !Set.of("PLANNED", "IN_TRANSIT", "COMPLETED", "CANCELLED").contains(query.status()))
throw bad("车次状态无效");
var filter = new LambdaQueryWrapper<FleetTripEntity>()
.eq(query.status() != null && !query.status().isBlank(), FleetTripEntity::getStatus, query.status())
.eq(query.warehouseId() != null, FleetTripEntity::getWarehouseId, query.warehouseId());
if (query.keyword() != null && !query.keyword().isBlank()) filter.and(part -> part.like(FleetTripEntity::getTripNo, query.keyword().trim())
.or().like(FleetTripEntity::getVehiclePlate, query.keyword().trim()).or().like(FleetTripEntity::getDriverName, query.keyword().trim()));
filter.orderByDesc(FleetTripEntity::getUpdatedAt, FleetTripEntity::getId);
var page = trips.selectPage(new Page<>(query.page(), query.size()), filter);
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetTripModels.Detail detail(String tripNo) {
FleetTripEntity trip = required(tripNo);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) {
String hash = hash(write(request));
var existing = trips.findCreated(operator, request.idempotencyKey());
if (existing != null) return replay(existing, hash);
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
var trip = new FleetTripEntity();
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash);
trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
trip.setRecordVersion(0L); trip.setCreatedBy(operator); trip.setUpdatedBy(operator);
trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt());
try { if (trips.insert(trip) != 1) throw conflict("车次创建失败"); }
catch (DuplicateKeyException duplicate) {
var concurrent = trips.lockCreated(operator, request.idempotencyKey());
if (concurrent == null) throw conflict("车次编号冲突,请重试");
return replay(concurrent, hash);
}
record(trip, "CREATE", null, operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
// All assigners serialize first on the vehicle, then on the driver. Resource uniqueness is the final safeguard.
var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆");
if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null
|| vehicle.maxPalletSpaces() <= 0 || vehicle.maxLoadKg().signum() <= 0 || vehicle.maxVolumeCbm().signum() <= 0)
throw bad("请先确认车辆的托位、载重和容积");
if (trip.getLoadJson() != null) {
var load = read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class);
if (load.segments().stream().anyMatch(segment -> segment.occupied().palletSpaces() > vehicle.maxPalletSpaces()
|| segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0
|| segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0))
throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排");
}
if (!Objects.equals(trip.getVehicleId(), vehicle.id()))
bookingReservations.requireReassignmentFits(trip, vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm());
var driver = drivers.requireDispatchable(request.driverId(), instant(trip.getExpectedDriverEndAt()));
if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机");
reserve("VEHICLE", request.vehicleId(), trip);
reserve("DRIVER", request.driverId(), trip);
if (trip.getVehicleId() != null && !trip.getVehicleId().equals(request.vehicleId())) release("VEHICLE", trip.getVehicleId(), trip.getId());
if (trip.getDriverId() != null && !trip.getDriverId().equals(request.driverId())) release("DRIVER", trip.getDriverId(), trip.getId());
trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber());
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone());
for (var binding : bindings.listByTrip(trip.getId())) {
var dispatch = dispatches.lockForAssignment(binding.getDispatchNo());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致");
dispatches.assign(dispatch, assignment(trip));
}
persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator);
return new FleetTripModels.Detail(response(trip), driver.qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson()));
}
@Transactional
public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
bookingReservations.requireRoute(trip, request.stops());
if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt());
boolean unchangedRoute = response(trip).stops().equals(request.stops());
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
for (var binding : bindings.listByTrip(trip.getId())) {
if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo());
else dispatches.lockForPlanning(binding.getDispatchNo());
}
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
persist(trip, request.recordVersion(), unchangedPlan ? "REMARK" : "PLAN", request.reason(), operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) {
var trip = planned(tripNo, request.recordVersion());
String reason = FleetTripReview.reason(request);
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
if ("APPROVED".equals(request.decision())) {
var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) throw conflict("车辆当前不可派车");
var driver = drivers.requireDispatchable(trip.getDriverId(), instant(trip.getExpectedDriverEndAt()));
if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机");
}
var dispatch = dispatches.lockForAssignment(dispatchNo);
var binding = bindings.lockByDispatch(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion())));
if (bindings.updateById(binding) != 1) throw conflict("排车单审核保存失败");
trip.setLoadingReviewJson(null);
persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
if (!bindings.listByTrip(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次");
if (trip.getVehicleId() != null) release("VEHICLE", trip.getVehicleId(), trip.getId());
if (trip.getDriverId() != null) release("DRIVER", trip.getDriverId(), trip.getId());
trip.setStatus("CANCELLED");
persist(trip, request.recordVersion(), "CANCEL", request.reason(), operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓");
var bound = bindings.lockByDispatch(dispatch.dispatchId());
if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo());
var binding = new FleetTripDispatch();
binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId());
binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount());
binding.setBoundBy(operator); binding.setBoundAt(now());
try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); }
bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator);
dispatches.assign(dispatch, assignment(trip));
persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator);
return detail(trip);
}
@Transactional
public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
var binding = bindings.lockByDispatch(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null));
if (bindings.deleteById(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试");
bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator);
persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator);
return detail(trip);
}
public PageResult<FleetTripModels.History> history(String tripNo, int page, int size) {
var trip = required(tripNo);
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<FleetTripHistory>()
.eq(FleetTripHistory::getTripId, trip.getId()).orderByDesc(FleetTripHistory::getRecordVersion));
return new PageResult<>(result.getRecords().stream().map(event -> new FleetTripModels.History(event.getRecordVersion(), event.getAction(),
event.getReason(), event.getOperatorId(), instant(event.getOccurredAt()), read(event.getSnapshotJson(), FleetTripModels.AuditSnapshot.class))).toList(),
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
private void reserve(String kind, long resourceId, FleetTripEntity trip) {
var current = resources.find(kind, resourceId);
if (current != null) {
if (current.tripId() == trip.getId()) return;
throw conflict(("VEHICLE".equals(kind) ? "车辆" : "司机") + "已被车次 " + current.tripNo() + " 占用");
}
try { if (resources.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("车辆或司机已被其他车次占用,请刷新后重试"); }
}
private void release(String kind, long resourceId, long tripId) {
if (resources.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成");
}
private FleetTripEntity required(String number) {
var trip = trips.findByNo(number);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
return trip;
}
private FleetTripEntity planned(String number, long version) {
var trip = trips.lockByNo(number);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
return trip;
}
private FleetTripModels.Detail replay(FleetTripEntity trip, String hash) {
if (!Objects.equals(trip.getRequestHash(), hash)) throw conflict("创建请求标识已用于不同车次内容");
return detail(trip);
}
private FleetTripModels.Detail detail(FleetTripEntity trip) {
return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson()));
}
private List<FleetTripModels.DispatchBinding> bindingResponses(FleetTripEntity trip) {
return bindings.listByTrip(trip.getId()).stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()))).toList();
}
private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) {
return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone());
}
private FleetTripModels.Response response(FleetTripEntity trip) {
List<FleetTripModels.Stop> stops;
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
return new FleetTripModels.Response(trip.getTripNo(), trip.getStatus(), trip.getWarehouseId().toString(), trip.getWarehouseCode(), trip.getWarehouseName(),
text(trip.getVehicleId()), trip.getVehiclePlate(), text(trip.getDriverId()), trip.getDriverName(), trip.getDriverPhone(),
trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm(), instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()),
instant(trip.getDepartedAt()), instant(trip.getCompletedAt()), List.copyOf(stops), trip.getRemark(), trip.getRecordVersion(), instant(trip.getUpdatedAt()),
releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson()));
}
private void validatePlan(Instant departure, Instant end, List<FleetTripModels.Stop> stops) {
if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间");
try {
if (utc(departure).getYear() < 1900 || utc(end).getYear() > 9998) throw bad("车次时间超出允许范围");
} catch (DateTimeException invalid) { throw bad("车次时间超出允许范围"); }
if (stops == null || stops.size() < 2 || stops.size() > 200 || stops.stream().anyMatch(stop -> stop == null || trim(stop.name()) == null))
throw bad("车次须包含起点和目的地,最多200个站点");
}
private void persist(FleetTripEntity trip, long version, String action, String reason, String operator) {
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null);
if (Set.of("ASSIGN", "PLAN").contains(action)) bindings.update(null, new LambdaUpdateWrapper<FleetTripDispatch>()
.eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null));
trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator);
if (trips.update(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1)
throw conflict("车次已被修改,请刷新后重试");
record(trip, action, reason, operator);
}
private void record(FleetTripEntity trip, String action, String reason, String operator) {
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt());
event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null
: read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null
: read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()),
trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId()))));
if (histories.insert(event) != 1) throw conflict("车次历史保存失败");
}
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("车次序列化失败", e); } }
private FleetCompletionModels.ReleaseInfo releaseInfo(String value) {
if (value == null) return null;
var released = read(value, FleetCompletionModels.Released.class);
return new FleetCompletionModels.ReleaseInfo(released.actualEndAt(), released.endLocation(), released.vehicleStatus());
}
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) {
persist(trip, version, action, reason, operator);
}
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } }
private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
private LocalDateTime now() { return utc(clock.instant()); }
private static LocalDateTime utc(Instant value) { return LocalDateTime.ofInstant(value, ZoneOffset.UTC); }
private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); }
private static String text(Long value) { return value == null ? null : value.toString(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static void requireReason(String reason) { if (trim(reason) == null) throw bad("请填写操作原因"); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard;
import org.springframework.stereotype.Component;
@Component
public class FleetVehicleAssignmentGuard implements VehicleAssignmentGuard {
private final FleetResourceMapper resources;
public FleetVehicleAssignmentGuard(FleetResourceMapper resources) { this.resources = resources; }
@Override public void requireUnassigned(long vehicleId) {
var assignment = resources.find("VEHICLE", vehicleId);
if (assignment != null) throw new VehicleApplicationService.VehicleFailure(VehicleApplicationService.FailureKind.CONFLICT,
"车辆正在车次 " + assignment.tripNo() + " 中使用,请先在车次中改派或取消安排");
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController @RequestMapping("/api/platform/v1/last-leg/fleet/trips")
public class PlatformFleetBookingController {
private final FleetBookingService service;
private final FleetCustomerDirectory customers;
public PlatformFleetBookingController(FleetBookingService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;}
@GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<List<FleetCustomerDirectory.Customer>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));}
@GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetBookingModels.ListResult> list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));}
@PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')")
public ApiResponse<FleetBookingModels.Result> create(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetBookingModels.Create request){return ApiResponse.success(service.create(tripNo,operator(user),request));}
@PutMapping("/{tripNo}/bookings/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')")
public ApiResponse<FleetBookingModels.Result> update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Update request){return ApiResponse.success(service.update(tripNo,bookingNo,operator(user),request));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/confirm") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')")
public ApiResponse<FleetBookingModels.Result> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.confirm(tripNo,bookingNo,operator(user),request));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/cancel") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-cancel')")
public ApiResponse<FleetBookingModels.Result> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.cancel(tripNo,bookingNo,operator(user),request));}
@GetMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetBookingModels.IncomingPreview> incoming(@PathVariable String tripNo,@PathVariable String bookingNo){return ApiResponse.success(service.incoming(tripNo,bookingNo));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')")
public ApiResponse<FleetBookingModels.Result> incoming(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.startIncoming(tripNo,bookingNo,operator(user),request));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')")
public ApiResponse<FleetBookingModels.Result> receipt(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(tripNo,bookingNo,operator(user),request));}
@PostMapping("/{tripNo}/bookings/{bookingNo}/dispatch") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetBookingModels.Result> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.BindDispatch request){return ApiResponse.success(service.bindDispatch(tripNo,bookingNo,operator(user),request));}
private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");return user.userId().toString();}
}
@@ -0,0 +1,120 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/trips")
public class PlatformFleetTripController {
private final FleetTripService service;
private final FleetTripExecutionService execution;
private final FleetTripCompletionService completion;
public PlatformFleetTripController(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) {
this.service = service; this.execution = execution; this.completion = completion;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetTripModels.Response>> page(@Valid FleetTripModels.Query query) { return ApiResponse.success(service.page(query)); }
@GetMapping("/{tripNo}")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetTripModels.Detail> detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); }
@PostMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:create')")
public ApiResponse<FleetTripModels.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetTripModels.Create request) {
return ApiResponse.success(service.create(operator(user), request));
}
@PutMapping("/{tripNo}/plan")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) {
return ApiResponse.success(service.updatePlan(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/assignment")
@PreAuthorize("hasAuthority('platform:fleet:trip:assign')")
public ApiResponse<FleetTripModels.Detail> assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) {
return ApiResponse.success(service.assign(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/cancel")
@PreAuthorize("hasAuthority('platform:fleet:trip:cancel')")
public ApiResponse<FleetTripModels.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) {
return ApiResponse.success(service.cancel(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/dispatches/bind")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.BindingCommand request) {
return ApiResponse.success(service.bindDispatch(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/dispatches/unbind")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> unbind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.BindingCommand request) {
return ApiResponse.success(service.unbindDispatch(tripNo, operator(user), request));
}
@GetMapping("/{tripNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetTripModels.History>> history(@PathVariable String tripNo,
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) {
return ApiResponse.success(service.history(tripNo, page, size));
}
@PostMapping("/{tripNo}/dispatches/{dispatchNo}/review")
@PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')")
public ApiResponse<FleetTripModels.Detail> reviewDispatch(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@PathVariable String dispatchNo, @Valid @RequestBody FleetTripModels.Review request) {
return ApiResponse.success(service.reviewDispatch(tripNo, dispatchNo, operator(user), request));
}
@PostMapping("/{tripNo}/loading-review")
@PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')")
public ApiResponse<FleetTripModels.Detail> reviewLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.Review request) {
return ApiResponse.success(execution.reviewLoading(tripNo, operator(user), request));
}
@GetMapping("/{tripNo}/loading")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetExecutionModels.Loading> loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); }
@GetMapping("/{tripNo}/capacity-plan")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetExecutionModels.Planning> capacityPlan(@PathVariable String tripNo) { return ApiResponse.success(execution.capacityPlan(tripNo)); }
@PostMapping("/{tripNo}/capacity-plan")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetExecutionModels.Planning> confirmCapacityPlan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetExecutionModels.Confirm request) {
return ApiResponse.success(execution.confirmCapacityPlan(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/loading")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetExecutionModels.Loading> confirmLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetExecutionModels.Confirm request) {
return ApiResponse.success(execution.confirmLoad(tripNo, operator(user), request));
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();
}
@GetMapping("/{tripNo}/completion")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetCompletionModels.Inspection> completion(@PathVariable String tripNo) { return ApiResponse.success(completion.inspection(tripNo)); }
@PostMapping("/{tripNo}/complete")
@PreAuthorize("hasAuthority('platform:fleet:trip:complete')")
public ApiResponse<FleetTripModels.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetCompletionModels.Complete request) {
return ApiResponse.success(completion.complete(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/resources/release")
@PreAuthorize("hasAuthority('platform:fleet:trip:release')")
public ApiResponse<FleetTripModels.Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetCompletionModels.Release request) {
return ApiResponse.success(completion.release(tripNo, operator(user), request));
}
}
@@ -48,6 +48,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
record.setId(IdWorker.getId()); record.setId(IdWorker.getId());
record.setPlateNumber(vehicle.plateNumber()); record.setPlateNumber(vehicle.plateNumber());
record.setModel(vehicle.model()); record.setModel(vehicle.model());
record.setMaxPalletSpaces(vehicle.maxPalletSpaces());
record.setMaxLoadKg(vehicle.maxLoadKg());
record.setMaxVolumeCbm(vehicle.maxVolumeCbm());
record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km()); record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km());
record.setMileageKm(vehicle.mileageKm()); record.setMileageKm(vehicle.mileageKm());
record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany()); record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany());
@@ -76,6 +79,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getPlateNumber, changes.plateNumber()) .set(VehicleRecord::getPlateNumber, changes.plateNumber())
.set(VehicleRecord::getModel, changes.model()) .set(VehicleRecord::getModel, changes.model())
.set(VehicleRecord::getMaxPalletSpaces, changes.maxPalletSpaces())
.set(VehicleRecord::getMaxLoadKg, changes.maxLoadKg())
.set(VehicleRecord::getMaxVolumeCbm, changes.maxVolumeCbm())
.set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km()) .set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km())
.set(VehicleRecord::getMileageKm, changes.mileageKm()) .set(VehicleRecord::getMileageKm, changes.mileageKm())
.set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany()) .set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany())
@@ -112,7 +118,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(), return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt()); value.getUpdatedBy(), value.getUpdatedAt(), value.getMaxPalletSpaces(), value.getMaxLoadKg(), value.getMaxVolumeCbm());
} }
private static long version(Long value) { private static long version(Long value) {

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