diff --git a/deploy/sql/README.md b/deploy/sql/README.md index d7b10dabe..974734e2d 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -7,7 +7,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-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,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -24,7 +24,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 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 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 2666b8fce..678bca7f8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6021,7 +6021,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at) -- 系统基线 iam_number_sequence:1 行。 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 (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); @@ -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 (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,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)); @@ -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,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_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,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)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 18f0c47a5..c21872057 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -263,7 +263,7 @@ CREATE TABLE ll_delivery_order ( seller_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '卖家姓名', package_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 '总体积(立方米)', planned_delivery_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_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL 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_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' 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 '业务状态', package_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_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 '业务幂等键', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', 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 '出库类型:派送转出库、一件代发', lx_codes 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), UNIQUE KEY uk_ll_outbound_no (outbound_no), 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_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL 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_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' 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) ) 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 ( + 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', plate_number varchar(64) 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 '更新时间', PRIMARY KEY (id), 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='平台车辆主数据'; +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 ( 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', @@ -3020,4 +3251,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) 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; diff --git a/file/AGENTS.md b/file/AGENTS.md index c4a01d206..9b30f0dd8 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `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 校验申请/明细关系、客户归属、权限和业务状态 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `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 按固定十三列供数 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 42add2447..bc8bd6d68 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -7,6 +7,8 @@ import java.util.Arrays; /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ 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_BOL(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) { + 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) { throw new BizException(ResultCode.BAD_REQUEST, this == OCEAN_BOOKING_DOCUMENT ? "订舱附件必须归属平台" : "海外卸柜照片必须归属平台"); @@ -93,7 +101,7 @@ enum BusinessFileCategory { 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 static final class Limits { private static final long STANDARD = 10L * 1024 * 1024; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 764b21e53..f7ac07c88 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -64,6 +64,10 @@ public class BusinessFileStorage { verifyXlsx(file); extension = ".xlsx"; } 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); if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); verifyContent(file, type); diff --git a/file/src/main/resources/openapi/fleet-driver-license-v1.yaml b/file/src/main/resources/openapi/fleet-driver-license-v1.yaml new file mode 100644 index 000000000..2d88200e9 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-driver-license-v1.yaml @@ -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} diff --git a/file/src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml b/file/src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml new file mode 100644 index 000000000..b5ea28f80 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml @@ -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: {} diff --git a/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java b/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java new file mode 100644 index 000000000..5664cf19f --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java @@ -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; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java b/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java new file mode 100644 index 000000000..fb314beeb --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java @@ -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"); + } +} diff --git a/finance/AGENTS.md b/finance/AGENTS.md index fdb7f6c70..36a1a97b9 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -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` | | 业务费用来源事件入口 | 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 跳转客户隔离的入仓关系详情 | +| 尾程引用原头程费用 | `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` | | 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` | diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageController.java new file mode 100644 index 000000000..2cc93aee6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageController.java @@ -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> 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)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/DeliveryFeeCoverageModels.java b/finance/src/main/java/com/baiyue/finance/dto/DeliveryFeeCoverageModels.java new file mode 100644 index 000000000..323d8fef4 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/DeliveryFeeCoverageModels.java @@ -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) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java b/finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java new file mode 100644 index 000000000..e94da1f4d --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java @@ -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 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"))); + } +} diff --git a/finance/src/main/resources/openapi/finance-delivery-fee-coverage-v1.yaml b/finance/src/main/resources/openapi/finance-delivery-fee-coverage-v1.yaml new file mode 100644 index 000000000..c49328939 --- /dev/null +++ b/finance/src/main/resources/openapi/finance-delivery-fee-coverage-v1.yaml @@ -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] } diff --git a/finance/src/test/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageControllerTest.java new file mode 100644 index 000000000..07916f586 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageControllerTest.java @@ -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); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryServiceTest.java new file mode 100644 index 000000000..c7b6d0081 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryServiceTest.java @@ -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)); + } +} diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 90a8c1ceb..01fd80ecd 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -74,11 +74,33 @@ VALUES (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), (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), (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), (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), + (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), (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), diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index e9c243f8d..19af389d5 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -32,7 +32,16 @@ class OverseasPhase4PermissionSqlContractTest { "platform:overseas-business:looseboard:list", "platform:overseas-master-data:quote: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 void canonicalCatalogDisablesLegacyCodesAndExcludesFrozenDomains() throws Exception { @@ -42,7 +51,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(59).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(68).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains( "(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()", diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 258d382be..c9bea4623 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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`。 -本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 +本表只保留定位入口;先沿目标代码的实际引用追踪。状态、权限、资源归属或接口边界受影响且本文件不足以判断时,再用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段;纯文案、局部重构或已知文件定位不自动增加详细索引。不得全文加载无关业务约束。 退货预报、签收、库存、合箱与退货出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程来源托盘快照:`inboundplan/`。这两类入口的补充段落也在详细索引中,按对应短词查询。 @@ -60,6 +60,14 @@ | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` | | 海外仓报价、月度汇率、车辆主数据 | `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` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | | 测试 | `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 完成。 - 平台一件代发出库候选由 `/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 不得自行推导该门禁。 +- 共享车次关联取排车单自身客户与起运仓;排车有效关联唯一,多客户不合并库存归属。`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 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 - 仓库 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_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | | 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、按托盘/入仓单拆分的仓位、流水、命令幂等及实收快照;同一托盘可有多条入仓单仓位,汇总数量必须与各明细核平 | diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 3025abff5..3be67471e 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -2,6 +2,24 @@ ## 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 测试依赖。 可重复命令: @@ -216,3 +234,5 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - - `dispatch-delivery -> dropship` 是一件代发出库的稳定业务依赖:出库应用服务按客户范围读取一件代发托盘、箱唛和 SKU 库存,并在 PDA 出库事务中完成扣减。 - `tailleg-handoff -> dispatch-delivery` 是异常退回与客户回访的稳定业务依赖:异常单和回访应用服务按派送业务号读取派送单及操作记录,不引入反向兼容入口或跨服务共享模型。 - 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。 + +2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index a2488d164..cd4f87a92 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { pathToFileURL } from 'node:url'; const moduleRoot = path.resolve(import.meta.dirname, '..'); const sourceRoots = { @@ -12,6 +13,7 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ + ['fleet', /^com\.baiyue\.lastleg\.fleet(?:\.|$)/], ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], @@ -269,13 +271,34 @@ function renderDot(report, graph) { return lines.join('\n'); } -function checkContract(report, contractPath) { - const absolute = path.resolve(process.cwd(), contractPath); - const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); +export function contractViolations(report, contract) { const allowed = new Set(contract.allowedSliceEdges ?? []); - const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); - const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`); + const reviewed = new Set(); + 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 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 ?? {})) { const actualClass = classesByName.get(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 ?? {})) { - const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); - const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); + // A reviewed class reference does not authorize any other consumer of that slice. + 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())) { violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); } @@ -309,19 +335,25 @@ function checkContract(report, contractPath) { violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); } } - if (violations.length > 0) { - process.stderr.write(`Lastleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); - process.exitCode = 1; - } + return violations; } -const options = parseArgs(process.argv.slice(2)); -const report = buildReport(options.sourceSet); -if (options.check) checkContract(report, options.check); -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); +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const options = parseArgs(process.argv.slice(2)); + const report = buildReport(options.sourceSet); + if (options.check) { + const contract = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), options.check), 'utf8')); + const violations = contractViolations(report, contract); + if (violations.length) { + process.stderr.write(`Lastleg dependency contract violations in ${options.check}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); + process.exitCode = 1; + } + } + 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); +} diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index 28469687c..5d07260b4 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -1,13 +1,41 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; +import { contractViolations } from './analyze-lastleg-dependencies.mjs'; const moduleRoot = path.resolve(import.meta.dirname, '..'); const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs'); const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); 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') { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { 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.application.VehicleApplicationService', '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', ]); 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', target: 'com.baiyue.lastleg.security.OverseasPermissions', }]); - assert.deepEqual(incoming, []); - assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false); + assert.ok(incoming.length > 0); + 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.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 prefix = `${vehiclePrefix}.application`; 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) { assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), 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, []); }); -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 projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection'); - const projectionNames = new Set(projectionClasses.map(entry => entry.fqcn)); - assert.deepEqual(projectionClasses.map(entry => entry.fqcn).sort(), [ - '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(projectionClasses, []); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), []); assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []); - assert.equal(report.sliceSccs.some(component => component.includes('security-projection')), false); - const businessConsumers = report.classDependencies.filter(edge => projectionNames.has(edge.target) - && !projectionNames.has(edge.source)); - assert.deepEqual(businessConsumers, []); + assert.match(readFileSync(path.join(moduleRoot, 'pom.xml'), 'utf8'), /baiyue-common-security<\/artifactId>/); +}); + +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:'))); }); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java new file mode 100644 index 000000000..ac268f487 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java @@ -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> sources(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Query query); + record Query(Long customerId, List 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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 7397ca9b0..b2410ec24 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -23,6 +23,14 @@ import org.springframework.web.server.ResponseStatusException; public class DeliveryTransferController { @Resource private DeliveryTransferService service; + @GetMapping("/dispatches/{dispatchNo}/external-freight") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") + public ApiResponse externalFreight( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { + requirePlatform(user); + return ApiResponse.success(service.externalFreight(dispatchNo)); + } + @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") @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, @Valid @RequestBody DeliveryRequests.Cancel request) { requirePlatform(user); - service.cancelOutbound(outboundNo, request); + service.cancelOutbound(outboundNo, operator(user), request); return ApiResponse.success(null); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java new file mode 100644 index 000000000..b18bd481e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java @@ -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 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)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java new file mode 100644 index 000000000..805b90c08 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java @@ -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, List 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 services) { } + public record Evidence(String cargoHash, List cargo, List references, String operatorId, Instant confirmedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index c1b595225..4c1c7725b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import jakarta.validation.Valid; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection; import java.util.List; import java.util.Collections; import java.math.BigDecimal; @@ -30,7 +31,10 @@ public final class DeliveryRequests { @Size(max = 128) String appointmentAccount, @Size(max = 128) String sellerName, @Size(max = 1000) String remark, - @NotNull @Valid @Size(min = 1, max = 20) List fees) {} + @NotNull @Valid @Size(max = 20) List 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, @NotBlank @Size(max = 128) String feeName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index e81a09044..bed422a5f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -42,13 +42,14 @@ public final class DeliveryResponses { BigDecimal weightKg, BigDecimal volumeCbm) {} 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, String operatorName, String result) {} public record DeliveryDetail(DeliveryRow order, List skus, - List fees, List operations) {} + List fees, List operations, + DeliveryFeeCoverageModels.Evidence feeCoverage) {} /** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */ public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java index e598fdf53..28c2e9d3f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -30,6 +30,7 @@ public class OutboundOrder { private BigDecimal volumeCbm; private String idempotencyKey; private String requestHash; + private String feeCoverageJson; private String createdBy; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java index 1b34e5973..cbb6e2f31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java @@ -10,6 +10,8 @@ import java.util.List; @Mapper public interface DeliveryOperationMapper extends BaseMapper { + @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") List findByDelivery(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java index e3606c3af..9b949b501 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -9,6 +9,8 @@ import java.util.List; @Mapper public interface DeliveryOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id") + List 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") 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java new file mode 100644 index 000000000..0ef81e48c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java @@ -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 list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 600911e7d..d888a6b5f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -27,6 +27,7 @@ import java.util.*; @Service public class DeliveryDocumentService { @Resource private DeliveryOrderMapper orders; + @Resource private OutboundOrderMapper outboundOrders; @Resource private DeliveryDocumentRevisionMapper revisions; @Resource private DeliveryOperationMapper operations; @Resource private DeliveryDocumentFileGateway files; @@ -84,16 +85,19 @@ public class DeliveryDocumentService { return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values); }).toList(); 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.setDocumentsJson(write(categories)); order.setVersion(version(order) + 1); order.setUpdatedAt(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("当前派送状态不允许通过签收文件完成"); - } order.setStatus("DELIVERED"); order.setSignedAt(now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentSnapshot.java similarity index 92% rename from lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentSnapshot.java index 55316ee5d..31f745e07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentSnapshot.java @@ -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.model.DeliveryDocumentCategory; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java new file mode 100644 index 000000000..6a512a4af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java @@ -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(); + 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 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 load(String customerId, List 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 selected, List 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 includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set externalItems) { + public static Decision none() { return new Decision(Set.of(), null, Set.of()); } + public List 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 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(); + var scopes = new HashSet(); + 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(); + 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 references, Set 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, "原费用来源暂不可用,请稍后重试"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 0253b723d..4b5360343 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -1,5 +1,9 @@ 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.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -123,6 +127,25 @@ public class DeliveryTransferService { @Resource private RelabelApplicationService relabelService; @Resource private LastlegOrderNoGenerator orderNoGenerator; @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 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 public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, @@ -135,6 +158,8 @@ public class DeliveryTransferService { @Transactional public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId, DeliveryRequests.Transfer request) { + if (request.feeCoverage() != null && !request.feeCoverage().isEmpty()) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认"); DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo); if (found == null) throw notFound("Dispatch order not found"); DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId()); @@ -145,16 +170,20 @@ public class DeliveryTransferService { private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId, DeliveryRequests.Transfer request) { String customerId = dispatch.getCustomerId(); + String requestHash = transferRequestHash(request); OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (replay != null) { 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()); - 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())) { 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()); List items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId()); 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"); } 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(); OutboundOrder outbound = new OutboundOrder(); @@ -173,10 +211,15 @@ public class DeliveryTransferService { outbound.setLxCodes(resolveLxCodes(customerId, items)); outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); outbound.setPalletCount(items.size()); - outbound.setGrossWeightKg(sum(items, DispatchItem::getGrossWeightKg)); + outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg)); outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg)); outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm)); 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); try { outboundOrders.insert(outbound); @@ -185,11 +228,11 @@ public class DeliveryTransferService { // the document created by the winning transaction rather than receive a false failure. OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); 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()); if (concurrent != null) { - return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true); + return replayTransfer(concurrent, requestHash); } 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.setPalletCount(groupItems.size()); 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.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now); bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); 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); - LastlegOrder firstOrder = firstTailOrder(customerId, items); List 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) { - feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()), - new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(), - "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(), + String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT); + var uncovered = coverage.uncovered(feeCode); + 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.getInboundCode(), null, firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()), - firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()), - input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(), + firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()), + feeCode, input.feeName().trim(), input.direction(), input.supplierId(), input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(), input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(), input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(), @@ -277,27 +352,45 @@ public class DeliveryTransferService { 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 covered) { List inputs = request.fees() == null ? List.of() : request.fees(); - if (inputs.isEmpty()) throw conflict("Delivery fee is required"); Set allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"); Set seen = new HashSet<>(); for (DeliveryRequests.FeeItem input : inputs) { String feeType = input.feeType().trim().toUpperCase(Locale.ROOT); 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))) 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 (!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)) - && !seen.contains("LOADING_FEE")) { + && !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) { throw conflict("Loading fee is required for floor or mixed loading"); } if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) { 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 - 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)); if (outbound == null) throw notFound("Outbound order not found"); 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())) { 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"); LocalDateTime now = LocalDateTime.now(); 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.setUpdatedAt(now); deliveryOrders.updateById(delivery); } @@ -1019,9 +1139,26 @@ public class DeliveryTransferService { return; } if (!existingDeliveries.isEmpty()) { - for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { - String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); - 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); + var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList(); + for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId) + .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() + .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() + .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 skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList(); return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus, 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 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 items, String outboundNo, String deliveryNo, @@ -1286,9 +1438,9 @@ public class DeliveryTransferService { allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3), 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.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 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 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 getter) { return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } + private static BigDecimal sumKnownPhysicalWeight(List items, + java.util.function.Function 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, long selectedPackages, int scale) { if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java new file mode 100644 index 000000000..ad328d63c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -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().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().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 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(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.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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index aa160bdfe..1f20c09b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -9,6 +9,11 @@ import java.util.List; @Mapper public interface DispatchCandidateMapper { + // Billing volume weight is never a substitute for physical cargo mass. + String SOURCE_GROSS_WEIGHT = + "COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>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("") + List findByDispatchNos(@Param("numbers") List 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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java new file mode 100644 index 000000000..0dacd3636 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java new file mode 100644 index 000000000..a6ae434a2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -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 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 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 gates(String warehouseId, List numbers) { + if (numbers.isEmpty()) return Map.of(); + var result = new HashMap(); + numbers.forEach(number -> result.put(number, missingGate())); + var byTrip = new HashMap(); + 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(); + 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 manifests(FleetTripEntity trip, boolean lock) { + return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList(); + } + private static List cargo(List 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 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 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 capacity(FleetTripEntity trip, List cargo, + List pallets) { + List stops; + try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); } + catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); } + var destinations = new HashMap(); var palletNumbers = new HashMap(); + 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(); var bookings = new ArrayList(); + 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 read(String value, Class 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java new file mode 100644 index 000000000..05f0085d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java new file mode 100644 index 000000000..0e6603e89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java @@ -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 { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java new file mode 100644 index 000000000..430dcc49e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java new file mode 100644 index 000000000..0d59736d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java @@ -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 stops, String remark, long recordVersion, Instant updatedAt, + FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { } + public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List dispatches, ReviewState loadingReview) { } + public record AuditSnapshot(Response trip, List dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad, + FleetCompletionModels.Completed completion, ReviewState loadingReview, + FleetExecutionModels.PlannedLoad capacityPlan, List externalBookings) { } + public record History(long recordVersion, String action, String reason, String operatorId, Instant occurredAt, AuditSnapshot snapshot) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java new file mode 100644 index 000000000..03179d7c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java @@ -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()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java new file mode 100644 index 000000000..736accc84 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -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 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() + .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 history(String tripNo, int page, int size) { + var trip = required(tripNo); + var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper() + .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 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 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 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() + .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().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 read(String value, Class 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java new file mode 100644 index 000000000..cb015a615 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java @@ -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() + " 中使用,请先在车次中改派或取消安排"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java new file mode 100644 index 000000000..239d31065 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java @@ -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> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));} + @GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));} + @PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')") + public ApiResponse 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 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 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 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 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 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 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 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();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java new file mode 100644 index 000000000..18259b501 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -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> page(@Valid FleetTripModels.Query query) { return ApiResponse.success(service.page(query)); } + @GetMapping("/{tripNo}") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:create')") + public ApiResponse 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 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 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 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 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 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> 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 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 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 loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); } + @GetMapping("/{tripNo}/capacity-plan") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse capacityPlan(@PathVariable String tripNo) { return ApiResponse.success(execution.capacityPlan(tripNo)); } + @PostMapping("/{tripNo}/capacity-plan") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse 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 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 completion(@PathVariable String tripNo) { return ApiResponse.success(completion.inspection(tripNo)); } + @PostMapping("/{tripNo}/complete") + @PreAuthorize("hasAuthority('platform:fleet:trip:complete')") + public ApiResponse 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 release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetCompletionModels.Release request) { + return ApiResponse.success(completion.release(tripNo, operator(user), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java index cb5252cf7..c889adaf6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java @@ -48,6 +48,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository { record.setId(IdWorker.getId()); record.setPlateNumber(vehicle.plateNumber()); record.setModel(vehicle.model()); + record.setMaxPalletSpaces(vehicle.maxPalletSpaces()); + record.setMaxLoadKg(vehicle.maxLoadKg()); + record.setMaxVolumeCbm(vehicle.maxVolumeCbm()); record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km()); record.setMileageKm(vehicle.mileageKm()); record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany()); @@ -76,6 +79,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository { .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) .set(VehicleRecord::getPlateNumber, changes.plateNumber()) .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::getMileageKm, changes.mileageKm()) .set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany()) @@ -112,7 +118,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository { return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(), value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), 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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java index 9518e94e4..9ae5a923b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java @@ -14,6 +14,9 @@ public class VehicleRecord { @TableId(type = IdType.INPUT) private Long id; private String plateNumber; private String model; + private Integer maxPalletSpaces; + private BigDecimal maxLoadKg; + private BigDecimal maxVolumeCbm; private BigDecimal fuelConsumptionPer100Km; private Long mileageKm; private String driverOrDispatchCompany; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java index 79fbb24d1..d350c34e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.masterdata.vehicle.application; import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria; @@ -24,9 +25,11 @@ public class VehicleApplicationService { private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); private final VehicleRepository vehicles; + private final VehicleAssignmentGuard assignments; - public VehicleApplicationService(VehicleRepository vehicles) { + public VehicleApplicationService(VehicleRepository vehicles, VehicleAssignmentGuard assignments) { this.vehicles = vehicles; + this.assignments = assignments; } public PageResult page(VehicleModels.Query query) { @@ -41,6 +44,7 @@ public class VehicleApplicationService { @Transactional public VehicleModels.Response create(String operator, VehicleModels.Create request) { + validateCapacity(request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm()); LocalDateTime now = LocalDateTime.now(); long id; try { @@ -48,7 +52,8 @@ public class VehicleApplicationService { requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), - ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now)); + ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now, + request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm())); } catch (DuplicatePlateException exception) { throw conflict("车牌号已存在"); } @@ -57,16 +62,25 @@ public class VehicleApplicationService { @Transactional public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) { + validateCapacity(request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm()); VehicleSnapshot current = locked(id); requireVersion(current, request.recordVersion()); if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码"); + if (!current.plateNumber().equals(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)) + || !current.model().equals(requiredText(request.model(), "车型")) || !current.region().equals(region(request.region())) + || !current.ownership().equals(ownership(request.ownership())) + || !java.util.Objects.equals(current.maxPalletSpaces(), request.maxPalletSpaces()) + || !sameDecimal(current.maxLoadKg(), request.maxLoadKg()) || !sameDecimal(current.maxVolumeCbm(), request.maxVolumeCbm())) { + assignments.requireUnassigned(id); + } boolean updated; try { updated = vehicles.update(id, request.recordVersion(), new VehicleChanges( requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), - ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now())); + ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now(), + request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm())); } catch (DuplicatePlateException exception) { throw conflict("车牌号已存在"); } @@ -80,6 +94,7 @@ public class VehicleApplicationService { requireVersion(current, request.recordVersion()); String status = status(request.status()); if (status.equals(current.status())) return response(current); + assignments.requireUnassigned(id); boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now()); if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); return detail(id); @@ -87,11 +102,12 @@ public class VehicleApplicationService { public byte[] exportCsv(VehicleModels.Query query) { var rows = vehicles.list(criteria(query)).stream().map(this::response).toList(); - StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); - for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') + StringBuilder csv = new StringBuilder("\uFEFF车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n"); + for (var row : rows) csv.append(cell(row.plateNumber())).append(',') .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') + .append(cell(row.maxPalletSpaces())).append(',').append(cell(row.maxLoadKg())).append(',').append(cell(row.maxVolumeCbm())).append(',') .append(row.updatedAt()).append("\r\n"); return csv.toString().getBytes(StandardCharsets.UTF_8); } @@ -115,7 +131,17 @@ public class VehicleApplicationService { return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(), value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(), value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(), - value.updatedBy(), value.updatedAt()); + value.updatedBy(), value.updatedAt(), value.maxPalletSpaces(), value.maxLoadKg(), value.maxVolumeCbm()); + } + + private static void validateCapacity(Integer pallets, java.math.BigDecimal weight, java.math.BigDecimal volume) { + if (pallets == null && weight == null && volume == null) return; + if (pallets == null || weight == null || volume == null || pallets < 1 || pallets > 10000 + || weight.signum() <= 0 || volume.signum() <= 0) throw badRequest("车辆托位、载重和容积须同时填写且大于零,或全部留空待确认"); + } + + private static boolean sameDecimal(java.math.BigDecimal first, java.math.BigDecimal second) { + return first == null ? second == null : second != null && first.compareTo(second) == 0; } private void requireVersion(VehicleSnapshot value, long expected) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java index 36a307164..c00dcb711 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java @@ -26,7 +26,10 @@ public final class VehicleModels { @Size(max = 128) String driverOrDispatchCompany, @Size(max = 64) String phone, @NotBlank String region, @NotBlank String ownership, - @NotBlank String status, @Size(max = 1000) String remark) { } + @NotBlank String status, @Size(max = 1000) String remark, + @Min(1) @Max(10000) Integer maxPalletSpaces, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxLoadKg, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxVolumeCbm) { } public record Update(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 64) String plateNumber, @@ -36,12 +39,16 @@ public final class VehicleModels { @Size(max = 128) String driverOrDispatchCompany, @Size(max = 64) String phone, @NotBlank String region, @NotBlank String ownership, - @Size(max = 1000) String remark) { } + @Size(max = 1000) String remark, + @Min(1) @Max(10000) Integer maxPalletSpaces, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxLoadKg, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxVolumeCbm) { } public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { } public record Response(String id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, String remark, long recordVersion, - String updatedBy, LocalDateTime updatedAt) { } + String updatedBy, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java new file mode 100644 index 000000000..950631ab0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.masterdata.vehicle.application.port.out; + +/** Called while the vehicle row is locked; fleet owns the active assignment. */ +public interface VehicleAssignmentGuard { + void requireUnassigned(long vehicleId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java index 31f0b89a9..0f4f9147b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java @@ -26,16 +26,19 @@ public interface VehicleRepository { record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, - String remark, String operator, LocalDateTime createdAt) { } + String remark, String operator, LocalDateTime createdAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String remark, - String operator, LocalDateTime updatedAt) { } + String operator, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, String remark, long recordVersion, - String updatedBy, LocalDateTime updatedAt) { } + String updatedBy, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } final class DuplicatePlateException extends RuntimeException { public DuplicatePlateException(Throwable cause) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 609d90308..7572a8948 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -94,6 +94,15 @@ public final class OverseasPermissions { public static final String VEHICLE_EDIT = "platform:overseas-master-data:vehicle:edit"; public static final String VEHICLE_STATUS = "platform:overseas-master-data:vehicle:status"; public static final String VEHICLE_EXPORT = "platform:overseas-master-data:vehicle:export"; + public static final String VEHICLE_RECORD_CREATE = "platform:overseas-master-data:vehicle:record-create"; + public static final String VEHICLE_RECORD_EDIT = "platform:overseas-master-data:vehicle:record-edit"; + public static final String VEHICLE_RECORD_UPLOAD = "platform:overseas-master-data:vehicle:record-upload"; + public static final String DRIVER_LIST = "platform:overseas-master-data:driver:list"; + public static final String DRIVER_CREATE = "platform:overseas-master-data:driver:create"; + public static final String DRIVER_EDIT = "platform:overseas-master-data:driver:edit"; + public static final String DRIVER_STATUS = "platform:overseas-master-data:driver:status"; + public static final String DRIVER_REVIEW = "platform:overseas-master-data:driver:review"; + public static final String DRIVER_UPLOAD = "platform:overseas-master-data:driver:upload"; private OverseasPermissions() { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java new file mode 100644 index 000000000..bca2ebf79 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.transfer.fleet; + +import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +@Component +public class FleetIncomingAdapter implements FleetBookingIncomingGateway { + private final WarehouseTransferService service; + private final WarehouseTransferOrderMapper orders; + private final ObjectMapper json; + public FleetIncomingAdapter(WarehouseTransferService service,WarehouseTransferOrderMapper orders,ObjectMapper json){this.service=service;this.orders=orders;this.json=json;} + @Override @Transactional(propagation=Propagation.MANDATORY) + public Evidence create(String operator,Incoming input){ + boolean fba=input.cargo().destinationKey().startsWith("FBA:"); + String code=fba?input.cargo().destinationKey().substring(4):null; + String key=FleetDispatchGateway.destinationKey(fba?"FBA":"PRIVATE",code,code,input.privateAddress(),input.postalCode()); + if(!Objects.equals(key,input.cargo().destinationKey()))throw conflict("收货目的地与已确认外接卸货站不一致"); + var request=new WarehouseTransferRequests.Create(null,List.of(),input.warehouseId(),code, + fba?"FBA":"PRIVATE",null,code,code,input.privateAddress(),input.postalCode(),input.recipientName(),input.recipientPhone(), + input.deliveryInstructions()==null||input.deliveryInstructions().isBlank()?"客户自送至"+input.warehouseName():input.deliveryInstructions(), + input.cargo().contactName(),input.cargo().contactPhone(),input.cargo().packageCount(),input.cargo().volumeCbm(),"CUSTOMER_DELIVERY",input.estimatedArrivalAt(),null, + null,null,null,"外接预订 "+input.bookingNo(),false,null,null,null,null,null,"FLEET:"+input.bookingNo()+":INCOMING", + List.of(new WarehouseTransferRequests.Item(null,input.cargo().cargoName(),input.cargo().packageCount(),null)),null); + var created=service.createAndSubmit(input.customerId(),operator,request); + var approved=service.review(input.customerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","外接承运已确认,按实际来货收货")); + return evidence(approved); + } + @Override @Transactional(propagation=Propagation.MANDATORY) + public Evidence inspect(String customerId,String incomingNo,boolean lock){ + var row=lock?orders.lockNo(customerId,incomingNo):orders.findNo(customerId,incomingNo); + if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前客户无此外接来货单"); + return evidence(service.detail(customerId,incomingNo)); + } + @Override @Transactional(propagation=Propagation.MANDATORY) + public Evidence confirm(String customerId,String incomingNo,String operator,String key){ + var row=orders.lockNo(customerId,incomingNo); + if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"外接来货单不存在"); + if("READY_FOR_DISPATCH".equals(row.getStatus()))return evidence(service.detail(customerId,incomingNo)); + return evidence(service.confirmMeasurement(customerId,incomingNo,operator,new WarehouseTransferRequests.CustomerConfirm(key,"运营代委托方确认外接实测货量"))); + } + private Evidence evidence(WarehouseTransferResponses.Detail value){ + var pallets=value.pallets().stream().filter(row->"COMPLETED".equals(row.status())).map(row->new Pallet( + row.warehousePalletId()==null?null:row.warehousePalletId().toString(),row.palletNo(),row.cartonCount(),row.grossWeightKg(),row.volumeCbm())) + .sorted(Comparator.comparing(row->Objects.requireNonNullElse(row.warehousePalletId(),""))).toList(); + String destination=FleetDispatchGateway.destinationKey(value.destinationType(),value.destinationWarehouseCode(),value.fbaWarehouseCode(),value.privateDestinationAddress(),value.privateDestinationPostalCode()); + int packages=pallets.stream().mapToInt(Pallet::packages).sum(); + try{ + String hash=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(Arrays.asList(value.transferNo(),value.inboundCode(),value.operatingWarehouseId(),destination,pallets)).getBytes(StandardCharsets.UTF_8))); + return new Evidence(value.transferNo(),value.inboundCode(),value.operatingWarehouseId(),destination,value.status(),hash,pallets,packages); + }catch(Exception error){throw new IllegalStateException(error);} + } + private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index b4174ee1b..4035b113a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -953,7 +953,7 @@ public class WarehouseTransferService { String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transfer.getTransferNo() + ":" + pallet.getSequenceNo() + ":" + item.getId(); InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, - new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, + new InventoryCommandRequests.StockIn(customerId, "—", transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), transfer.getInboundOrderId(), transfer.getTransferNo(), transfer.getTransferNo(), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index b3dafc099..705de1fd8 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -299,7 +299,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( seller_name VARCHAR(128) NULL COMMENT '卖家姓名', package_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 COMMENT '总重量(千克)', + total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空', total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', dispatched_at DATETIME(3) NULL COMMENT '实际派送时间', @@ -968,7 +968,65 @@ CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) ) ENGINE=InnoDB 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 IF NOT EXISTS 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', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', @@ -979,9 +1037,181 @@ CREATE TABLE IF NOT EXISTS ll_vehicle ( created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY (id), 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 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='运输车次操作历史'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at')); PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at')); @@ -1389,7 +1619,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', - gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),未知为NULL,不以材积重代替', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', @@ -1510,13 +1740,14 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', package_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 COMMENT '毛重(千克)', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', - request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', + request_hash CHAR(64) NULL COMMENT '创建或转派送业务请求摘要,用于幂等内容校验', + fee_coverage_json JSON NULL COMMENT '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), KEY idx_ll_outbound_list (customer_id,status,created_at) @@ -1526,7 +1757,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', - package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml index ad82f7431..fa84f738a 100644 --- a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -5,7 +5,7 @@ info: description: | 平台资料入口的customerId必须为真实派送所属客户;客户只读入口范围只取Token,只公开当前派送单已保存的RECEIPT签收文件,不提供上传、修改、历史版本、BOL或装车照片下载。 GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 - RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。 + RECEIPT 签收文件只有在关联出库单已OUTBOUNDED、PUT保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。发车前可保存BOL及装车资料,不能通过提前保存签收文件绕过实际出库。allowedActions 不再返回人工 COMPLETE_DELIVERY。 START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 security: [{ bearerAuth: [] }] diff --git a/lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml new file mode 100644 index 000000000..fc5cead6e --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml @@ -0,0 +1,310 @@ +openapi: 3.0.3 +info: + title: 平台车辆管理司机档案 + version: 1.0.0 + description: >- + 平台司机主数据,列表权限包含详情、资格预览、历史和已关联证照读取。 + 写操作独立鉴权且要求当前recordVersion;创建版本0,后续每次成功写入递增。 + 证照或身份关键字段变化、替换附件后重新待审;电话和备注更正不撤销既有审核。 + 驾驶证是当前资格最低要求,其他资质不隐式必填。日期按licenseZone计算, + 30天内到期但仍有效允许并提醒,任务结束达到有效截止日次日当地零点或更晚则禁止。 + 缺少预计结束时间时资格返回MISSING_TRIP_END;资格预览不预留司机,不代替发车事务复核。 + 新档案允许缺证照保存为PENDING,通过审核必须补齐编号、类型、有效期和真实附件。 + 所有业务错误遵循HTTP200加code,认证或基础设施错误可返回401/403/503。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/drivers: + get: + summary: 分页查询司机及资格 + description: '权限:platform:overseas-master-data:driver:list;无逐行文件请求。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + - {name: active, in: query, schema: {type: boolean}} + - {name: reviewStatus, in: query, schema: {type: string, enum: [PENDING, APPROVED, REJECTED]}} + - {name: ownership, in: query, schema: {type: string, enum: [OWNED, DISPATCHED]}} + - {name: region, in: query, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}} + - {name: licenseState, in: query, schema: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]}, description: 仅驾驶证日期状态,与审核和附件状态分别展示} + - {name: expectedEnd, in: query, schema: {type: string, format: date-time}} + responses: + '200': + description: 业务响应,分页数据 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Driver'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + summary: {$ref: '#/components/schemas/Summary'} + post: + summary: 创建待审司机档案 + description: '权限:platform:overseas-master-data:driver:create' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Draft'}}}} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/warehouse-options: + get: + summary: 获取当前区域可关联的公共仓库 + description: '权限:platform:overseas-master-data:driver:list;复用Warehouse内部有效目录,国内DOMESTIC,海外OVERSEAS/OVERSEAS_PARTNER,排除FBA目的仓;保存时再次校验。' + parameters: + - {name: region, in: query, required: true, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}} + responses: + '200': + description: 业务响应,data为仓库候选 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {type: array, items: {$ref: '#/components/schemas/WarehouseOption'}} + /api/platform/v1/last-leg/fleet/drivers/{id}: + parameters: [{$ref: '#/components/parameters/Id'}] + get: + summary: 司机详情 + description: '权限:platform:overseas-master-data:driver:list' + parameters: + - {name: expectedEnd, in: query, schema: {type: string, format: date-time}} + responses: {'200': {$ref: '#/components/responses/Driver'}} + put: + summary: 编辑档案,关键字段变化后重新待审 + description: '权限:platform:overseas-master-data:driver:edit' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, details] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + details: {$ref: '#/components/schemas/Draft'} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/status: + parameters: [{$ref: '#/components/parameters/Id'}] + patch: + summary: 启停司机并记录原因 + description: '权限:platform:overseas-master-data:driver:status;停用后不可派车,历史保留。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, active, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + active: {type: boolean} + reason: {type: string, minLength: 1, maxLength: 1000} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/review: + parameters: [{$ref: '#/components/parameters/Id'}] + post: + summary: 通过或打回司机证照 + description: '权限:platform:overseas-master-data:driver:review;仅PENDING允许审核;打回原因必填;通过前校验真实附件归属与证照有效性。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, decision] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + decision: {type: string, enum: [APPROVED, REJECTED]} + reason: {type: string, maxLength: 1000} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/submit: + parameters: [{$ref: '#/components/parameters/Id'}] + post: + summary: 已打回档案重新提交 + description: '权限:platform:overseas-master-data:driver:edit;仅REJECTED;先校验证照完整及文件归属。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/qualification: + parameters: [{$ref: '#/components/parameters/Id'}] + get: + summary: 按预计任务结束时间预览派车资格 + description: '权限:platform:overseas-master-data:driver:list;预计结束应覆盖本司机承担的返程或转场。' + parameters: + - {name: expectedEnd, in: query, required: true, schema: {type: string, format: date-time}} + responses: + '200': + description: 资格结果,canDispatch与expiringSoon独立 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/Qualification'} + /api/platform/v1/last-leg/fleet/drivers/{id}/history: + parameters: [{$ref: '#/components/parameters/Id'}] + get: + summary: 分页读取司机操作与审核历史 + description: '权限:platform:overseas-master-data:driver:list;按版本降序,snapshot是历史当时的档案及资格,不能用作当前派车门禁。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: 业务响应,data为含records/page/size/total/pages的分页 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/History'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + /api/platform/v1/last-leg/fleet/drivers/{id}/license: + parameters: [{$ref: '#/components/parameters/Id'}] + post: + summary: 上传并绑定驾驶证,重置为待审 + description: '权限:platform:overseas-master-data:driver:upload;复合归属固定平台0、FLEET_DRIVER_LICENSE、当前司机;非空PDF/JPG/PNG不超过10MiB,校验MIME和签名,旧版本附件保留在历史中。' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [recordVersion, file] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + file: {type: string, format: binary} + responses: {'200': {$ref: '#/components/responses/Driver'}} + /api/platform/v1/last-leg/fleet/drivers/{id}/license/{fileId}: + parameters: + - {$ref: '#/components/parameters/Id'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + get: + summary: 下载当前或历史已关联驾驶证 + description: '权限:platform:overseas-master-data:driver:list;校验关联和File复合归属,attachment下载,Cache-Control no-store,拒绝未绑定文件。' + responses: + '200': + description: 文件内容;业务错误仍需识别JSON响应 + content: + application/pdf: {schema: {type: string, format: binary}} + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + Driver: + description: 业务响应,data为司机详情 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/Driver'} + schemas: + Envelope: + type: object + properties: + code: {type: string} + message: {type: string, nullable: true} + data: {type: object, nullable: true} + Draft: + type: object + required: [name, phone, ownership, region, licenseZone] + properties: + name: {type: string, minLength: 1, maxLength: 128} + phone: {type: string, minLength: 1, maxLength: 64} + ownership: {type: string, enum: [OWNED, DISPATCHED]} + region: {type: string, enum: [DOMESTIC, OVERSEAS]} + licenseNumber: {type: string, nullable: true, maxLength: 128} + licenseType: {type: string, nullable: true, maxLength: 64} + licenseIssuedOn: {type: string, format: date, nullable: true} + licenseExpiresOn: {type: string, format: date, nullable: true} + licenseZone: {type: string, maxLength: 64, description: IANA时区,如America/Los_Angeles} + remark: {type: string, nullable: true, maxLength: 1000} + profile: {$ref: '#/components/schemas/Profile'} + Profile: + type: object + nullable: true + description: 可选补充资料;当前其他资质日期不自动作为派车必需条件 + properties: + warehouseId: {type: string, nullable: true, pattern: '^[1-9][0-9]*$'} + vehicleModels: {type: string, nullable: true, maxLength: 128} + languagesAndArea: {type: string, nullable: true, maxLength: 256} + medicalExpiresOn: {type: string, format: date, nullable: true} + trainingExpiresOn: {type: string, format: date, nullable: true} + insuranceExpiresOn: {type: string, format: date, nullable: true} + WarehouseOption: + type: object + properties: + id: {type: string} + code: {type: string} + name: {type: string} + region: {type: string, enum: [DOMESTIC, OVERSEAS]} + Summary: + type: object + description: 与列表共用筛选范围,按各证照时区计算30天(含)提醒,不以浏览器时区计算 + properties: + total: {type: integer, format: int64} + owned: {type: integer, format: int64} + dispatched: {type: integer, format: int64} + expiringSoon: {type: integer, format: int64} + expired: {type: integer, format: int64} + Driver: + type: object + properties: + id: {type: string} + driverNo: {type: string, description: 独立生成的业务编号} + details: {$ref: '#/components/schemas/Draft'} + active: {type: boolean} + reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]} + licenseFileId: {type: string, nullable: true} + recordVersion: {type: integer, format: int64} + updatedBy: {type: string} + updatedAt: {type: string, description: UTC本地日期时间} + qualification: {$ref: '#/components/schemas/Qualification'} + licenseState: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]} + warehouseCode: {type: string, nullable: true} + warehouseName: {type: string, nullable: true} + Qualification: + type: object + properties: + canDispatch: {type: boolean} + expiringSoon: {type: boolean} + reason: + type: string + enum: [ELIGIBLE, INACTIVE, MISSING_LICENSE, MISSING_EXPIRY, PENDING_REVIEW, REJECTED, EXPIRED, MISSING_TRIP_END, INVALID_TRIP_END, EXPIRES_DURING_TRIP] + licenseExpiresOn: {type: string, format: date, nullable: true} + History: + type: object + properties: + recordVersion: {type: integer, format: int64} + action: {type: string, enum: [CREATE, UPDATE, ACTIVATE, DEACTIVATE, UPLOAD_LICENSE, APPROVED, REJECTED, RESUBMIT]} + reason: {type: string, nullable: true} + operatorId: {type: string} + occurredAt: {type: string, description: UTC本地日期时间} + snapshot: {$ref: '#/components/schemas/Driver'} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml new file mode 100644 index 000000000..bb1460d92 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -0,0 +1,649 @@ +openapi: 3.0.3 +info: + title: 平台运输车次与资源分配 + version: 1.0.0 + description: >- + 车次是一次实际运输的稳定业务编号,客户排车、库存和派送单继续保留各自客户归属。 + 本文件定义计划、资源分配及客户排车单关联命令;分配或关联不会扣库存或标记发车。 + 创建可暂不指定车与司机,幂等键按创建人隔离;相同键相同内容返回同一车次,不同内容拒绝。 + 正式分配要求在役海外车辆及已确认的托位/载重/容积,以及海外司机;司机按预计任务结束时间重新核验且验证证照文件。 + 发车预览与实际发车重新核对司机当前海外区域和身份;分配后档案变更不能沿用原分配时的资格结果。 + 一个车辆或司机同时只能占用一个未释放车次;当前不提供多个未来时段的并行预约。 + 改派先取得新资源,成功后释放旧资源;整次写入与历史处于同一事务。 + 司机预计结束包含承担的返程/转场;修改预计结束须重新核验司机,所有写命令使用recordVersion。 + 普通计划接口不提供修改发车或签收状态的入口;PDA统一发车,结案检查所有客户派送终态及实发数量。 + 货物结案不释放车辆或司机;结束各资源任务独立提交实际结束时间、地点和原因,车辆另确认可用/维修/停用/已归还状态。 + 结案和结束任务命令携带版本及幂等键;结束司机任务不要求其证照仍有效,也不自动恢复其派车资格。 + 关联要求已分配车和司机,客户排车单同起运仓且未开始拣货;可跨客户关联但不改变客户数据归属。 + 同一排车单唯一关联一个车次;关联、改派、解除及尚未执行的派送单车/司机快照同步使用同一事务。 + 关联后子排车不能独立编辑、取消或单独发车;取消车次前须解除关联,已开工不允许直接解除或变更路线。 + 发车前允许在车次统一换车、换司机或调整时间,保留全部拣货事实与客户归属;已确认装载须能容纳于替换车辆。 + 改派或调整计划使原装载确认失效,发车前须重新确认;不支持用计划接口处理在途换车或撤销已执行货物。 + 关联只建立执行单据关系,不代表实际装载量、容量核验或审核已完成。 + 每张客户排车单调度审核通过后允许新拣货;整车确认实际装载并通过理货审核后允许发车。 + 审核仅通过或打回,通过意见选填、打回原因必填;无需额外提交或不同审核人。 + 换车换司机、修改路线或时间使调度与理货审核待审;仅修改备注保留审核及装载。 + 关联变更、重新确认装载或重新审核客户排车单使理货审核待审;保留全部拣货事实及历史审核快照。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/trips: + get: + summary: 分页查询车次 + description: '权限:platform:fleet:trip:list' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}, description: 车次号/车牌/司机} + - {name: status, in: query, schema: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]}} + - {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 统一业务响应,data包含records/page/size/total/pages;不逐行请求司机详情 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Trip'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + post: + summary: 幂等创建车次计划 + description: '权限:platform:fleet:trip:create;起运仓使用公共有效海外仓目录;不推断GPS路线。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Create'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/customer-options: + get: + summary: 查询有效委托客户候选 + description: '权限:platform:fleet:trip:list;读取IAM当前客户主档;不使用采购历史或允许输入不存在的客户。' + parameters: [{name: keyword, in: query, schema: {type: string}}] + responses: {'200': {description: 统一响应data为客户id/code/name/contactName/contactPhone数组}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 查询本车外接询价与承运预订 + description: '权限:platform:fleet:trip:list;返回tripRecordVersion及records;约定金额不是应收或实收。' + responses: {'200': {description: 统一响应data为版本及Booking数组}} + post: + summary: 登记外接询价 + description: '权限:platform:fleet:trip:booking-create;DRAFT不占位。客户由IAM校验,报价和预计载荷可待确认再补;起点固定本起运仓,卸货站为既有路线站点。相同创建键与内容重放原预订。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingCreate'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + put: + summary: 编辑尚未确认的外接询价 + description: '权限:platform:fleet:trip:booking-edit;仅DRAFT;客户与预订业务编号不变,更新前核对车次及预订版本。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingUpdate'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/confirm: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + post: + summary: 确认外接承运并占用计划容量 + description: '权限:platform:fleet:trip:booking-confirm;父车次锁内核对来源仍有效的自有货容量计划及全部已确认预订;报价/币种/预计托位重量体积完整。幂等重放返回当前预订事实,不重复占位;确认不代表已收货、已发运或已入财务。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingCommand'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}, '409': {description: 版本、资格或容量不满足}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/incoming: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + get: + summary: 查询外接来货及实际测量依据 + description: '权限:platform:fleet:trip:list;固定预订客户范围,返回tripRecordVersion、recordVersion与incoming;尚未登记时incoming为null,不逐行预取。' + responses: {'200': {description: 统一响应data为版本及IncomingEvidence}} + post: + summary: 为已确认承运生成真实来货单 + description: '权限:platform:fleet:trip:booking-edit;复用仓储转运创建/审核,在同一事务建立客户自送来货并关联预订;不写库存,不冒充实收;已有来货拒绝重复创建,同键同内容重放。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingIncoming'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/receipt-confirmation: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + post: + summary: 运营确认委托方实收货量与运费 + description: '权限:platform:fleet:trip:booking-confirm;实际来货已完成测量,receiptHash和实托集合一致,重新核算容量和约定金额,引用真实收货证据并确认原来货流程;不重复入库。差异必须显式确认,超容量回滚。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingAcceptReceipt'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/dispatch: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + post: + summary: 将外接实收货物关联客户排车 + description: '权限:同时要求platform:fleet:trip:booking-edit和platform:fleet:trip:edit;排车必须同客户同仓,完整包含预订来货的实收托盘与数量且目的地一致;原子绑定共享车次,防止绕过预订挂到其他车次。解除未开工关联后回到RECEIVED并重新预留容量,已拣货不允许直接解除。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingBindDispatch'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/cancel: + parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] + post: + summary: 取消未执行的外接预订并释放容量 + description: '权限:platform:fleet:trip:booking-cancel;仅计划中车次的DRAFT/CONFIRMED/RECEIVED;只释放承运预留,不删除来货或库存;同键同内容重放不重复释放,历史保留报价及承运确认。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingCommand'}}}} + responses: {'200': {$ref: '#/components/responses/BookingResult'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 查询车次详情和当前司机资格 + description: '权限:platform:fleet:trip:list;司机资格仅是详情预览,实际命令必须重新核验。' + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/{dispatchNo}/review: + parameters: + - {$ref: '#/components/parameters/TripNo'} + - {name: dispatchNo, in: path, required: true, schema: {type: string}} + post: + summary: 通过或打回单张客户排车调度审核 + description: '权限:platform:fleet:trip:dispatch-review;仅PLANNED;审核所属车次内的排车单,成功后理货审核待审;审批不扣库存。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Review'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}, '409': {description: 版本、关联或资格冲突}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/loading-review: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 通过或打回整车理货审核 + description: '权限:platform:fleet:trip:loading-review;仅PLANNED;通过要求全部调度审核、拣货和实际装载确认已完成,当前司机资格有效。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Review'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}, '409': {description: 版本、调度审核、装载或资格冲突}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/plan: + parameters: [{$ref: '#/components/parameters/TripNo'}] + put: + summary: 修改计划时间和路线 + description: '权限:platform:fleet:trip:edit;仅PLANNED;司机预计结束变更重新核验;原因必填。已开始拣货时路线须完全一致,仅可调整时间和备注;调整时间使审核及装载确认失效,仅修改备注保留。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Plan'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/assignment: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 分配或更换车辆与司机 + description: '权限:platform:fleet:trip:assign;仅PLANNED且全部关联单未发车、未出库;允许已拣货时统一改派并同步派送快照,保留拣货和到达时间。替换车辆须满足已确认装载容量,成功后清除原装载确认并重新核验;行锁和唯一占用键防重复派车,资格校验不得通过人工审核绕过。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, vehicleId, driverId, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + vehicleId: {type: string, pattern: '^[1-9][0-9]*$'} + driverId: {type: string, pattern: '^[1-9][0-9]*$'} + reason: {type: string, minLength: 1, maxLength: 1000} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/cancel: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 取消计划并释放车和司机 + description: '权限:platform:fleet:trip:cancel;仅PLANNED;保留车次快照和历史。已执行货物后续须按实际执行状态校验,不允许取消抹除作业事实。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + reason: {type: string, minLength: 1, maxLength: 1000} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/bind: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 将客户排车单关联到车次 + description: '权限:platform:fleet:trip:edit;按业务单号解析客户归属,同仓且未开工;关联和车/司机快照同步原子提交。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindingCommand'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/unbind: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 解除尚未执行的客户排车单关联 + description: '权限:platform:fleet:trip:edit;清除排车及派送单当前车辆司机快照,历史保留;已拣货禁止直接解除。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindingCommand'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/history: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 分页读取车次操作历史 + description: '权限:platform:fleet:trip:list;版本倒序;不将历史快照当作当前资格。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: 统一响应;data为PageResult,records项包含recordVersion/action/reason/operatorId/occurredAt/snapshot,snapshot包含trip、带review的dispatches、capacityPlan、confirmedLoad、completion及loadingReview、externalBookings不可变快照 + /api/platform/v1/last-leg/fleet/trips/{tripNo}/capacity-plan: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 读取拣货前计划载荷和逐段余位 + description: '权限:platform:fleet:trip:list;按当前关联货物返回manifestHash;planValid为false表示尚未核对或来源已经变化,不能据旧计划承诺外接运力。' + responses: + '200': {description: 统一响应data为Planning, content: {application/json: {schema: {$ref: '#/components/schemas/PlanningEnvelope'}}}} + post: + summary: 核对本车货物计划载荷 + description: '权限:platform:fleet:trip:edit;仅PLANNED;允许尚未拣货或审核,通过实托去重及逐段容量检查。保存计划不推进审核、库存或发车,不代替实际装载确认;调整计划/资源/关联使容量计划失效,纯备注保留。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmLoad'}}}} + responses: + '200': {description: 已保存容量计划, content: {application/json: {schema: {$ref: '#/components/schemas/PlanningEnvelope'}}}} + '409': {description: 版本、来源变化或容量超限} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/loading: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 读取整车实物装载核对依据 + description: '权限:platform:fleet:trip:list;返回各客户货物组成、manifestHash和当前装载确认,实际重量不得使用计费材积重。' + responses: + '200': {description: 统一响应data为Loading, content: {application/json: {schema: {$ref: '#/components/schemas/LoadingEnvelope'}}}} + post: + summary: 确认整车实际装载及逐段容量 + description: '权限:platform:fleet:trip:edit;全部客户拣货核平且转派送后确认;同一实物托盘只计量一次。货物hash或车次版本变化拒绝,托盘卸货站须匹配原货物目的地。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmLoad'}}}} + responses: + '200': {description: 已确认装载快照,统一响应data为Loading, content: {application/json: {schema: {$ref: '#/components/schemas/LoadingEnvelope'}}}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/completion: + parameters: [{$ref: '#/components/parameters/TripNo'}] + get: + summary: 核对各客户货物是否全部结束履约 + description: '权限:platform:fleet:trip:list;返回tripNo、recordVersion、canComplete、blockReason、outcomes。签收以signedAt为准,退回须有RETURN_COMPLETED操作事实,不以updatedAt代替实际终态时间。' + responses: + '200': {description: 统一响应data包含结案检查结果与逐张派送Outcome, content: {application/json: {schema: {$ref: '#/components/schemas/CompletionEnvelope'}}}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/complete: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 核对全部货物并结案车次 + description: '权限:platform:fleet:trip:complete;仅IN_TRANSIT,实发数量由全部已签收或已真实退回派送单覆盖;拒收、待退回、取消不算终态完成。不改写客户单、不新增收费、不释放资源;同键同内容重放,旧版本或不同内容拒绝。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Complete'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/resources/release: + parameters: [{$ref: '#/components/parameters/TripNo'}] + post: + summary: 单独结束车辆或司机本次任务并释放占用 + description: '权限:platform:fleet:trip:release;货物结案后才允许;实际结束时间不早于货物完成且不晚于当前时间,地点及原因必填。车辆状态与释放同事务更新;司机可继续返程而保持占用。原请求重放不释放后来另一个车次的占用。' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Release'}}}} + responses: {'200': {$ref: '#/components/responses/Detail'}} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + BookingNo: + name: bookingNo + in: path + required: true + schema: {type: string} + TripNo: {name: tripNo, in: path, required: true, schema: {type: string}} + responses: + BookingResult: + description: 统一响应data含tripRecordVersion、booking及replayed;客户端使用当前状态,重放不覆盖后续已发生的取消 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + tripRecordVersion: {type: integer, format: int64} + booking: {$ref: '#/components/schemas/Booking'} + replayed: {type: boolean} + Detail: + description: 统一响应,code为200代表成功,data为车次详情;业务冲突使用业务错误,不降级为成功 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + trip: {$ref: '#/components/schemas/Trip'} + dispatches: {type: array, items: {$ref: '#/components/schemas/DispatchBinding'}} + loadingReview: {$ref: '#/components/schemas/ReviewState'} + driverQualification: + type: object + nullable: true + description: 未分配司机为null;字段与fleet-drivers-v1的Qualification一致 + properties: + canDispatch: {type: boolean} + expiringSoon: {type: boolean} + reason: {type: string} + licenseExpiresOn: {type: string, format: date, nullable: true} + schemas: + BookingCargo: + type: object + required: [destinationKey, packageCount] + properties: + destinationKey: {type: string, maxLength: 160} + cargoName: {type: string, nullable: true, maxLength: 255} + packageCount: {type: integer, minimum: 1} + palletSpaces: {type: integer, nullable: true, minimum: 1} + weightKg: {type: number, nullable: true, minimum: 0.001} + volumeCbm: {type: number, nullable: true, minimum: 0.001} + priceBasis: {type: string, nullable: true, enum: [PALLET, ORDER], description: 为空时默认PALLET} + unitPrice: {type: number, nullable: true, minimum: 0, multipleOf: 0.01} + currency: {type: string, nullable: true, maxLength: 3} + contactName: {type: string, nullable: true, maxLength: 128} + contactPhone: {type: string, nullable: true, maxLength: 128} + remark: {type: string, nullable: true, maxLength: 1000} + IncomingEvidence: + type: object + properties: + incomingNo: {type: string} + inboundCode: {type: string} + warehouseId: {type: string} + destinationKey: {type: string} + status: {type: string} + receiptHash: {type: string, description: 实托、数量、实测值及来源匹配摘要,不包含可变作业状态} + receivedPackages: {type: integer} + pallets: + type: array + items: + type: object + properties: + warehousePalletId: {type: string} + palletNo: {type: string} + packages: {type: integer} + weightKg: {type: number, nullable: true} + volumeCbm: {type: number, nullable: true} + BookingIncoming: + allOf: + - {$ref: '#/components/schemas/BookingCommand'} + - properties: + estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 缺省为计划发车前2小时的预计值} + deliveryInstructions: {type: string, nullable: true, maxLength: 1000} + privateAddress: {type: string, nullable: true, maxLength: 1000} + postalCode: {type: string, nullable: true, maxLength: 32} + recipientName: {type: string, nullable: true, maxLength: 128} + recipientPhone: {type: string, nullable: true, maxLength: 64} + BookingPalletSpaces: + type: object + required: [warehousePalletId, palletSpaces] + properties: + warehousePalletId: {type: string} + palletSpaces: {type: integer, minimum: 1} + BookingAcceptReceipt: + allOf: + - {$ref: '#/components/schemas/BookingCommand'} + - required: [receiptHash, pallets, unitPrice] + properties: + receiptHash: {type: string} + pallets: {type: array, minItems: 1, items: {$ref: '#/components/schemas/BookingPalletSpaces'}} + unitPrice: {type: number, minimum: 0, multipleOf: 0.01} + BookingBindDispatch: + allOf: + - {$ref: '#/components/schemas/BookingCommand'} + - required: [dispatchNo] + properties: + dispatchNo: {type: string, maxLength: 64} + BookingCreate: + type: object + required: [tripRecordVersion, customerId, cargo, idempotencyKey] + properties: + tripRecordVersion: {type: integer, format: int64, minimum: 0} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + cargo: {$ref: '#/components/schemas/BookingCargo'} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + BookingUpdate: + type: object + required: [tripRecordVersion, recordVersion, cargo] + properties: + tripRecordVersion: {type: integer, format: int64, minimum: 0} + recordVersion: {type: integer, format: int64, minimum: 0} + cargo: {$ref: '#/components/schemas/BookingCargo'} + BookingCommand: + type: object + required: [tripRecordVersion, recordVersion, idempotencyKey] + properties: + tripRecordVersion: {type: integer, format: int64, minimum: 0} + recordVersion: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + reason: {type: string, nullable: true, maxLength: 1000} + Booking: + type: object + properties: + bookingNo: {type: string} + tripNo: {type: string} + customerId: {type: string} + customerCode: {type: string} + customerName: {type: string} + status: {type: string, enum: [DRAFT, CONFIRMED, RECEIVED, BOUND, CANCELLED]} + cargo: {$ref: '#/components/schemas/BookingCargo'} + destinationLabel: {type: string} + agreedAmount: {type: number, nullable: true, description: 约定运费,非财务应收或实收} + incomingNo: {type: string, nullable: true} + dispatchNo: {type: string, nullable: true} + receipt: + type: object + nullable: true + properties: + source: {$ref: '#/components/schemas/IncomingEvidence'} + pallets: {type: array, items: {$ref: '#/components/schemas/BookingPalletSpaces'}} + operatorId: {type: string} + confirmedAt: {type: string, format: date-time} + recordVersion: {type: integer, format: int64} + updatedAt: {type: string, format: date-time} + Outcome: + type: object + description: 本车原运输的结案依据;已签收后另发起退货仍保留本次运输DELIVERED及原signedAt,不修改后续退货单据状态。 + properties: + dispatchNo: {type: string} + deliveryNo: {type: string} + customerId: {type: string} + destination: {type: string, nullable: true} + status: {type: string} + packages: {type: integer} + terminalAt: {type: string, format: date-time, nullable: true} + CompletionEnvelope: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + tripNo: {type: string} + recordVersion: {type: integer, format: int64} + canComplete: {type: boolean} + blockReason: {type: string, nullable: true} + outcomes: {type: array, items: {$ref: '#/components/schemas/Outcome'}} + Complete: + type: object + required: [recordVersion, idempotencyKey, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + reason: {type: string, minLength: 1, maxLength: 1000} + Release: + type: object + required: [recordVersion, resourceKind, actualEndAt, endLocation, idempotencyKey, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + resourceKind: {type: string, enum: [VEHICLE, DRIVER]} + actualEndAt: {type: string, format: date-time} + endLocation: {type: string, minLength: 1, maxLength: 500} + vehicleStatus: {type: string, nullable: true, enum: [IN_SERVICE, MAINTENANCE, INACTIVE, RETURNED], description: 结束车辆任务必填,结束司机任务必须为空} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + reason: {type: string, minLength: 1, maxLength: 1000} + ReleaseInfo: + type: object + nullable: true + properties: + actualEndAt: {type: string, format: date-time} + endLocation: {type: string} + vehicleStatus: {type: string, nullable: true, description: 结束本次车辆任务时的状态快照,不代表车辆此后的实时可派状态} + LoadingEnvelope: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + tripNo: {type: string} + recordVersion: {type: integer, format: int64} + manifestHash: {type: string} + cargo: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + blockReason: {type: string, nullable: true} + confirmedLoad: + type: object + nullable: true + properties: + manifestHash: {type: string} + cargo: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + pallets: {type: array, items: {$ref: '#/components/schemas/Measurement'}} + segments: {type: array, items: {type: object}, description: 逐段fromIndex/toIndex及occupied/remaining托位、weightKg、volumeCbm} + operatorId: {type: string} + confirmedAt: {type: string, format: date-time} + Cargo: + type: object + properties: + dispatchNo: {type: string} + customerId: {type: string} + dispatchItemId: {type: string} + warehousePalletId: {type: string, nullable: true} + palletNo: {type: string, nullable: true} + packages: {type: integer} + destinationKey: {type: string, nullable: true, description: 原货物目的地稳定匹配键,选择路线站点时透传} + destinationLabel: {type: string, nullable: true} + Measurement: + type: object + required: [warehousePalletId, palletSpaces, weightKg, volumeCbm, deliveryIndex] + properties: + warehousePalletId: {type: string} + palletSpaces: {type: integer, minimum: 1} + weightKg: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999} + volumeCbm: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999} + deliveryIndex: {type: integer, minimum: 1, description: 卸货站索引,起运仓固定为0} + PlanningEnvelope: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + tripNo: {type: string} + recordVersion: {type: integer, format: int64} + cargo: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + manifestHash: {type: string} + planValid: {type: boolean, description: 计划已经核对且来源hash仍一致} + availableSegments: {type: array, items: {$ref: '#/components/schemas/Segment'}, description: 当前自有货计划加已确认外接预订后的逐段占用与余位;计划失效返回空数组} + blockReason: {type: string, nullable: true} + capacityPlan: + type: object + nullable: true + description: 计划值,不能作为实际装载确认 + properties: + manifestHash: {type: string} + cargo: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + pallets: {type: array, items: {$ref: '#/components/schemas/Measurement'}} + segments: {type: array, items: {$ref: '#/components/schemas/Segment'}} + operatorId: {type: string} + confirmedAt: {type: string, format: date-time} + Segment: + type: object + properties: + fromIndex: {type: integer} + toIndex: {type: integer} + occupied: {$ref: '#/components/schemas/CapacityLoad'} + remaining: {$ref: '#/components/schemas/CapacityLoad'} + CapacityLoad: + type: object + properties: + palletSpaces: {type: integer} + weightKg: {type: number} + volumeCbm: {type: number} + ConfirmLoad: + type: object + required: [recordVersion, manifestHash, pallets, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + manifestHash: {type: string, pattern: '^[0-9a-f]{64}$'} + pallets: {type: array, minItems: 1, maxItems: 1000, items: {$ref: '#/components/schemas/Measurement'}} + reason: {type: string, minLength: 1, maxLength: 1000} + BindingCommand: + type: object + required: [recordVersion, dispatchNo, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + dispatchNo: {type: string, minLength: 1, maxLength: 64} + reason: {type: string, minLength: 1, maxLength: 1000} + Review: + type: object + required: [recordVersion, decision] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + decision: {type: string, enum: [APPROVED, REJECTED]} + reason: {type: string, nullable: true, maxLength: 1000, description: 通过选填;打回必填} + ReviewState: + type: object + required: [status] + properties: + status: {type: string, enum: [PENDING, APPROVED, REJECTED]} + reason: {type: string, nullable: true} + reviewerId: {type: string, nullable: true} + reviewedAt: {type: string, format: date-time, nullable: true} + recordVersion: {type: integer, format: int64, nullable: true, description: 本次审核所核对的车次版本} + DispatchBinding: + type: object + properties: + dispatchNo: {type: string} + customerId: {type: string, description: 排车单自身客户归属,由服务端解析} + packageCount: {type: integer, description: 关联时待发运箱数,不是实物托位数} + boundBy: {type: string} + boundAt: {type: string, format: date-time} + review: {$ref: '#/components/schemas/ReviewState'} + Envelope: + type: object + properties: + code: {type: string} + message: {type: string, nullable: true} + data: {type: object, nullable: true} + Stop: + type: object + required: [name] + properties: + name: {type: string, minLength: 1, maxLength: 128} + address: {type: string, nullable: true, maxLength: 500} + destinationKey: {type: string, nullable: true, maxLength: 160, description: 卸货站匹配Loading货物的destinationKey;未配置时可以保存计划但不能确认装载} + Create: + type: object + required: [warehouseId, plannedDepartureAt, expectedDriverEndAt, stops, idempotencyKey] + properties: + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + plannedDepartureAt: {type: string, format: date-time} + expectedDriverEndAt: {type: string, format: date-time} + stops: {type: array, minItems: 2, maxItems: 200, items: {$ref: '#/components/schemas/Stop'}} + remark: {type: string, nullable: true, maxLength: 1000} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + Plan: + type: object + required: [recordVersion, plannedDepartureAt, expectedDriverEndAt, stops, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + plannedDepartureAt: {type: string, format: date-time} + expectedDriverEndAt: {type: string, format: date-time} + stops: {type: array, minItems: 2, maxItems: 200, items: {$ref: '#/components/schemas/Stop'}} + remark: {type: string, nullable: true, maxLength: 1000} + reason: {type: string, minLength: 1, maxLength: 1000} + Trip: + type: object + properties: + tripNo: {type: string} + status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]} + warehouseId: {type: string} + warehouseCode: {type: string} + warehouseName: {type: string} + vehicleId: {type: string, nullable: true} + vehiclePlate: {type: string, nullable: true} + driverId: {type: string, nullable: true} + driverName: {type: string, nullable: true} + driverPhone: {type: string, nullable: true} + maxPalletSpaces: {type: integer, nullable: true} + maxLoadKg: {type: number, nullable: true} + maxVolumeCbm: {type: number, nullable: true} + plannedDepartureAt: {type: string, format: date-time} + expectedDriverEndAt: {type: string, format: date-time} + departedAt: {type: string, format: date-time, nullable: true} + completedAt: {type: string, format: date-time, nullable: true} + stops: {type: array, items: {$ref: '#/components/schemas/Stop'}} + remark: {type: string, nullable: true} + recordVersion: {type: integer, format: int64} + updatedAt: {type: string, format: date-time} + vehicleRelease: {$ref: '#/components/schemas/ReleaseInfo'} + driverRelease: {$ref: '#/components/schemas/ReleaseInfo'} diff --git a/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml new file mode 100644 index 000000000..6ee0ea42c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml @@ -0,0 +1,175 @@ +openapi: 3.0.3 +info: + title: 车辆运维记录 + version: 1.0.0 + description: >- + 平台车辆运维事实,支持保养、加油、维修、紧急故障、应急救援和异常。每条记录固定属于一辆车,使用VR业务编号;读取、修改、照片和历史均校验路径中的车辆归属。 + 原始里程必须明确KM或MI,加油量必须明确L、US_GAL或IMP_GAL;不自动推测或换算,不更新车辆主档公里数。 + 核验状态是人工记录的PENDING或VERIFIED,不代表财务审批或车辆可派,不自动产生账单、付款或变更车辆状态。 + 创建按车辆、操作者和幂等键去重;更正须版本及原因。照片上传按记录与幂等键去重并核对内容摘要,丢失响应可原样重试。 + 照片最多20张,每张非空JPG/PNG且最多10MiB;File按平台0、FLEET_VEHICLE_RECORD_PHOTO和记录内部主键复合归属,验证内容签名。 + 维修和救援项目、地点独立保存;处理中或已完成须有处理人,异常须有明确分类。 + 费用只是运维申报金额,填金额则必须填有效币种;未填金额和零金额有区别,不生成应付或付款。 + 维修/救援paymentStatus当前固定UNLINKED表示未关联财务,不得由processingStatus推定为已付款,其他类型为空。 + 不提供物理删除;编辑与上传历史同事务不可变保存。本接口不提供财务结算或车辆占用/状态自动变更。 +security: [{bearerAuth: []}] +paths: + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records: + parameters: [{$ref: '#/components/parameters/Vehicle'}] + get: + summary: 分页查询当前车辆运维记录 + description: '权限:platform:overseas-master-data:vehicle:list;按发生时间倒序,列表包含已关联照片元数据,不读取图片内容。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + - {name: recordType, in: query, schema: {type: string, enum: [MAINTENANCE, FUEL, REPAIR, BREAKDOWN, RESCUE, EXCEPTION]}} + responses: + '200': {description: '统一响应data为records/page/size/total/pages,records元素为Record'} + post: + summary: 新增记录 + description: '权限:platform:overseas-master-data:vehicle:record-create;同键同内容返回原记录,不同内容冲突。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [idempotencyKey, details] + properties: + idempotencyKey: {type: string, minLength: 1, maxLength: 128} + details: {$ref: '#/components/schemas/Draft'} + responses: + '200': {$ref: '#/components/responses/Record'} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}: + parameters: [{$ref: '#/components/parameters/Vehicle'}, {$ref: '#/components/parameters/RecordNumber'}] + get: + summary: 读取记录详情 + description: '权限:platform:overseas-master-data:vehicle:list;跨车辆记录返回404,不泄露其他车辆数据。' + responses: + '200': {$ref: '#/components/responses/Record'} + put: + summary: 更正记录 + description: '权限:platform:overseas-master-data:vehicle:record-edit;类型及车辆不可更换,保留历史。' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, reason, details] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + reason: {type: string, minLength: 1, maxLength: 1000} + details: {$ref: '#/components/schemas/Draft'} + responses: + '200': {$ref: '#/components/responses/Record'} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/files: + parameters: [{$ref: '#/components/parameters/Vehicle'}, {$ref: '#/components/parameters/RecordNumber'}] + post: + summary: 上传并关联运维图片 + description: '权限:platform:overseas-master-data:vehicle:record-upload;记录保存成功后再上传,失败重试不得重复建记录。' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [recordVersion, idempotencyKey, file] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 1, maxLength: 128} + file: {type: string, format: binary} + responses: + '200': {$ref: '#/components/responses/Record'} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/files/{fileId}: + parameters: + - {$ref: '#/components/parameters/Vehicle'} + - {$ref: '#/components/parameters/RecordNumber'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + get: + summary: 读取当前记录已关联图片 + description: '权限:platform:overseas-master-data:vehicle:list;先校验记录关联,再校验File复合归属;no-store和nosniff,不返回存储路径。' + responses: + '200': {description: 图片字节, content: {image/png: {schema: {type: string, format: binary}}, image/jpeg: {schema: {type: string, format: binary}}}} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/history: + parameters: [{$ref: '#/components/parameters/Vehicle'}, {$ref: '#/components/parameters/RecordNumber'}] + get: + summary: 分页读取不可变修改历史 + description: '权限:platform:overseas-master-data:vehicle:list;版本倒序,仅按需读取。' + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': {description: '统一响应data为分页对象;records包含recordVersion/action/reason/operatorId/occurredAt/snapshot,snapshot为Record'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + Vehicle: {name: vehicleId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + RecordNumber: {name: recordNo, in: path, required: true, schema: {type: string}} + responses: + Record: + description: 统一业务响应;失败保留code、message及诊断标识 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/Record'} + schemas: + Draft: + type: object + required: [recordType, occurredAt] + oneOf: + - properties: {recordType: {enum: [MAINTENANCE]}} + required: [distanceUnit, odometer, verificationStatus, lastServiceOdometer, nextServiceOdometer] + - properties: {recordType: {enum: [FUEL]}} + required: [distanceUnit, odometer, verificationStatus, fuelQuantity, fuelUnit] + - properties: {recordType: {enum: [REPAIR, BREAKDOWN, RESCUE, EXCEPTION]}} + required: [incident] + properties: + recordType: {type: string, enum: [MAINTENANCE, FUEL, REPAIR, BREAKDOWN, RESCUE, EXCEPTION]} + occurredAt: {type: string, format: date-time, description: 带偏移或Z的实际发生时间,不晚于当前时间,保存UTC} + distanceUnit: {type: string, nullable: true, enum: [KM, MI], description: 保养加油必填;其他类型必须为空} + odometer: {type: number, nullable: true, minimum: 0, multipleOf: 0.001, description: 保养加油必填;其他类型必须为空} + lastServiceOdometer: {type: number, nullable: true, minimum: 0, description: 保养必填,不大于记录时里程;加油必须为空} + nextServiceOdometer: {type: number, nullable: true, minimum: 0, description: 保养必填且大于上次保养里程,可低于当前里程表示已超保养里程;不默认一万迈} + fuelQuantity: {type: number, nullable: true, minimum: 0, exclusiveMinimum: true, description: 加油必填正数;保养必须为空} + fuelUnit: {type: string, nullable: true, enum: [L, US_GAL, IMP_GAL], description: 加油必填明确单位;保养必须为空} + verificationStatus: {type: string, nullable: true, enum: [PENDING, VERIFIED], description: 保养加油必填;其他类型必须为空,不能混作处理状态} + remark: {type: string, nullable: true, maxLength: 1000} + incident: {$ref: '#/components/schemas/Incident'} + Incident: + type: object + nullable: true + description: 维修/故障/救援/异常必填;保养加油必须为空,并禁止混入里程、加油或核验字段 + required: [description, processingStatus] + properties: + description: {type: string, minLength: 1, maxLength: 2000} + item: {type: string, nullable: true, maxLength: 256, description: 维修或救援项目,此两类必填;其他类型为空} + place: {type: string, nullable: true, maxLength: 512, description: 维修或救援地点,此两类必填;其他类型为空} + handler: {type: string, nullable: true, maxLength: 128, description: 实际处理人;处理中或完成时必填,与系统操作人分别保留} + processingStatus: {type: string, enum: [PENDING, IN_PROGRESS, COMPLETED]} + exceptionType: {type: string, nullable: true, enum: [ROUTE, TIMEOUT, DELIVERY, VEHICLE_BREAKDOWN], description: 异常记录必填;其他类型为空} + reportedCost: {type: number, nullable: true, minimum: 0, multipleOf: 0.01, maximum: 999999999999.99, description: 仅维修/救援允许;空为未登记,零为已登记零费用,不表示付款} + currency: {type: string, nullable: true, pattern: '^[A-Z]{3}$', description: 金额非空时必填有效币种;未填金额时为空,不自动换算} + File: + type: object + properties: + fileId: {type: string} + fileName: {type: string} + contentType: {type: string, enum: [image/jpeg, image/png]} + sizeBytes: {type: integer, format: int64} + Record: + type: object + properties: + recordNo: {type: string} + vehicleId: {type: string} + vehiclePlate: {type: string, description: 建档时车牌快照} + details: {$ref: '#/components/schemas/Draft'} + files: {type: array, items: {$ref: '#/components/schemas/File'}} + recordVersion: {type: integer, format: int64, minimum: 0} + updatedBy: {type: string} + updatedAt: {type: string, format: date-time} + paymentStatus: {type: string, nullable: true, readOnly: true, enum: [UNLINKED], description: 维修/救援未关联财务时为UNLINKED,其余为空;不可用处理完成推定已付款} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 9f7836de1..c91b76114 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -600,14 +600,14 @@ paths: '404': { description: 排车单不存在 } put: summary: 编辑待发车排车单 - description: 权限 platform:overseas-warehouse:dispatch:edit;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId。仅允许 PENDING_DEPARTURE,且请求不包含 status/departAt,发车事实只能由 PDA 作业命令产生。 + description: 权限 platform:overseas-warehouse:dispatch:edit;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId。仅允许 PENDING_DEPARTURE,且请求不包含 status/departAt,发车事实只能由 PDA 作业命令产生。已关联共享车次时拒绝独立编辑,须先按车次执行状态解除关联。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchUpdate' } } } } responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单不存在 }, '409': { description: 排车单已执行或不可编辑 } } delete: summary: 取消待发车排车单 - description: 权限 platform:overseas-warehouse:dispatch:cancel;只按全局唯一 dispatchNo 锁定记录并解析客户范围,不接收 customerId。仅 PENDING_DEPARTURE 可取消并释放正式托盘锁;重复取消幂等成功。 + description: 权限 platform:overseas-warehouse:dispatch:cancel;只按全局唯一 dispatchNo 锁定记录并解析客户范围,不接收 customerId。仅 PENDING_DEPARTURE 可取消并释放正式托盘锁;重复取消幂等成功。已关联共享车次必须先解除关联,不能单独取消破坏整车计划。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } } @@ -650,7 +650,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}: delete: summary: 移除已排车托盘 - description: 权限 platform:overseas-warehouse:dispatch:edit;仅待发车、未拣货且尚未生成出库单时允许,成功后释放托盘锁并刷新排车汇总。 + description: 权限 platform:overseas-warehouse:dispatch:edit;仅待发车、未拣货且尚未生成出库单时允许,成功后释放托盘锁并刷新排车汇总。已关联共享车次时须先解除关联。 security: [{ bearerAuth: [] }] parameters: - { name: dispatchNo, in: path, required: true, schema: { type: string } } @@ -715,10 +715,82 @@ paths: - { name: draftToken, in: path, required: true, schema: { type: string, maxLength: 64 } } - { name: containerId, in: path, required: true, schema: { type: string } } responses: { '200': { description: 空车厢删除成功 }, '409': { description: 车厢仍绑定托盘或草稿状态冲突 } } + /api/platform/v1/last-leg/dispatches/{dispatchNo}/fee-coverage: + get: + summary: 按实际发运来源查询原头程费用 + description: 权限platform:overseas-warehouse:dispatch:list;只接收排车号,客户由服务端解析。按实物库存与本次已拣数量还原各票来源并核平排车箱数;合托不使用单一汇总入仓码代替全部来源。sourceComplete=false时不返回费用候选。Finance批量查询仅返回各票该客户原入仓单的已审核有效应收最新版本,存在来源不等于任何服务已包含,查询不会免收或新增费用。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 来源核对结果与原费用候选 + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + dispatchNo: { type: string } + sourceComplete: { type: boolean } + blockReason: { type: string, nullable: true } + cargoHash: { type: string } + cargo: + type: array + items: + type: object + properties: + dispatchItemId: { type: string } + warehousePalletId: { type: string } + palletNo: { type: string, nullable: true } + orderId: { type: string } + sourceRegion: { type: string, nullable: true } + sourceInboundId: { type: string, nullable: true } + inboundCode: { type: string, nullable: true } + packages: { type: integer } + dispatchInboundCode: { type: string, nullable: true, description: 排车来源业务码;不能代替实物拆分后的各票inboundCode } + feeSources: + type: array + items: + type: object + properties: + financeFeeId: { type: string } + sourceFeeId: { type: string } + sourceType: { type: string } + 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] } + '404': { description: 排车单不存在 } + '503': { description: Finance来源查询不可用或返回范围不一致 } + /api/platform/v1/last-leg/dispatches/{dispatchNo}/external-freight: + get: + summary: 读取本次排车的外接实收约定运费 + description: 权限 platform:overseas-warehouse:dispatch:list;客户从排车单解析。逐票核对真实来货、车次关联、目的地及实收数量,返回不可变报价版本与货物摘要;查询不发布费用。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 约定运费与是否仍需填写其余货物派送费用 + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/ExternalFreightPreview' } + '404': { description: 排车单不存在 } + '409': { description: 外接实收或排车关联不一致 } /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery: post: summary: 排车转出库与派送单 - description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车重复调用返回全部既有单据。派送费用只发布一次,避免按拆分派送单重复计费。 + description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车只有业务请求内容与首次保存一致才返回既有单据,费用、计划时间、装载或其他字段变化均拒绝,不因更换幂等键绕过。派送费用只发布一次,费用更正使用正式来源调整流程。已关联车次时新请求不得覆盖车次维护的车牌、司机和电话;原请求重放不改写后续已同步的车次快照。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } @@ -766,7 +838,7 @@ paths: /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel: post: summary: 取消未执行出库单 - description: 权限 platform:overseas-warehouse:outbound:cancel;只按全局唯一 outboundNo 锁定并解析客户范围,不接收 customerId。仅未开始拣货/出库的单据可取消,重复取消幂等成功。 + description: 权限 platform:overseas-warehouse:outbound:cancel;操作人取认证身份,只按全局唯一outboundNo解析客户,不接收customerId。关联排车按dispatch→outbound顺序锁定,已拣货/发车拒绝,仍绑定车次时须先解除;取消出库不自动取消原排车占用,后续在排车列表取消。当前已审核有效的DELIVERY_FEE应收按原来源版本申请作废,装车/买约/应付等其他费用保留核对,物流取消不代表账务作废成功。取消原因完整保存在派送操作记录,重复取消幂等成功。 security: [{ bearerAuth: [] }] parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundCancel' } } } } @@ -783,7 +855,7 @@ paths: description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓 }, '404': { description: 派送单不存在 } } + responses: { '200': { description: '派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓;fees附reviewStatus、sourceStatus、voidStatus(NOT_REQUESTED/PENDING_FINANCE/FINANCE_ATTENTION/CONFIRMED),不得把待财务确认或待处理显示为已作废。feeCoverage为可空的原费用服务覆盖确认快照,只包含当前派送所属货物、引用的原费用版本及服务、确认人和时间;不代表新增收费或原费用已付款。' }, '404': { description: 派送单不存在 } } /api/platform/v1/last-leg/exception-orders: get: summary: 分页查询异常订单 @@ -983,7 +1055,7 @@ paths: responses: { '200': { description: 当前客户派送单分页 }, '403': { description: 缺少客户派送查看权限 } } post: summary: 客户从自己的排车单创建派送单 - description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。 + description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。已关联车次时请求不得覆盖车次维护的车牌、司机和电话。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: query, required: true, schema: { type: string, minLength: 1 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } @@ -2311,7 +2383,7 @@ components: planNo: { type: string, nullable: true } orderType: { type: string, nullable: true } volumeWeightKg: { type: number, nullable: true } - grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重(千克);按入库计划分配比例汇总,缺失时回退体积重 } + grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重(千克);以实际毛重按入库计划分配比例汇总,缺失时为空,禁止使用体积重替代;正式配载须核验实际装载 } containerNo: { type: string, nullable: true } appointmentAt: { type: string, format: date-time, nullable: true } expectedLoadingAt: { type: string, format: date-time, nullable: true } @@ -2327,8 +2399,6 @@ components: destinationWarehouseName: { type: string } fbaWarehouseCode: { type: string } packageCount: { type: integer } - grossWeightKg: { type: number, description: 排车托盘冻结毛重(千克) } - volumeWeightKg: { type: number, description: 排车托盘冻结材积重(千克) } volumeCbm: { type: number } sourceStage: { type: string, enum: [PLAN, RECEIVED] } cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } @@ -2396,6 +2466,7 @@ components: estimatedArrivalAt: { type: string, format: date-time, nullable: true } DeliveryTransfer: type: object + description: 外接货必须带入当前externalFreightHash,服务端按实收报价发布对应目的地的派送应收。平台可通过feeCoverage逐票确认原Finance费用所含服务,并传feeCoverageCargoHash;重新核对真实货物、费用ID/版本/hash及客户范围后冻结确认快照。只有全部相关货物已覆盖的服务才能免填对应新增应收,并禁止重复登记该应收;仍可独立登记供应商应付。未覆盖部分收费,原费用不复制入账也不伪造零金额费用。客户接口禁止自行声明feeCoverage。已有成功命令重放保留原覆盖快照。 required: [idempotencyKey, plannedDeliveryAt, deliveryMethod, pickupMethod, loadingType, fees] properties: idempotencyKey: { type: string, minLength: 1, maxLength: 255 } @@ -2413,7 +2484,40 @@ components: appointmentAccount: { type: string, nullable: true, maxLength: 128 } sellerName: { type: string, nullable: true, maxLength: 128 } remark: { type: string, nullable: true, maxLength: 1000 } - fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } + fees: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } + externalFreightHash: { type: string, nullable: true, maxLength: 64 } + feeCoverageCargoHash: { type: string, nullable: true, maxLength: 64 } + feeCoverage: { type: array, nullable: true, maxItems: 2000, items: { $ref: '#/components/schemas/FeeCoverageSelection' } } + FeeCoverageSelection: + type: object + required: [financeFeeId, sourceVersion, sourceHash, services] + properties: + financeFeeId: { type: string, pattern: '^[1-9][0-9]*$' } + sourceVersion: { type: integer, minimum: 1 } + sourceHash: { type: string } + services: { type: array, minItems: 1, maxItems: 3, items: { type: string, enum: [DELIVERY_FEE, LOADING_FEE, APPOINTMENT_FEE] } } + ExternalFreightPreview: + type: object + required: [snapshotHash, requiresDeliveryFee, quotes] + properties: + snapshotHash: { type: string } + requiresDeliveryFee: { type: boolean } + quotes: + type: array + items: + type: object + properties: + bookingNo: { type: string } + recordVersion: { type: integer, format: int64 } + incomingNo: { type: string } + inboundCode: { type: string, nullable: true } + customerName: { type: string } + destinationKey: { type: string } + currency: { type: string, enum: [CNY, USD] } + amount: { type: number } + quantity: { type: number } + quantityUnit: { type: string, enum: [托位, 单] } + unitPrice: { type: number } DeliveryStatusChange: type: object required: [status, expectedVersion] diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml index 0e511069e..617b106ca 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -10,7 +10,7 @@ paths: - { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } } - { in: query, name: businessNo, schema: { type: string } } - { in: query, name: containerBookingId, description: 精确查询真实来源柜下全部客户的费用;可再传customerId收窄到单个客户, schema: { type: string, pattern: '^[1-9][0-9]*$' } } - responses: { '200': { description: 当前版本列表 } } + responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } } post: summary: 创建海外业务费用来源草稿 description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 @@ -34,7 +34,7 @@ paths: /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: post: summary: 提交、审核、调整或作废海外业务费用来源 - description: 审核通过与作废均事务性写入Outbox,经Event Bridge可靠投递至独立Finance;action为submit、review、adjust、void。 + description: 审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。 security: [{ bearerAuth: [] }] parameters: - { in: path, name: sourceFeeId, required: true, schema: { type: string } } diff --git a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml index 06a85196f..fc59b8d73 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -264,10 +264,14 @@ components: VehicleStatus: { type: string, enum: [IN_SERVICE, DISPATCHED, MAINTENANCE, INACTIVE, RETURNED] } VehicleCreate: type: object + description: 托位/载重/容积同时填写正数,或全部留空表示容量尚未确认;未知容量不可用于正式配载,不按车型名称推算。 required: [plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership, status] properties: plateNumber: { type: string, maxLength: 64 } model: { type: string, maxLength: 128 } + maxPalletSpaces: { type: integer, minimum: 1, maximum: 10000, nullable: true } + maxLoadKg: { type: number, minimum: 0, exclusiveMinimum: true, nullable: true } + maxVolumeCbm: { type: number, minimum: 0, exclusiveMinimum: true, nullable: true } fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } mileageKm: { type: integer, format: int64, minimum: 0 } driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } @@ -278,11 +282,15 @@ components: remark: { type: string, maxLength: 1000, nullable: true } VehicleUpdate: type: object + description: 托位/载重/容积同时填写正数,或全部留空;不能部分设置容量。 required: [recordVersion, plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership] properties: recordVersion: { type: integer, format: int64, minimum: 0 } plateNumber: { type: string, maxLength: 64 } model: { type: string, maxLength: 128 } + maxPalletSpaces: { type: integer, minimum: 1, maximum: 10000, nullable: true } + maxLoadKg: { type: number, minimum: 0, exclusiveMinimum: true, nullable: true } + maxVolumeCbm: { type: number, minimum: 0, exclusiveMinimum: true, nullable: true } fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } mileageKm: { type: integer, format: int64, minimum: 0, description: 不得小于已有公里码 } driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 99cd5ad41..c0247f9a3 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -15,12 +15,13 @@ paths: /api/pda/v1/last-leg/dispatches/{dispatchNo}: get: summary: 查询绑定仓排车任务详情 - description: 返回 canDepart 与 departBlockReason;PDA 必须以服务端门禁决定是否开放发车,不得仅按本地拣货数量推断。 + description: 返回 tripNo、tripRecordVersion、tripDispatchCount、driverWarning、canDepart、departBlockReason、canPick 与 pickBlockReason;门禁反映整车所有客户单据、实际装载及当前司机资格,PDA 不得仅按当前子单拣货数量推断。canPick/pickBlockReason仅反映当前客户排车单调度审核,不以其他客户审核结果替代。未关联车次不可拣货或发车;列表按车次复用同次查询的门禁结果。证照30天内到期但覆盖任务时driverWarning提醒且仍允许,预计结束前过期则阻断。 parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 排车托盘、目的仓与库存位置详情 }, '404': { description: 当前绑定仓无此排车任务 } } /api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick: post: summary: 扫码拣取排车托盘包裹 + description: 新请求须当前排车调度审核通过;同键同内容重放已保存结果不重新扣库存。 parameters: - { name: dispatchNo, in: path, required: true, schema: { type: string } } - { name: itemId, in: path, required: true, schema: { type: string, pattern: '^[0-9]+$' } } @@ -29,17 +30,17 @@ paths: /api/pda/v1/last-leg/dispatches/{dispatchNo}/complete: post: summary: 完成排车拣货 - description: 每个要求托盘及全部包裹必须精确完成;不允许部分完成。 + description: 当前排车调度审核通过且每个要求托盘及全部包裹必须精确完成;不允许部分完成。 parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } responses: { '200': { description: READY_TO_DEPART结果 }, '409': { description: 未全部拣货或幂等冲突 } } /api/pda/v1/last-leg/dispatches/{dispatchNo}/depart: post: - summary: 排车发车并完成出库门禁 - description: 仅完整拣货且存在待出库单时发车;同事务推进出库与排车状态、订单归属并释放托盘锁,不重复扣减扫码时已扣库存。 + summary: 从排车任务确认所属车次整车发车 + description: 按Token绑定仓锁定车次,校验期望车次版本、车辆占用、全部调度审核、实际装载、整车理货审核及全部客户拣货和待出库单,重新核验司机证照、当前海外区域和身份;区域或身份变化与预览门禁一致阻断。一个事务推进所有子单,任一失败全车回滚。扫码阶段已扣库存不重复扣减;同键同内容重放整车结果,不同内容冲突。DEPARTING仅是同事务内部状态,不对外提交。 parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DepartAction' } } } } - responses: { '200': { description: DEPARTED结果;同键同请求返回既有结果 }, '409': { description: 拣货、出库或幂等门禁未满足 } } + responses: { '200': { description: '统一响应data包含tripNo、status=IN_TRANSIT、departedAt、shippedPackages、dispatches数组(dispatchNo/shippedPackages)和replayed;每张子排车状态均为DEPARTED' }, '409': { description: 车次版本、装载、资格、拣货、出库或幂等门禁未满足 } } /api/pda/v1/last-leg/outbound-tasks: get: summary: 查询Token绑定仓的一件代发出库任务 @@ -850,9 +851,10 @@ components: properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } DepartAction: type: object - required: [idempotencyKey] + required: [recordVersion, idempotencyKey] properties: - idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + recordVersion: { type: integer, format: int64, minimum: 0, description: 当前车次版本,来自tripRecordVersion} + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } remark: { type: string, maxLength: 1000, nullable: true } DispatchPick: type: object diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java new file mode 100644 index 000000000..b8fedd86a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.client.FinanceFeeCoverageClient; +import com.baiyue.lastleg.delivery.controller.PlatformDeliveryFeeCoverageController; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper; +import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DeliveryFeeCoverageServiceTest { + final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class); + final DispatchItemMapper items = mock(DispatchItemMapper.class); + final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class); + final FinanceFeeCoverageClient finance = mock(FinanceFeeCoverageClient.class); + final DeliveryFeeCoverageService service = new DeliveryFeeCoverageService(dispatches, items, cargo, finance, + new ObjectMapper().findAndRegisterModules(), "coverage-test-token"); + @BeforeEach void setup() { + var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); dispatch.setDispatchNo("DP-1"); + when(dispatches.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch); + var selected = new DispatchItem(); selected.setId(201L); selected.setPackageCount(20); + when(items.findByDispatchOrder("101", 1L)).thenReturn(List.of(selected)); + when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10), source("12", "IN-B", 10))); + } + @Test void batchesBothActualInboundSourcesInsteadOfUsingOneDisplayCode() { + var query = new FinanceFeeCoverageClient.Query(101L, List.of(11L, 12L)); + when(finance.sources("coverage-test-token", query)).thenReturn(ApiResponse.success(List.of(fee(101, 11)))); + var result = service.preview("DP-1"); + assertThat(result.sourceComplete()).isTrue(); assertThat(result.cargo()).hasSize(2); assertThat(result.feeSources()).hasSize(1); + assertThat(result.cargoHash()).hasSize(64); + verify(finance).sources("coverage-test-token", query); verifyNoMoreInteractions(finance); + } + @Test void incompletePhysicalSourcesNeverProduceAFalseCoverageOption() { + when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10))); + var result = service.preview("DP-1"); + assertThat(result.sourceComplete()).isFalse(); assertThat(result.blockReason()).contains("箱数不一致"); + assertThat(result.feeSources()).isEmpty(); verifyNoInteractions(finance); + } + @Test void unavailableOrWrongScopeFinanceResultsFailClosed() { + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(202, 11)))); + assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("503"); + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 999)))); + assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class); + when(finance.sources(anyString(), any())).thenReturn(null); + assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class); + } + @Test void platformReadonlyEndpointDoesNotAcceptMerchantIdentityOrAClientSuppliedCustomer() throws Exception { + var controller = new PlatformDeliveryFeeCoverageController(service); + var merchant = new AuthenticatedUser(7L, 101L, 101L, 8L, "merchant", "MERCHANT", "WEB", Set.of()); + assertThatThrownBy(() -> controller.preview(merchant, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("403"); + assertThatThrownBy(() -> controller.preview(null, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("401"); + var method = PlatformDeliveryFeeCoverageController.class.getMethod("preview", AuthenticatedUser.class, String.class); + assertThat(method.getAnnotation(PreAuthorize.class).value()).contains("platform:overseas-warehouse:dispatch:list"); + verifyNoInteractions(dispatches, items, cargo, finance); + } + @Test void onlyFullyCoveredServicesCanRemoveANewChargeAndTheSnapshotKeepsEveryActualSource() { + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11), fee(101,12)))); + var preview = service.preview("DP-1"); + var a = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE")); + var partial = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a)), "OP-1", Set.of()); + assertThat(partial.includedServices()).isEmpty(); + assertThat(partial.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::sourceInboundId).containsExactly("12"); + var b = new DeliveryFeeCoverageModels.Selection(712L, 1, fee(101,12).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE")); + var complete = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a,b)), "OP-1", Set.of()); + assertThat(complete.includedServices()).containsExactlyInAnyOrder("DELIVERY_FEE", "LOADING_FEE"); + assertThat(complete.evidence().cargo()).hasSize(2); assertThat(complete.evidence().references()).hasSize(2); + assertThat(complete.evidence().operatorId()).isEqualTo("OP-1"); + } + @Test void staleCargoFeeVersionAndRepeatedCoverageAreRejected() { + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11)))); + var preview = service.preview("DP-1"); + var selected = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE")); + assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest("old-cargo", List.of(selected)), "OP-1", Set.of())).hasMessageContaining("货物已变化"); + var old = new DeliveryFeeCoverageModels.Selection(711L, 2, selected.sourceHash(), selected.services()); + assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(old)), "OP-1", Set.of())).hasMessageContaining("版本已变化"); + assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selected, selected)), "OP-1", Set.of())).hasMessageContaining("只能引用一项"); + } + static DeliveryRequests.Transfer coverageRequest(String hash, List selections) { + return new ObjectMapper().findAndRegisterModules().convertValue(java.util.Map.of("feeCoverageCargoHash", hash, "feeCoverage", selections), DeliveryRequests.Transfer.class); + } + static DeliveryFeeCoverageModels.Cargo source(String id, String code, long packages) { + return new DeliveryFeeCoverageModels.Cargo(201L, 501L, "PLT-501", Long.valueOf(id), "FIRSTLEG", id, code, packages, "IN-A"); + } + static FinanceFeeCoverageClient.Fee fee(long customer, long inbound) { + return new FinanceFeeCoverageClient.Fee(700L + inbound, 800L + inbound, "INBOUND_FEE", 1, customer, inbound, + "IN-A", "FREIGHT", "原运费", "CNY", BigDecimal.TEN, "sha256:" + "a".repeat(64), "AVAILABLE"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index d3bd1ed4e..b5bcd85d8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -34,9 +34,9 @@ class DeliveryLongIdJsonContractTest { deliveryRow(), List.of(), List.of(new DeliveryResponses.DeliveryFeeRow( - FEE_ID, "RECEIVABLE", "DELIVERY_FEE", "USD", null, null)), + FEE_ID, "RECEIVABLE", "DELIVERY_FEE", "USD", null, null, "APPROVED", "ACTIVE", "NOT_REQUESTED")), List.of(new DeliveryResponses.DeliveryOperationRow( - OPERATION_ID, null, "CREATED", "tester", "success"))); + OPERATION_ID, null, "CREATED", "tester", "success")), null); JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(detail)); @@ -67,7 +67,7 @@ class DeliveryLongIdJsonContractTest { null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, - com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null, + com.baiyue.lastleg.delivery.service.DeliveryDocumentSnapshot.summary(null), null, null, null, null, null, null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 6581d8b52..68df52f74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -17,6 +17,18 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; class DeliverySchemaContractTest { + @Test + void shipmentSnapshotsKeepUnknownPhysicalWeightNullableAcrossBothBaselines() throws Exception { + for (Path path : java.util.List.of(Path.of("src/main/resources/db/lastleg.sql"), Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) { + String sql = Files.readString(path, StandardCharsets.UTF_8); + for (String table : java.util.List.of("ll_dispatch_item", "ll_outbound_order", "ll_outbound_pallet_item", "ll_delivery_order")) { + var matcher = java.util.regex.Pattern.compile("(?is)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(sql); + assertTrue(matcher.find(), table); + String column = "ll_delivery_order".equals(table) ? "total_weight_kg" : "gross_weight_kg"; + assertTrue(java.util.regex.Pattern.compile("(?i)" + column + " DECIMAL\\(18,[34]\\) NULL COMMENT").matcher(matcher.group()).find(), table + " keeps unknown weight NULL"); + } + } + } @Test void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception { String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java new file mode 100644 index 000000000..f89490f9b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.delivery; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DispatchFeeCargoMapperTest { + JdbcTemplate jdbc; DispatchFeeCargoMapper mapper; + @BeforeEach void setup() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT,pallet_no VARCHAR(64),overseas_warehouse_id VARCHAR(64),plan_pallet_id BIGINT,inbound_code VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)"); + jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(64),dispatch_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(task_id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(201,1,'101',501,'PLT-501','WH-1',NULL,'IN-A')"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(101,'101',501,'WH-1',301,8),(102,'101',501,'WH-1',302,6),(103,'202',501,'WH-1',303,50),(104,'101',501,'WH-2',301,50)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(301,'101',401,'IN-A'),(302,'101',402,'IN-B'),(303,'202',403,'IN-OTHER')"); + jdbc.update("INSERT INTO ll_order VALUES(401,'101','FIRSTLEG','11'),(402,'101','FIRSTLEG','12'),(403,'202','FIRSTLEG','13')"); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,'101',1),(602,'101',2)"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'101',201,101,2),(601,'101',201,102,4),(602,'101',201,101,50)"); + var config = new MybatisConfiguration(); config.addMapper(DispatchFeeCargoMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet(); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchFeeCargoMapper.class); + } + @Test void preservesEveryInboundOnSharedPalletAndIncludesOnlyThisDispatchsPickedPackages() { + var rows = mapper.list("101", 1); + assertThat(rows).hasSize(2); + assertThat(rows).extracting(row -> row.sourceInboundId()).containsExactly("11", "12"); + assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L); + assertThat(mapper.list("202", 1)).isEmpty(); + } + @Test void aSelectedPlanPalletCannotBorrowOtherInboundStockOnTheSamePhysicalPallet() { + jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801"); + jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)"); + jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(901,'101',401)"); + assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index f7af714d3..825c3e312 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -94,6 +94,8 @@ class PlatformDispatchOutboundContractTest { Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class); Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class, String.class, DeliveryRequests.Transfer.class); + Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class); + assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST); Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail", AuthenticatedUser.class, String.class); Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound", @@ -105,7 +107,7 @@ class PlatformDispatchOutboundContractTest { assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST); assertEquals(2, outboundDetail.getParameterCount()); assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL); - for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) { + for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) { assertTrue(Arrays.stream(method.getParameters()) .noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java index ff88c80c8..9b187676e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java @@ -125,7 +125,7 @@ class MerchantDeliveryControllerTest { @Test void invalidCreatePayloadIsRejectedByBeanValidationContract() { DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null, - " ", " ", null, null, null, null, null, null, null, null, null); + " ", " ", null, null, null, null, null, null, null, null, null, null, null, null); try (var factory = Validation.buildDefaultValidatorFactory()) { assertTrue(factory.getValidator().validate(invalid).size() >= 6); } @@ -186,6 +186,6 @@ class MerchantDeliveryControllerTest { "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null, List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY", new BigDecimal("10.00"), null, null, null, null, null, null, null, null, - null, null, null, null, null, null))); + null, null, null, null, null, null)), null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index 6eadd68a1..fc320bcee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -26,6 +26,7 @@ import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*; @ExtendWith(MockitoExtension.class) class DeliveryDocumentServiceTest { @Mock DeliveryOrderMapper orders; + @Mock OutboundOrderMapper outboundOrders; @Mock DeliveryDocumentRevisionMapper revisions; @Mock DeliveryOperationMapper operations; @Mock DeliveryDocumentFileGateway files; @@ -37,6 +38,7 @@ class DeliveryDocumentServiceTest { @BeforeEach void setUp() { service = new DeliveryDocumentService(); ReflectionTestUtils.setField(service,"orders",orders); + ReflectionTestUtils.setField(service,"outboundOrders",outboundOrders); ReflectionTestUtils.setField(service,"revisions",revisions); ReflectionTestUtils.setField(service,"operations",operations); ReflectionTestUtils.setField(service,"files",files); @@ -46,6 +48,7 @@ class DeliveryDocumentServiceTest { ReflectionTestUtils.setField(service,"customerVisits",customerVisits); order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); + order.setOutboundNo("OUT-1"); } @Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() { when(orders.lockByNo("100","DL-1")).thenReturn(order); @@ -132,6 +135,8 @@ class DeliveryDocumentServiceTest { } @Test void savingReceiptAutomaticallyCompletesOnlyThisDeliveryAndItsBoundOrders() { order.setStatus("WAIT_DELIVERY"); + var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED"); + when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound); LastlegOrder related = new LastlegOrder(); related.setId(20L); related.setCustomerId("100"); related.setDeliveryOrderNo("DL-1"); related.setSubOrderStatus("DELIVERY:WAIT_DELIVERY"); when(orders.lockByNo("100","DL-1")).thenReturn(order); @@ -151,6 +156,27 @@ class DeliveryDocumentServiceTest { assertThat(operation.getAllValues()).extracting(DeliveryOperation::getOperationNode) .containsExactly("SAVE_DOCUMENTS", "RECEIPT_AUTO_COMPLETE"); } + @Test void receiptCannotCompleteDeliveryBeforeItsActualOutbound() { + order.setStatus("WAIT_DELIVERY"); + var outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound); + assertThatThrownBy(() -> service.save("100","DL-1","9",request(0,"too-early",List.of(new Binding(RECEIPT,List.of(13L)))))) + .hasMessageContaining("先完成实际出库"); + verify(orders, never()).updateById(any(DeliveryOrder.class)); + verifyNoInteractions(lastlegOrders, deliveryCompletedOutbox, customerVisits, operations); + assertThat(order.getStatus()).isEqualTo("WAIT_DELIVERY"); + assertThat(order.getVersion()).isZero(); + } + @Test void bolMayBePreparedBeforeOutboundWithoutCompletingDelivery() { + order.setStatus("WAIT_DELIVERY"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,BOL,11)).thenReturn(file(11)); + service.save("100","DL-1","9",request(0,"prepare-bol",List.of(new Binding(BOL,List.of(11L))))); + assertThat(order.getStatus()).isEqualTo("WAIT_DELIVERY"); + verifyNoInteractions(outboundOrders, deliveryCompletedOutbox, customerVisits); + } @Test void merchantReadsOnlyCurrentReceiptFilesAndDownloadsWithAggregateOwnershipChecks() throws Exception { order.setDocumentsJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(List.of( new Category(BOL,"UPLOADED",1,List.of(file(11))), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 0bb621769..6b9e4d37d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -53,6 +53,8 @@ import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Set; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -60,6 +62,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -111,11 +114,19 @@ class DeliveryTransferServiceTest { @Mock WarehouseTransferPalletMapper warehouseTransferPallets; private DeliveryTransferService service; + @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; + @Mock com.baiyue.lastleg.fleet.trip.FleetBookingFreight bookingFreight; + @Mock DeliveryFeeCoverageService feeCoverage; @BeforeEach void setUp() { service = new DeliveryTransferService(); - ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper()); + ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); + ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight); + ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage); + org.mockito.Mockito.lenient().when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) + .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("test-hash", true, List.of(), List.of())); + ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); @@ -141,6 +152,35 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets); } + @Test + void transferPreservesUnknownGrossWeightThroughPalletAndDeliverySnapshots() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + DispatchItem known = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + DispatchItem unknown = item(202L, 502L, "PLT-502", "ONT8", "Ontario", "IN-B", 8); + unknown.setGrossWeightKg(null); unknown.setVolumeWeightKg(new BigDecimal("999")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(known, unknown)); + service.platformTransfer("DSP-101", "U-1", transferRequest("unknown-gross", null)); + var outbound = ArgumentCaptor.forClass(OutboundOrder.class); + var delivery = ArgumentCaptor.forClass(DeliveryOrder.class); + var pallets = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundOrders).insert(outbound.capture()); + verify(deliveryOrders).insert(delivery.capture()); + verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture()); + assertEquals(null, outbound.getValue().getGrossWeightKg()); + assertEquals(null, delivery.getValue().getTotalWeightKg()); + assertEquals(null, pallets.getAllValues().get(1).getGrossWeightKg()); + assertEquals(new BigDecimal("999"), pallets.getAllValues().get(1).getVolumeWeightKg()); + } + + @Test void transferCannotOverrideAnAssignmentOwnedByTheTrip() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "车辆司机由车次维护")) + .when(fleetBindings).requireMatchingAssignment(org.mockito.ArgumentMatchers.eq(101L), any(), any()); + assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", transferRequest("bound-transfer", null))); + org.mockito.Mockito.verifyNoInteractions(deliveryOrders, feeSources, dispatchItems); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + @Test void firstTransferCreatesOneOutboundAndOneDeliveryPerDestinationFbaWarehouse() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); @@ -220,26 +260,78 @@ class DeliveryTransferServiceTest { assertEquals("LX-1", outbound.getValue().getLxCodes()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"SAME_KEY", "NEW_KEY", "INSERT_RACE"}) + void changedFeesCannotReplayAnEarlierTransferAsSuccessful(String path) throws Exception { + var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + var request = transferRequest("transfer-original", null); + service.platformTransfer("DSP-101", "U-1", request); + var created = ArgumentCaptor.forClass(OutboundOrder.class); + var delivered = ArgumentCaptor.forClass(DeliveryOrder.class); + verify(outboundOrders).insert(created.capture()); verify(deliveryOrders).insert(delivered.capture()); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode changed = mapper.valueToTree(request); + ((com.fasterxml.jackson.databind.node.ObjectNode) changed.withArray("fees").get(0)).put("amount", new BigDecimal("20.00")); + if ("NEW_KEY".equals(path)) changed.put("idempotencyKey", "another-key"); + var retry = mapper.treeToValue(changed, DeliveryRequests.Transfer.class); + if ("NEW_KEY".equals(path)) when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(created.getValue()); + else if ("INSERT_RACE".equals(path)) { + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(null, created.getValue()); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("race")); + } else when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(created.getValue()); + org.mockito.Mockito.lenient().when(deliveryOrders.lockByOutbound("TENANT-A", created.getValue().getId())).thenReturn(List.of(delivered.getValue())); + var failure = assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", retry)); + assertEquals(409, failure.getStatusCode().value()); + assertTrue(failure.getReason().contains("内容不一致")); + verify(feeSources).publishApproved(any(), any(), any()); + verify(deliveryOrders).insert(any(DeliveryOrder.class)); + } + @Test void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + var request = transferRequest(" transfer-101 ", null); + outbound.setRequestHash(service.transferRequestHash(request)); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", - transferRequest(" transfer-101 ", null)); + request); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); + org.mockito.Mockito.verifyNoInteractions(fleetBindings); verify(outboundOrders, never()).insert((OutboundOrder) any()); verify(outboundPallets, never()).insert((OutboundPalletItem) any()); verify(deliveryOrders, never()).insert((DeliveryOrder) any()); } + @Test + void identicalBusinessContentWithAnotherCommandKeyReturnsExistingDocumentsWithoutNewFees() throws Exception { + var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + var original = transferRequest("first-key", null); + var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "first-key"); + outbound.setRequestHash(service.transferRequestHash(original)); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode content = mapper.valueToTree(original); + content.put("idempotencyKey", "another-key"); + var retry = mapper.treeToValue(content, DeliveryRequests.Transfer.class); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery(401L, outbound, "DEL-401"))); + var replay = service.platformTransfer("DSP-101", "U-1", retry); + assertTrue(replay.replayed()); assertEquals("OUT-301", replay.outboundNo()); + org.mockito.Mockito.verifyNoInteractions(feeSources, fleetBindings); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + } + @Test void idempotencyKeyCannotBeReusedForAnotherDispatch() { DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A"); @@ -258,6 +350,8 @@ class DeliveryTransferServiceTest { void concurrentDuplicateInsertReturnsWinningDocuments() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + var request = transferRequest("transfer-101", null); + winner.setRequestHash(service.transferRequestHash(request)); DeliveryOrder delivery = delivery(401L, winner, "DEL-401"); when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( @@ -267,7 +361,7 @@ class DeliveryTransferServiceTest { when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", - transferRequest("transfer-101", null)); + request); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); @@ -391,6 +485,8 @@ class DeliveryTransferServiceTest { void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key"); + var request = transferRequest(" merchant-key ", null); + outbound.setRequestHash(service.transferRequestHash(request)); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); @@ -398,7 +494,7 @@ class DeliveryTransferServiceTest { when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2", - transferRequest(" merchant-key ", null)); + request); assertTrue(result.replayed()); assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); @@ -457,11 +553,12 @@ class DeliveryTransferServiceTest { OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setStatus("PENDING"); when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task); - service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("cancelled by platform")); - service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("duplicate command")); + service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("cancelled by platform")); + service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("duplicate command")); assertEquals("CANCELLED", outbound.getStatus()); assertEquals("CANCELLED", item.getStatus()); @@ -473,10 +570,11 @@ class DeliveryTransferServiceTest { void globalOutboundCancelRejectsAnExecutedOrderWithoutDrift() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-key"); outbound.setStatus("PICKED"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("too late"))); + service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("too late"))); assertEquals(409, exception.getStatusCode().value()); assertEquals("PICKED", outbound.getStatus()); @@ -484,6 +582,66 @@ class DeliveryTransferServiceTest { verify(outboundPallets, never()).findByOutbound(any(), any()); } + @Test @SuppressWarnings({"rawtypes", "unchecked"}) + void cancellingDeliveryRequestsOnlyUnperformedFreightReceivablesAndPreservesOtherCosts() { + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(new org.apache.ibatis.builder.MapperBuilderAssistant( + new com.baomidou.mybatisplus.core.MybatisConfiguration(), "cancellation-test"), LastlegOrder.class); + var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key"); + var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PENDING_DEPARTURE"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + var pallet = pallet(501L, "P-1", "ONT8", "Ontario", "IN-1", 12); pallet.setStatus("WAIT_PICK"); pallet.setPickedPackageCount(0); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet)); + var delivery = new DeliveryOrder(); delivery.setId(401L); delivery.setDeliveryNo("DEL-1"); delivery.setStatus("WAIT_DELIVERY"); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); + var freight = cancellationFee(701, "DELIVERY_FEE", "RECEIVABLE"); + when(feeSources.listForBusiness("TENANT-A", "DELIVERY_ORDER", 401L)).thenReturn(List.of(freight, + cancellationFee(702, "APPOINTMENT_FEE", "RECEIVABLE"), cancellationFee(703, "DELIVERY_FEE", "PAYABLE"))); + var order = new LastlegOrder(); order.setId(901L); order.setCustomerId("TENANT-A"); order.setOutboundOrderNo("OUT-301"); order.setCurrentStatus("WAIT_OUTBOUND"); order.setVersion(3); + when(tailOrders.selectList(any())).thenReturn(List.of(order)); when(tailOrders.lockById("TENANT-A", 901L)).thenReturn(order); + var previous = new LastlegOrderStatusHistory(); previous.setFromStatus("INBOUND_COMPLETED"); + when(histories.selectOne(any())).thenReturn(previous); + when(tailOrders.update(org.mockito.ArgumentMatchers.isNull(), any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(1); + String longReason = "取消运输".repeat(200); + service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel(longReason)); + var request = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest.class); + verify(feeSources).voidSource(org.mockito.ArgumentMatchers.eq(701L), org.mockito.ArgumentMatchers.eq("OP-1"), request.capture()); + assertEquals(1, request.getValue().expectedVersion()); + assertEquals("CANCELLED", delivery.getStatus()); assertEquals("CANCELLED", outbound.getStatus()); + var restored = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper.class); + verify(tailOrders).update(org.mockito.ArgumentMatchers.isNull(), restored.capture()); + assertTrue(restored.getValue().getSqlSet().contains("outbound_order_no=")); + assertTrue(restored.getValue().getSqlSet().contains("delivery_order_no=")); + assertTrue(restored.getValue().getParamNameValuePairs().containsValue("INBOUND_COMPLETED")); + assertTrue(restored.getValue().getParamNameValuePairs().containsValue(null)); + var operation = ArgumentCaptor.forClass(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class); + verify(deliveryOperations, org.mockito.Mockito.times(3)).insert(operation.capture()); + assertTrue(operation.getAllValues().stream().allMatch(row -> row.getResult().length() <= 500)); + assertEquals(longReason, operation.getAllValues().stream().filter(row -> "CANCEL_OUTBOUND_REASON".equals(row.getOperationNode())) + .map(row -> row.getResult().replaceFirst("^\\(\\d+\\) ", "")).collect(java.util.stream.Collectors.joining())); + verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(702L), any(), any()); + verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(703L), any(), any()); + var ordered = org.mockito.Mockito.inOrder(outboundOrders, dispatchOrders, fleetBindings); + ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(dispatchOrders).lockById("TENANT-A", 101L); + ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); ordered.verify(fleetBindings).requireUnbound(101L); + service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("重试")); + verify(feeSources, org.mockito.Mockito.times(1)).voidSource(org.mockito.ArgumentMatchers.anyLong(), any(), any()); + } + @Test void dispatchPickingCannotBeCancelledWhileOutboundSnapshotStillLooksUnexecuted() { + var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key"); + var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PICKING"); dispatch.setPickedPackageCount(1); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + assertThrows(ResponseStatusException.class, () -> service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("取消"))); + org.mockito.Mockito.verifyNoInteractions(feeSources, outboundPallets, deliveryOrders); + } + private static com.baiyue.lastleg.financefee.entity.OverseasFeeSource cancellationFee(long id, String type, String direction) { + var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); fee.setId(id); fee.setSourceFeeId(id); fee.setSourceVersion(1); + fee.setFeeItemCode(type); fee.setDirection(direction); fee.setSourceStatus("ACTIVE"); fee.setReviewStatus("APPROVED"); return fee; + } + @Test void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() { for (long i = 1; i <= 2; i++) { @@ -967,6 +1125,96 @@ class DeliveryTransferServiceTest { .getStatusCode().value()); } + @Test void externalFreightPublishesOnlyTheConfirmedQuoteOnItsOwnDestination() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12), + item(202L, 502L, "PLT-502", "LAX9", "LA", "WT-2", 8))); + var q1 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", new BigDecimal("60.00"), new BigDecimal("2"), "托位", new BigDecimal("30.00")); + var q2 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-2", 4, "WT-2", "IN-2", "客户甲", "FBA:LAX9", "CNY", BigDecimal.ZERO, BigDecimal.ONE, "单", BigDecimal.ZERO); + when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) + .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("external-hash", false, List.of(q1, q2), List.of("IN-1", "IN-2"))); + var request = externalRequest("external-hash", List.of()); + service.platformTransfer("DSP-101", "OP-1", request); + var deliveries = ArgumentCaptor.forClass(DeliveryOrder.class); + verify(deliveryOrders, org.mockito.Mockito.times(2)).insert(deliveries.capture()); + var fee = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class); + verify(feeSources).publishApproved(org.mockito.ArgumentMatchers.eq("OP-1"), org.mockito.ArgumentMatchers.eq("FLEET:FB-1:DSP-101:FREIGHT"), fee.capture()); + verify(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1", "IN-2")); + assertEquals(new BigDecimal("60.00"), fee.getValue().amount()); + assertEquals("IN-1", fee.getValue().inboundCode()); + assertEquals(deliveries.getAllValues().stream().filter(d -> "ONT8".equals(d.getDestinationWarehouseCode())).findFirst().orElseThrow().getId(), fee.getValue().businessId()); + var outbound = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders).insert(outbound.capture()); + when(outboundOrders.findByIdempotency("TENANT-A", "external-1")).thenReturn(outbound.getValue()); + when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(deliveries.getAllValues()); + assertTrue(service.platformTransfer("DSP-101", "OP-OTHER", request).replayed()); + org.mockito.Mockito.verifyNoMoreInteractions(feeSources); + } + + @Test void externalFreightRejectsStalePreviewAndDuplicateDeliveryChargeBeforeWriting() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12))); + var q = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", BigDecimal.TEN, BigDecimal.ONE, "单", BigDecimal.TEN); + when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) + .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("current-hash", false, List.of(q), List.of("IN-1"))); + assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("old-hash", List.of()))); + assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("current-hash", List.of(receivableDeliveryFee())))); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + org.mockito.Mockito.verifyNoInteractions(feeSources, deliveryOrders); + } + @Test void rebookingAfterCancellationCannotChargeAgainBeforeTheOriginalReceivableIsVoided() { + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "WT-1", 12))); + when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) + .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("cancelled-source", true, List.of(), List.of("IN-1"))); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "原运费待财务作废")) + .when(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1")); + assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-NEW", transferRequest("new-trip", null))); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + verify(feeSources, never()).publishApproved(any(), any(), any()); + } + @Test void confirmedOriginalFeeCoverageSavesEvidenceWithoutPublishingAFabricatedNewFee() throws Exception { + var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + var input = json.valueToTree(externalRequest(null, List.of())); + ((com.fasterxml.jackson.databind.node.ObjectNode) input).set("feeCoverage", json.valueToTree(List.of( + new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE"))))); + ((com.fasterxml.jackson.databind.node.ObjectNode) input).put("feeCoverageCargoHash", "cargo-hash"); + var request = json.treeToValue(input, DeliveryRequests.Transfer.class); + var evidence = new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence("cargo-hash", List.of(), List.of(), "OP-1", java.time.Instant.parse("2026-10-03T00:00:00Z")); + when(feeCoverage.confirm(org.mockito.ArgumentMatchers.eq("DSP-101"), org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.eq("OP-1"), any())) + .thenReturn(new DeliveryFeeCoverageService.Decision(Set.of("DELIVERY_FEE"), evidence, Set.of())); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "IN-A", 12))); + service.platformTransfer("DSP-101", "OP-1", request); + var outbound = ArgumentCaptor.forClass(OutboundOrder.class); verify(outboundOrders).insert(outbound.capture()); + assertEquals("cargo-hash", json.readTree(outbound.getValue().getFeeCoverageJson()).get("cargoHash").asText()); + org.mockito.Mockito.verifyNoInteractions(feeSources); + var delivery = ArgumentCaptor.forClass(DeliveryOrder.class); verify(deliveryOrders).insert(delivery.capture()); + when(outboundOrders.findByIdempotency("TENANT-A", request.idempotencyKey())).thenReturn(outbound.getValue()); + when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(List.of(delivery.getValue())); + assertTrue(service.platformTransfer("DSP-101", "OP-1", request).replayed()); + verify(feeCoverage, org.mockito.Mockito.times(1)).confirm(any(), any(), any(), any()); + } + @Test void emptyCoverageRepresentationsDoNotCreateFalseIdempotencyConflicts() { + var original = transferRequest("key", null); + var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode body = json.valueToTree(original); + body.putArray("feeCoverage"); body.put("feeCoverageCargoHash", "unused-hash"); + var empty = json.convertValue(body, DeliveryRequests.Transfer.class); + assertEquals(service.transferRequestHash(original), service.transferRequestHash(empty)); + } + @Test void merchantCannotDeclareOriginalFeeCoverageForItself() { + var request = new com.fasterxml.jackson.databind.ObjectMapper().convertValue(Map.of("feeCoverage", List.of( + new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE")))), DeliveryRequests.Transfer.class); + var error = assertThrows(ResponseStatusException.class, () -> service.merchantTransfer("TENANT-A", "DSP-101", "OP-1", request)); + assertEquals(403, error.getStatusCode().value()); org.mockito.Mockito.verifyNoInteractions(dispatchOrders, feeCoverage, feeSources); + } + private static DeliveryRequests.Transfer externalRequest(String hash, List fees) { + return new DeliveryRequests.Transfer("external-1", LocalDateTime.of(2026,10,3,12,0), "TRUCK_PALLET", null, null, + "WAREHOUSE_PICKUP", "PALLET", null, null, null, null, null, null, null, null, fees, hash, null, null); + } + private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) { DispatchOrder value = new DispatchOrder(); value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); @@ -977,7 +1225,7 @@ class DeliveryTransferServiceTest { private static DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) { return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null, "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark, - List.of(receivableDeliveryFee())); + List.of(receivableDeliveryFee()), null, null, null); } private static DeliveryRequests.FeeItem receivableDeliveryFee() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index a3c404eb0..57c9f8b26 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { @@ -131,7 +132,7 @@ class DispatchCandidateMapperContractTest { } @Test - void candidateGrossWeightIsAllocatedByPalletAndFallsBackToVolumeWeight() throws Exception { + void candidateGrossWeightUsesPhysicalSourcesAndNeverBillingVolumeWeight() throws Exception { for (Method method : List.of( DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class), @@ -142,7 +143,7 @@ class DispatchCandidateMapperContractTest { assertTrue(sql.contains("ll_inbound_order inbound")); assertTrue(sql.contains("a.allocated_volume_cbm")); assertTrue(sql.contains("a2.line_id=a.line_id")); - assertTrue(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg")); + assertFalse(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java new file mode 100644 index 000000000..a25e8bfcd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.dispatch; + +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.math.BigDecimal; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DispatchPhysicalWeightTest { + @Test void billingVolumeWeightCannotFillMissingPhysicalWeight() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR(64),order_id BIGINT,gross_weight_kg DECIMAL(12,3))"); + jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)"); + jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)"); + int tested = 0; + for (var method : DispatchCandidateMapper.class.getDeclaredMethods()) { + var annotation = method.getAnnotation(Select.class); + if (annotation == null) continue; + String sql = String.join(" ", annotation.value()).replace(">", ">").replace("<", "<"); + int from = sql.indexOf("AS volumeWeightKg,"); + int to = sql.indexOf(" AS grossWeightKg,"); + if (from < 0 || to < 0) continue; + String projection = sql.substring(from + "AS volumeWeightKg,".length(), to); + String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id) ip " + + "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm) l " + + "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm) a " + + "CROSS JOIN (SELECT 999 AS volume_weight_kg) o"; + assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " missing gross weight").isNull(); + if (projection.trim().equals("NULL")) continue; // Relabel candidates have no measured pallet weight source yet. + jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,40)"); + assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " physical weight allocation").isEqualByComparingTo("20"); + String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm", "SELECT 1 AS order_id,10 AS volume_cbm UNION ALL SELECT 2,10"); + assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " incomplete aggregate").isNull(); + jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)"); + assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40"); + jdbc.update("DELETE FROM ll_order_detail"); + tested++; + } + assertThat(tested).isEqualTo(2); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 57ec44595..15a472cba 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -32,7 +32,8 @@ class SmartDispatchOpenApiContractTest { assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchCandidate")); assertTrue(yaml.contains("overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码")); - assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重")); + assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重")); + assertTrue(yaml.contains("禁止使用体积重替代")); String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"), yaml.indexOf(" SmartDispatchDraftPallet:")); String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index c7945e1e8..984c27160 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -40,6 +40,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -57,12 +59,14 @@ class DispatchPdaWorkflowServiceTest { @Mock InventoryPositionMapper positions; @Mock InventoryCommandService inventory; @Mock DispatchPickOrderAttributionService attribution; + @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; private DispatchPdaWorkflowService service; private ObjectMapper objectMapper; @BeforeEach void setUp() { service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); objectMapper = new ObjectMapper(); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); @@ -167,6 +171,7 @@ class DispatchPdaWorkflowServiceTest { DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request); assertEquals(2, result.packages()); + verify(fleetBindings, never()).requirePickingApproved(anyLong()); verify(inventory, never()).allocate(any(), any(), any()); verify(picks, never()).insert((DispatchPdaPick) any()); verify(items, never()).bindReceivedPallets(any(), any()); @@ -231,12 +236,36 @@ class DispatchPdaWorkflowServiceTest { when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null))); + () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now())); assertEquals(409, exception.getStatusCode().value()); verify(outboundOrders, never()).updateById((OutboundOrder) any()); } + @Test void pendingDispatchReviewBlocksPickingAndCompletionBeforeInventoryChanges() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING")); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货")) + .when(fleetBindings).requirePickingApproved(1L); + assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null))); + assertThrows(ResponseStatusException.class, () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("pending-complete"))); + org.mockito.Mockito.verifyNoInteractions(inventory, picks, attribution); + verify(orders, never()).updateById((DispatchOrder) any()); + } + + @Test void boundDispatchCannotDepartBeforeTripCoordination() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); order.setVehiclePlate("CA-100"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "按车次统一发车")) + .when(fleetBindings).requireCoordinatedDeparture(1L); + assertThrows(ResponseStatusException.class, () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now())); + org.mockito.Mockito.verifyNoInteractions(attribution, inventory, outboundOrders); + verify(orders, never()).updateById((DispatchOrder) any()); + } + @Test void successfulDeparturePersistsBusinessDepartureTime() { DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); @@ -262,7 +291,7 @@ class DispatchPdaWorkflowServiceTest { when(palletLocks.selectList(any())).thenReturn(List.of()); DispatchPdaResponses.Departed result = service.depart( - "WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null)); + "WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()); assertEquals("DEPARTED", result.status()); assertNotNull(order.getDepartAt()); @@ -284,7 +313,7 @@ class DispatchPdaWorkflowServiceTest { when(orders.lockById("100", 1L)).thenReturn(order); when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted)); - DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request); + DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request, java.time.LocalDateTime.now()); assertEquals("DEPARTED", result.status()); assertTrue(result.replayed()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index b0c28e5e6..63e740ef4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -43,6 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -59,11 +60,13 @@ class DispatchServiceTest { @Mock DispatchPdaTaskMapper pdaTasks; @Mock LastlegOrderMapper tailOrders; @Mock OutboundOrderMapper outboundOrders; + @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; private DispatchService service; @BeforeEach void setUp() { service = new DispatchService(); + ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "bolLines", bolLines); @@ -383,9 +386,22 @@ class DispatchServiceTest { assertEquals("ONT8,ABE4", result.route()); assertEquals("ONT8,ABE4", dispatch.getRoute()); verify(orders).updateById(dispatch); + verify(fleetBindings).requireUnbound(1L); verify(items).findByDispatchOrder("100", 1L); } + @Test void boundDispatchCannotEditRemoveOrCancelIndependently() { + DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setStatus("PENDING_DEPARTURE"); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先解除车次关联")) + .when(fleetBindings).requireUnbound(1L); + assertThrows(ResponseStatusException.class, () -> service.update("PC1", null)); + assertThrows(ResponseStatusException.class, () -> service.removeItem("PC1", 2L)); + assertThrows(ResponseStatusException.class, () -> service.cancel("PC1")); + verify(orders, never()).updateById((DispatchOrder) any()); + org.mockito.Mockito.verifyNoInteractions(items, locks, outboundOrders); + } + @Test void platformEditRejectsAnExecutedDispatchWithoutWriting() { DispatchOrder dispatch = new DispatchOrder(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationContractTest.java new file mode 100644 index 000000000..c15edc857 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.financefee; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import static org.assertj.core.api.Assertions.*; + +class OverseasFeeCancellationContractTest { + @Test void changedContractsHaveNoDuplicateKeysAndSeparateTheRequestFromFinancialConfirmation() throws Exception { + var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); + var yaml = new Yaml(new SafeConstructor(options)); + for (var name : new String[]{"overseas-business-v1.yaml", "overseas-finance-fees-v1.yaml"}) { + String source = Files.readString(Path.of("src/main/resources/openapi", name)); + Map parsed = yaml.load(source); + assertThat(parsed).containsKeys("paths", "components"); + assertThat(source).contains("PENDING_FINANCE", "FINANCE_ATTENTION", "CONFIRMED"); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java new file mode 100644 index 000000000..75e059060 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.lastleg.financefee; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class OverseasFeeCancellationPersistenceTest { + JdbcTemplate jdbc; + LastlegEventOutboxMapper events; + OverseasFeeSourceMapper fees; + OverseasFeeCancellation cancellation; + OverseasFeeOutboxRepository repository; + EventDeliveryJournal journal; + OverseasFeeSource source; + LastlegEventOutbox request; + final LocalDateTime now = LocalDateTime.of(2026, 10, 3, 1, 0); + @BeforeEach void setup() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(200) UNIQUE,payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32))"); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status) VALUES(10,701,1,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE')"); + var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); config.addMapper(OverseasFeeSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet(); + var session = new SqlSessionTemplate(factory.getObject()); + events = session.getMapper(LastlegEventOutboxMapper.class); fees = session.getMapper(OverseasFeeSourceMapper.class); + var json = new ObjectMapper().findAndRegisterModules(); + cancellation = new OverseasFeeCancellation(events, fees, json); + repository = new OverseasFeeOutboxRepository(events, cancellation); + journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + source = fees.lockCurrent(701); + var producer = new OverseasFeeOutboxService(); + ReflectionTestUtils.setField(producer, "mapper", events); ReflectionTestUtils.setField(producer, "objectMapper", json); + producer.publish(source, "PUBLISHED"); + var copy = new OverseasFeeSource(); org.springframework.beans.BeanUtils.copyProperties(source, copy); copy.setSourceStatus("VOIDED"); + producer.publish(copy, "VOIDED"); + request = events.findByEventKey(OverseasFeeCancellation.eventKey(source, "VOIDED")); + } + @Test void waitsForOriginalPublicationAndDoesNotCallPendingARequestAVoid() { + assertThat(cancellation.prerequisitePublished(request)).isFalse(); + assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("PENDING_FINANCE"); + jdbc.update("UPDATE ll_event_outbox SET status='DEAD' WHERE event_key=?", OverseasFeeCancellation.eventKey(source, "PUBLISHED")); + assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("FINANCE_ATTENTION"); + assertThat(cancellation.prerequisitePublished(request)).isFalse(); + publishedOriginal(); assertThat(cancellation.prerequisitePublished(request)).isTrue(); + assertThat(status()).isEqualTo("ACTIVE"); + } + @Test void waitingCancellationsCannotStarveLaterReadyPublications() { + for (int i = 0; i < 60; i++) jdbc.update("INSERT INTO ll_event_outbox(id,event_type,event_key,status,created_at) VALUES(?,? ,?,'PENDING',?)", + 1000 + i, request.getEventType(), "BusinessFeeSourceChanged:LASTLEG:OVERSEAS_DELIVERY_FEE:" + (800 + i) + ":1:VOIDED", now.minusDays(1)); + var ready = repository.pending(50, now.plusDays(1), now.minusSeconds(60)); + assertThat(ready).hasSize(1); + assertThat(ready.get(0).getEventKey()).isEqualTo(OverseasFeeCancellation.eventKey(source, "PUBLISHED")); + } + @Test void journalFailureRollsBackBothLocalVoidAndOutboxCompletionThenSameAckCanCommit() { + publishedOriginal(); claim(); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> complete(Outcome.APPLIED)).isInstanceOf(RuntimeException.class); + assertThat(status()).isEqualTo("ACTIVE"); + assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PROCESSING"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(complete(Outcome.DUPLICATE)).isTrue(); + assertThat(status()).isEqualTo("VOIDED"); + assertThat(cancellation.describe(List.of(fees.lockCurrent(701))).get(0).getVoidStatus()).isEqualTo("CONFIRMED"); + assertThat(complete(Outcome.DUPLICATE)).isFalse(); + } + @Test void rejectedOrUnknownConsumerOutcomeKeepsTheFinancialSourceActive() { + publishedOriginal(); claim(); + assertThat(journal.complete("worker", Outcome.DEAD, now, "PERMANENT_FAILURE", null, + () -> repository.dead(request.getId(), "worker", 1, "来源已进入有效账单"))).isTrue(); + assertThat(status()).isEqualTo("ACTIVE"); + assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("FINANCE_ATTENTION"); + assertThat(repository.published(request.getId(), "stale-worker", now)).isZero(); + assertThat(status()).isEqualTo("ACTIVE"); + } + @Test void wrongSourceVersionCannotAcknowledgeOrCompleteTheEvent() { + publishedOriginal(); claim(); + jdbc.update("UPDATE ll_finance_fee_source SET source_version=2 WHERE id=10"); + assertThatThrownBy(() -> complete(Outcome.APPLIED)).hasMessageContaining("当前业务版本不一致"); + assertThat(status()).isEqualTo("ACTIVE"); + assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PROCESSING"); + } + @Test void aNewShipmentWaitsForThePreviousActualFreightToBeVoidedAndUsesTheLatestVersion() { + var sourceService = new com.baiyue.lastleg.financefee.service.OverseasFeeSourceService(fees, + org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper.class), + org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.service.OverseasFeeBusinessGuard.class), + org.mockito.Mockito.mock(OverseasFeeOutboxService.class), + org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter.class), + new ObjectMapper().findAndRegisterModules(), cancellation); + jdbc.update("UPDATE ll_finance_fee_source SET business_type='DELIVERY_ORDER',direction='RECEIVABLE',fee_item_code='DELIVERY_FEE',inbound_code='IN-1',review_status='APPROVED' WHERE id=10"); + assertThatThrownBy(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).hasMessageContaining("尚未确认作废"); + assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10002", List.of("IN-1"))).doesNotThrowAnyException(); + assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-OTHER"))).doesNotThrowAnyException(); + jdbc.update("UPDATE ll_finance_fee_source SET source_status='SUPERSEDED' WHERE id=10"); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status,business_type,direction,fee_item_code,inbound_code,review_status) VALUES(11,701,2,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE','DELIVERY_ORDER','RECEIVABLE','DELIVERY_FEE','IN-1','PENDING')"); + assertThatThrownBy(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).hasMessageContaining("尚未确认作废"); + jdbc.update("UPDATE ll_finance_fee_source SET source_status='VOIDED' WHERE id=11"); + assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).doesNotThrowAnyException(); + } + void publishedOriginal() { jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED' WHERE event_key=?", OverseasFeeCancellation.eventKey(source, "PUBLISHED")); } + void claim() { assertThat(journal.begin("worker", "lastleg-service", request.getEventType(), String.valueOf(request.getId()), null, "finance", now, + () -> repository.claim(request.getId(), "worker", now))).isTrue(); } + boolean complete(Outcome outcome) { return journal.complete("worker", outcome, now, null, null, () -> repository.published(request.getId(), "worker", now)); } + String status() { return jdbc.queryForObject("SELECT source_status FROM ll_finance_fee_source WHERE id=10", String.class); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java index af63f701c..ab4f8abec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java @@ -19,6 +19,7 @@ import static org.mockito.Mockito.*; class OverseasFeeOutboxRetryTest { @Test void failedPayloadAtLimitGoesDeadAndExhaustedLeaseNeverCallsClient() { var repository = mock(OverseasFeeOutboxRepository.class); + when(repository.prerequisitePublished(any())).thenReturn(true); var client = mock(DirectEventClient.class); var relay = new OverseasFeeOutboxRelay(); var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java index 59c3f2e4c..4492db620 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; @@ -25,7 +26,7 @@ class OverseasContainerFeeSourceServiceTest { private final OverseasFeeSourceService service = new OverseasFeeSourceService( mapper, commands, guard, outbox, new OverseasFeeCurrencyConverter(mock(MonthlyExchangeRateMapper.class)), - new ObjectMapper().findAndRegisterModules()); + new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class)); @Test void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCancellationRequestTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCancellationRequestTest.java new file mode 100644 index 000000000..20ba70019 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCancellationRequestTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.server.ResponseStatusException; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OverseasFeeCancellationRequestTest { + final OverseasFeeSourceMapper fees = mock(OverseasFeeSourceMapper.class); + final OverseasFeeCommandMapper commands = mock(OverseasFeeCommandMapper.class); + final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class); + final OverseasFeeCancellation cancellation = mock(OverseasFeeCancellation.class); + final OverseasFeeSourceService service = new OverseasFeeSourceService(fees, commands, + mock(OverseasFeeBusinessGuard.class), outbox, mock(OverseasFeeCurrencyConverter.class), + new ObjectMapper().findAndRegisterModules(), cancellation); + @Test void requestPreservesActiveSourceAndPublishesASeparateVoidSnapshot() { + var fee = approved(); when(fees.lockCurrent(701)).thenReturn(fee); + var result = service.voidSource(701, "operator", new OverseasFeeSourceDtos.VoidRequest(1, "取消未履行运输", "cancel-1")); + assertThat(result.getSourceStatus()).isEqualTo("ACTIVE"); + assertThat(result.getVoidStatus()).isEqualTo("PENDING_FINANCE"); + var emitted = ArgumentCaptor.forClass(OverseasFeeSource.class); + verify(outbox).publish(emitted.capture(), eq("VOIDED")); + assertThat(emitted.getValue()).isNotSameAs(result); + assertThat(emitted.getValue().getSourceStatus()).isEqualTo("VOIDED"); + verify(fees).updateById(result); verify(commands).insert(any(com.baiyue.lastleg.financefee.entity.OverseasFeeCommand.class)); + } + @Test void aPendingVoidCannotBeReplacedByAnAdjustment() { + var fee = approved(); when(fees.lockCurrent(701)).thenReturn(fee); when(cancellation.requested(fee)).thenReturn(true); + assertThatThrownBy(() -> service.adjust(701, "operator", new OverseasFeeSourceDtos.AdjustRequest(1, null, "adjust-1"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("已申请作废"); + verifyNoInteractions(outbox); verify(fees, never()).updateById(any(OverseasFeeSource.class)); + } + static OverseasFeeSource approved() { + var fee = new OverseasFeeSource(); fee.setId(10L); fee.setSourceFeeId(701L); fee.setSourceVersion(1); + fee.setCustomerId("10001"); fee.setSourceType("OVERSEAS_DELIVERY_FEE"); fee.setSourceStatus("ACTIVE"); + fee.setReviewStatus("APPROVED"); fee.setPublicationStatus("PUBLISHED"); return fee; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectoryTest.java new file mode 100644 index 000000000..8546f95e2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectoryTest.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.fleet.customer; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class FleetCustomerDirectoryTest { + @Test void exactActiveCustomerAndCurrentEligibilityAreRequired() { + var client=mock(FleetCustomerClient.class); var directory=new FleetCustomerDirectory(client,"internal-test-token"); + when(client.customers("internal-test-token",1,30,null,"ACTIVE",9L)).thenReturn(ApiResponse.success(new PageResult<>(List.of(new FleetCustomerClient.Customer(9L,"00009","客户甲","ACTIVE",null,null)),1,30,1,1))); + when(client.eligibility("internal-test-token",9L)).thenReturn(ApiResponse.success(new FleetCustomerClient.Eligibility(true,"ACTIVE",null))); + assertThat(directory.required(9).code()).isEqualTo("00009"); + when(client.eligibility("internal-test-token",9L)).thenReturn(ApiResponse.success(new FleetCustomerClient.Eligibility(false,"PENDING","待审核"))); + assertThatThrownBy(()->directory.required(9)).hasMessageContaining("下单资格"); + } + @Test void failedDirectoryDoesNotInventCustomerOrPermission() { + var client=mock(FleetCustomerClient.class); var directory=new FleetCustomerDirectory(client,"internal-test-token"); + when(client.customers(anyString(),anyLong(),anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("network")); + assertThatThrownBy(()->directory.required(9)).hasMessageContaining("暂不可用"); + verify(client,never()).eligibility(anyString(),anyLong()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicyTest.java new file mode 100644 index 000000000..6d48f0448 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicyTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.fleet.domain; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import static com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.*; +import static org.assertj.core.api.Assertions.assertThat; + +class DriverQualificationPolicyTest { + private final Instant now = Instant.parse("2026-10-02T16:00:00Z"); + private final ZoneId zone = ZoneId.of("America/Los_Angeles"); + + private Driver driver(LocalDate expiry) { return new Driver(true, ReviewStatus.APPROVED, true, expiry, zone); } + + @Test void expiringWithinThirtyDaysAllowsTripEndingBeforeExpiry() { + var result = evaluate(driver(LocalDate.of(2026, 10, 5)), now, now.plusSeconds(3600)); + assertThat(result.canDispatch()).isTrue(); + assertThat(result.expiringSoon()).isTrue(); + } + + @Test void thirtyDayWindowIncludesBoundaryButNotThirtyOneDays() { + assertThat(evaluate(driver(LocalDate.of(2026, 11, 1)), now, now.plusSeconds(60)).expiringSoon()).isTrue(); + assertThat(evaluate(driver(LocalDate.of(2026, 11, 2)), now, now.plusSeconds(60)).expiringSoon()).isFalse(); + } + + @Test void usesLicenceZoneAndIncludesEntireExpiryDate() { + var d = driver(LocalDate.of(2026, 10, 2)); + assertThat(evaluate(d, now, Instant.parse("2026-10-03T06:59:59Z")).canDispatch()).isTrue(); + assertThat(evaluate(d, now, Instant.parse("2026-10-03T07:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP); + } + + @Test void daylightSavingBoundaryUsesCalendarDayInsteadOfFixedHours() { + var d = driver(LocalDate.of(2026, 11, 1)); + Instant start = Instant.parse("2026-11-01T07:00:00Z"); + assertThat(evaluate(d, start, Instant.parse("2026-11-02T07:59:59Z")).canDispatch()).isTrue(); + assertThat(evaluate(d, start, Instant.parse("2026-11-02T08:00:00Z")).canDispatch()).isFalse(); + } + + @Test void rejectsExpiredAndUnknownDatesOrEndTimes() { + assertThat(evaluate(driver(LocalDate.of(2026, 10, 1)), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.EXPIRED); + assertThat(evaluate(driver(null), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_EXPIRY); + assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, null).reason()).isEqualTo(Reason.MISSING_TRIP_END); + assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, now).reason()).isEqualTo(Reason.INVALID_TRIP_END); + } + + @Test void reviewAndDocumentGatesCannotBeBypassedWithValidDate() { + LocalDate expiry = LocalDate.of(2027, 1, 1); + assertThat(evaluate(new Driver(false, ReviewStatus.APPROVED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.INACTIVE); + assertThat(evaluate(new Driver(true, ReviewStatus.APPROVED, false, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_LICENSE); + assertThat(evaluate(new Driver(true, ReviewStatus.PENDING, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.PENDING_REVIEW); + assertThat(evaluate(new Driver(true, ReviewStatus.REJECTED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.REJECTED); + } + + @Test void delayedTripIsReevaluatedRatherThanReusingAnEarlierApproval() { + var d = driver(LocalDate.of(2026, 10, 5)); + assertThat(evaluate(d, now, now.plusSeconds(3600)).canDispatch()).isTrue(); + assertThat(evaluate(d, now, Instant.parse("2026-10-06T09:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java new file mode 100644 index 000000000..12aeb0f9b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.fleet.domain; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.*; +import static org.assertj.core.api.Assertions.*; + +class TripCapacityPolicyTest { + private Load load(int pallets, String kg, String cbm) { return new Load(pallets, new BigDecimal(kg), new BigDecimal(cbm)); } + private final Load capacity = new Load(30, new BigDecimal("18000"), new BigDecimal("70")); + + @Test void combinesOwnedAndExternalCargoWithinAllCapacityLimits() { + var result = calculate(2, capacity, List.of(new Booking("OWN-1", 0, 1, load(20, "10000", "40")), + new Booking("EXT-1", 0, 1, load(10, "8000", "30")))); + assertThat(result.get(0).remaining()).isEqualTo(load(0, "0", "0")); + } + + @Test void releasesUnloadedCargoBeforeNextPickupAtSameStop() { + var result = calculate(3, capacity, List.of(new Booking("A", 0, 1, load(30, "18000", "70")), + new Booking("B", 1, 2, load(30, "18000", "70")))); + assertThat(result).hasSize(2); + assertThat(result).allSatisfy(segment -> assertThat(segment.occupied().palletSpaces()).isEqualTo(30)); + } + + @Test void rejectsOverlappingSegmentsEvenWhenSomeStopsHaveSpareCapacity() { + assertThatThrownBy(() -> calculate(4, capacity, List.of(new Booking("A", 0, 2, load(20, "8000", "20")), + new Booking("B", 1, 3, load(15, "5000", "20"))))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("第2段"); + } + + @Test void rejectsOverweightOrOversizeEvenWithFreePalletSpaces() { + assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "18001", "1"))))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "1", "70.01"))))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test void rejectsDuplicateBookingAndStopsOutsideRoute() { + var booking = new Booking("A", 0, 1, load(1, "10", "1")); + assertThatThrownBy(() -> calculate(2, capacity, List.of(booking, booking))).hasMessageContaining("重复"); + assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("B", 0, 2, load(1, "10", "1"))))) + .hasMessageContaining("不属于"); + } + + @Test void refusesUnknownCapacityAndMissingCargoMeasurements() { + assertThatThrownBy(() -> calculate(2, load(30, "0", "70"), List.of())).hasMessageContaining("大于零"); + assertThatThrownBy(() -> new Booking("A", 0, 1, load(1, "0", "1"))).hasMessageContaining("有效"); + assertThatThrownBy(() -> new Booking("A", 1, 1, load(1, "10", "1"))).hasMessageContaining("之后"); + assertThatThrownBy(() -> load(1, "-1", "1")).hasMessageContaining("非负"); + } + + @Test void emptyTripRetainsCapacityOnEachSegment() { + assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicyTest.java new file mode 100644 index 000000000..88ce1813c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicyTest.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.fleet.domain; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy.*; +import static org.assertj.core.api.Assertions.*; + +class TripCostAllocationPolicyTest { + @Test void allocatesTwentyOwnedAndTenExternalPalletsWithoutCreatingRevenue() { + assertThat(allocate(new BigDecimal("600.00"), "USD", List.of(new Share("OWN-1", 20), new Share("EXT-1", 10)))) + .containsExactly(new Allocation("EXT-1", new BigDecimal("200.00")), new Allocation("OWN-1", new BigDecimal("400.00"))); + } + + @Test void roundingConservesTotalAndIsIndependentOfRequestOrder() { + var first = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("C", 1), new Share("A", 1), new Share("B", 1))); + var second = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("B", 1), new Share("C", 1), new Share("A", 1))); + assertThat(first).isEqualTo(second); + assertThat(first).containsExactly(new Allocation("A", new BigDecimal("3.34")), + new Allocation("B", new BigDecimal("3.33")), new Allocation("C", new BigDecimal("3.33"))); + assertThat(first.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("10.00"); + } + + @Test void singleCentAndZeroCostRemainExact() { + var shares = List.of(new Share("A", 1), new Share("B", 2)); + assertThat(allocate(new BigDecimal("0.01"), "CNY", shares)) + .containsExactly(new Allocation("A", new BigDecimal("0.00")), new Allocation("B", new BigDecimal("0.01"))); + assertThat(allocate(BigDecimal.ZERO, "CNY", shares)).allSatisfy(a -> assertThat(a.amount()).isZero()); + } + + @Test void rejectsUnmeasuredDuplicatedAndInvalidCostInputs() { + var shares = List.of(new Share("A", 1)); + assertThatThrownBy(() -> allocate(new BigDecimal("1.001"), "USD", shares)).hasMessageContaining("两位"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE, "EUR", shares)).hasMessageContaining("币种"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE, null, shares)).hasMessageContaining("币种"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE.negate(), "USD", shares)).hasMessageContaining("负数"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of())).hasMessageContaining("没有"); + assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of(new Share("A", 1), new Share("A", 2)))).hasMessageContaining("重复"); + } + + @Test void handlesLargeValuesWithoutIntegerOverflow() { + var amount = new BigDecimal("99999999999999.99"); + var result = allocate(amount, "USD", List.of(new Share("A", Long.MAX_VALUE), new Share("B", Long.MAX_VALUE))); + assertThat(result.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(amount); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverApiContractTest.java new file mode 100644 index 000000000..c41414ae7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverApiContractTest.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.yaml.snakeyaml.Yaml; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; + +class DriverApiContractTest { + @Test void everyControllerOperationHasOpenApiAndRegisteredPermission() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/fleet-drivers-v1.yaml")); + Map document = new Yaml().load(yaml); + Map paths = (Map) document.get("paths"); + String seeds = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + String base = PlatformDriverController.class.getAnnotation(RequestMapping.class).value()[0]; + int operationCount = 0; + for (var method : PlatformDriverController.class.getDeclaredMethods()) { + String verb = null; + String[] relative = null; + if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; relative = method.getAnnotation(GetMapping.class).value(); } + if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; relative = method.getAnnotation(PostMapping.class).value(); } + if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; relative = method.getAnnotation(PutMapping.class).value(); } + if (method.isAnnotationPresent(PatchMapping.class)) { verb = "patch"; relative = method.getAnnotation(PatchMapping.class).value(); } + if (verb == null) continue; + operationCount++; + String path = base + (relative.length == 0 ? "" : relative[0]); + assertThat(paths.containsKey(path)).as(path).isTrue(); + Map operation = (Map) ((Map) paths.get(path)).get(verb); + assertThat(operation).as(verb + " " + path).isNotNull(); + String expression = method.getAnnotation(PreAuthorize.class).value(); + String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\'')); + assertThat(operation.get("description").toString()).contains(permission); + assertThat(seeds).contains("'" + permission + "'"); + assertThat(unified).contains("'" + permission + "'"); + } + assertThat(operationCount).isEqualTo(12); + verifyRefs(document, document); + } + + @Test void driverTablesMatchDeploymentBaselineAndKeepUniqueHistoryVersions() throws Exception { + String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) { + var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;"); + var a = pattern.matcher(module); var b = pattern.matcher(deployed); + assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue(); + assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n")); + } + assertThat(deployed).contains("UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version)") + .contains("FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)"); + } + + private static void verifyRefs(Object node, Map document) { + if (node instanceof Map map) { + if (map.get("$ref") instanceof String ref) { + assertThat(ref).startsWith("#/"); + Object value = document; + for (String key : ref.substring(2).split("/")) value = ((Map) value).get(key); + assertThat(value).as(ref).isNotNull(); + } + map.values().forEach(value -> verifyRefs(value, document)); + } else if (node instanceof Iterable values) values.forEach(value -> verifyRefs(value, document)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java new file mode 100644 index 000000000..57dcffe8a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DriverAuthorizationTest { + @AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); } + + @Test void listPermissionReadsDetailsButCannotModifyOrApprove() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformDriverController.class); + var service = context.getBean(DriverService.class); + authenticate("platform:overseas-master-data:driver:list"); + controller.detail(71, null); + verify(service).detail(71, null); + assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.update(null, 71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.review(null, 71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.status(null, 71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.submit(null, 71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.upload(null, 71, 0, null)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } + } + + @Test void unrelatedPermissionCannotReadSensitiveDriverDetailsOrAttachments() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformDriverController.class); + authenticate("platform:overseas-master-data:vehicle:list"); + assertThatThrownBy(() -> controller.detail(71, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.history(71, 1, 20)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.download(71, 81)).isInstanceOf(AccessDeniedException.class); + verifyNoInteractions(context.getBean(DriverService.class)); + } + } + + private static void authenticate(String permission) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", + List.of(new SimpleGrantedAuthority(permission)))); + } + + @Configuration + @EnableMethodSecurity + static class Config { + @Bean DriverService service() { return mock(DriverService.class); } + @Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java new file mode 100644 index 000000000..71f13d920 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java @@ -0,0 +1,130 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class DriverPersistenceTest { + private JdbcTemplate jdbc; + private DriverService service; + + @BeforeEach void createDatabaseAndTransactionalService() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) { + var match = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;", Pattern.MULTILINE).matcher(schema); + assertThat(match.find()).isTrue(); + // H2 JSON encodes bound strings differently; production schema is checked separately against MySQL baseline. + jdbc.execute(match.group().replace("snapshot_json JSON", "snapshot_json LONGTEXT")); + } + var configuration = new MybatisConfiguration(); + var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); + configuration.addInterceptor(pagination); + configuration.addMapper(DriverMapper.class); + configuration.addMapper(DriverHistoryMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(configuration); + var session = new SqlSessionTemplate(factory.getObject()); + var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), + mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()), + Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class)); + var proxy = new ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (DriverService) proxy.getProxy(); + } + + @Test void historyFailureRollsBackDriverStatusAndVersion() { + var driver = service.create("operator", draft("初始备注")); + jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')"); + assertThatThrownBy(() -> service.changeStatus(Long.parseLong(driver.id()), "e", new DriverModels.ChangeStatus(0L, false, "暂停"))) + .isInstanceOf(RuntimeException.class); + var stored = service.detail(Long.parseLong(driver.id()), null); + assertThat(stored.active()).isTrue(); + assertThat(stored.recordVersion()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(1); + } + + @Test void clearingOptionalFieldsPersistsAndHistoryRemainsImmutable() { + var driver = service.create("operator", draft("原始备注")); + long id = Long.parseLong(driver.id()); + service.update(id, "e", new DriverModels.Update(0L, draft(null))); + assertThat(service.detail(id, null).details().remark()).isNull(); + assertThat(service.history(id, 1, 20).records()).hasSize(2); + assertThat(service.history(id, 1, 20).records().get(1).snapshot().details().remark()).isEqualTo("原始备注"); + } + + @Test void concurrentSameVersionWritesProduceOneWinnerAndOneConflict() throws Exception { + var driver = service.create("operator", draft(null)); + long id = Long.parseLong(driver.id()); + var executor = Executors.newFixedThreadPool(2); + var start = new CountDownLatch(1); + Callable operation = () -> { + start.await(5, TimeUnit.SECONDS); + try { service.changeStatus(id, "e", new DriverModels.ChangeStatus(0L, false, "暂停")); return true; } + catch (org.springframework.web.server.ResponseStatusException e) { + assertThat(e.getStatusCode().value()).isEqualTo(409); + return false; + } + }; + try { + var first = executor.submit(operation); + var second = executor.submit(operation); + start.countDown(); + assertThat(java.util.List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + assertThat(service.detail(id, null).recordVersion()).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(2); + } finally { executor.shutdownNow(); } + } + + @Test void summaryAndExpiryFilteringUseLicenseZoneAcrossPages() { + var eastern = new DriverModels.Draft("东侧日期", "123", "OWNED", "OVERSEAS", null, null, null, + java.time.LocalDate.of(2026, 10, 2), "Pacific/Kiritimati", null, null); + var western = new DriverModels.Draft("西侧日期", "123", "DISPATCHED", "OVERSEAS", null, null, null, + java.time.LocalDate.of(2026, 10, 2), "America/Los_Angeles", null, null); + service.create("operator", eastern); + service.create("operator", western); + service.create("operator", draft(null)); + var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null)); + assertThat(all.total()).isEqualTo(3); + assertThat(all.records()).hasSize(1); + assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1)); + var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null)); + assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期"); + assertThat(expired.summary().expired()).isEqualTo(1); + var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null)); + assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING"); + var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null)); + assertThat(missing.total()).isEqualTo(1); + assertThat(missing.summary().expiringSoon()).isZero(); + } + + private static DriverModels.Draft draft(String remark) { + return new DriverModels.Draft("测试司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", remark, null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverServiceTest.java new file mode 100644 index 000000000..7ecb74abb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverServiceTest.java @@ -0,0 +1,196 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Reason; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.time.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DriverServiceTest { + private final DriverMapper drivers = mock(DriverMapper.class); + private final DriverHistoryMapper history = mock(DriverHistoryMapper.class); + private final DriverFileGateway files = mock(DriverFileGateway.class); + private final Clock clock = Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC); + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private final FleetWarehouseDirectory warehouses = mock(FleetWarehouseDirectory.class); + private final DriverService service = new DriverService(drivers, history, files, json, clock, warehouses); + + @BeforeEach void writesSucceed() { + when(drivers.insert(any(DriverEntity.class))).thenReturn(1); + when(drivers.update(any(DriverEntity.class), any())).thenReturn(1); + when(history.insert(any(DriverHistoryEntity.class))).thenReturn(1); + } + + @Test void draftCanBeSavedWithoutLicenseButCannotDispatchOrBeApproved() throws Exception { + var response = service.create("operator", draft(null)); + assertThat(response.driverNo()).startsWith("DR-").isNotEqualTo(response.id()); + assertThat(response.qualification().reason()).isEqualTo(Reason.MISSING_LICENSE); + assertThat(response.reviewStatus()).isEqualTo("PENDING"); + var captured = ArgumentCaptor.forClass(DriverEntity.class); + verify(drivers).insert(captured.capture()); + when(drivers.lockById(captured.getValue().getId())).thenReturn(captured.getValue()); + assertThatThrownBy(() -> service.review(captured.getValue().getId(), "reviewer", new DriverModels.Review(0L, "APPROVED", null))) + .hasMessageContaining("补齐驾驶证"); + var event = ArgumentCaptor.forClass(DriverHistoryEntity.class); + verify(history).insert(event.capture()); + assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).driverNo()).isEqualTo(response.driverNo()); + verifyNoInteractions(files); + } + + @Test void approvedDriverMayDepartWithReminderOnlyWhenTaskEndsBeforeExpiry() { + var row = row("APPROVED"); + when(drivers.lockById(71)).thenReturn(row); + var response = service.requireDispatchable(71, Instant.parse("2026-10-20T12:00:00Z")); + assertThat(response.qualification().canDispatch()).isTrue(); + assertThat(response.qualification().expiringSoon()).isTrue(); + verify(files).metadata(71, 81); + assertThatThrownBy(() -> service.requireDispatchable(71, Instant.parse("2026-10-23T00:00:00Z"))) + .hasMessageContaining("预计任务结束前到期"); + assertThatThrownBy(() -> service.requireDispatchable(71, null)).hasMessageContaining("预计任务结束时间"); + } + + @Test void approvalVerifiesAttachmentAndPersistsReviewerAndSnapshot() throws Exception { + when(drivers.lockById(71)).thenReturn(row("PENDING")); + var response = service.review(71, "reviewer", new DriverModels.Review(2L, "APPROVED", "已核对")); + assertThat(response.reviewStatus()).isEqualTo("APPROVED"); + assertThat(response.recordVersion()).isEqualTo(3); + verify(files).metadata(71, 81); + var event = ArgumentCaptor.forClass(DriverHistoryEntity.class); + verify(history).insert(event.capture()); + assertThat(event.getValue().getOperatorId()).isEqualTo("reviewer"); + assertThat(event.getValue().getReason()).isEqualTo("已核对"); + assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).reviewStatus()).isEqualTo("APPROVED"); + } + + @Test void failedFileVerificationCannotWriteApproval() { + when(drivers.lockById(71)).thenReturn(row("PENDING")); + when(files.metadata(71, 81)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "错误文件归属")); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("错误文件归属"); + verify(drivers, never()).update(any(DriverEntity.class), any()); + verify(history, never()).insert(any(DriverHistoryEntity.class)); + } + + @Test void rejectsExpiredAndNotYetIssuedLicenseApproval() { + var row = row("PENDING"); + row.setLicenseExpiresOn(LocalDate.of(2026, 10, 1)); + when(drivers.lockById(71)).thenReturn(row); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("已过期"); + row.setLicenseExpiresOn(LocalDate.of(2027, 10, 1)); + row.setLicenseIssuedOn(LocalDate.of(2026, 10, 3)); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("尚未生效"); + verifyNoInteractions(files); + } + + @Test void rejectRequiresReasonAndCanBeExplicitlyResubmitted() { + var row = row("PENDING"); + when(drivers.lockById(71)).thenReturn(row); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "REJECTED", " "))).hasMessageContaining("打回原因"); + assertThat(service.review(71, "r", new DriverModels.Review(2L, "REJECTED", "附件不清楚")).reviewStatus()).isEqualTo("REJECTED"); + assertThat(service.submit(71, "editor", new DriverModels.Submit(3L)).reviewStatus()).isEqualTo("PENDING"); + verify(files).metadata(71, 81); + } + + @Test void credentialEditResetsApprovalButPhoneCorrectionDoesNot() { + var row = row("APPROVED"); + when(drivers.lockById(71)).thenReturn(row); + var phoneOnly = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C", + null, row.getLicenseExpiresOn(), "UTC", null, null); + assertThat(service.update(71, "e", new DriverModels.Update(2L, phoneOnly)).reviewStatus()).isEqualTo("APPROVED"); + var changedExpiry = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C", + null, LocalDate.of(2027, 10, 22), "UTC", null, null); + assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING"); + } + + @Test void replacingLicenseBindsNewFileAndResetsApproval() { + when(drivers.lockById(71)).thenReturn(row("APPROVED")); + var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes()); + when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9)); + var response = service.uploadLicense(71, 2, "e", file); + assertThat(response.licenseFileId()).isEqualTo("82"); + assertThat(response.reviewStatus()).isEqualTo("PENDING"); + var event = ArgumentCaptor.forClass(DriverHistoryEntity.class); + verify(history).insert(event.capture()); + assertThat(event.getValue().getLicenseFileId()).isEqualTo(82); + } + + @Test void staleVersionFailsBeforeUploadOrReviewAndCasFailureHasNoHistory() { + when(drivers.lockById(71)).thenReturn(row("PENDING")); + assertThatThrownBy(() -> service.uploadLicense(71, 1, "e", null)).hasMessageContaining("已被修改"); + assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(1L, "APPROVED", null))).hasMessageContaining("已被修改"); + verifyNoInteractions(files); + when(drivers.update(any(DriverEntity.class), any())).thenReturn(0); + assertThatThrownBy(() -> service.changeStatus(71, "e", new DriverModels.ChangeStatus(2L, false, "暂停驾驶"))).hasMessageContaining("已被修改"); + verify(history, never()).insert(any(DriverHistoryEntity.class)); + } + + @Test void unlinkedFileCannotBeReadEvenIfItExistsInFileService() { + when(drivers.selectById(71L)).thenReturn(row("APPROVED")); + when(history.selectCount(any())).thenReturn(0L); + assertThatThrownBy(() -> service.download(71, 99)).hasMessageContaining("未关联"); + verifyNoInteractions(files); + } + + @Test void historicalLinkedFileIsReadableWithExactDriverScope() { + when(drivers.selectById(71L)).thenReturn(row("APPROVED")); + when(history.selectCount(any())).thenReturn(1L); + when(files.metadata(71, 80)).thenReturn(new DriverModels.File("80", "old.pdf", "application/pdf", 8)); + when(files.download(71, 80)).thenReturn(new byte[]{1, 2}); + assertThat(service.download(71, 80).content()).containsExactly(1, 2); + } + + @Test void invalidTimeZoneAndReversedDatesCannotBeSaved() { + var badZone = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "wrong-zone", null, null); + assertThatThrownBy(() -> service.create("e", badZone)).hasMessageContaining("时区"); + var badDates = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, + LocalDate.of(2027, 1, 1), LocalDate.of(2026, 1, 1), "UTC", null, null); + assertThatThrownBy(() -> service.create("e", badDates)).hasMessageContaining("不能早于"); + verify(drivers, never()).insert(any(DriverEntity.class)); + } + + @Test void optionalProfileUsesAuthoritativeWarehouseSnapshotAndDoesNotInventQualificationRequirements() { + when(warehouses.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS")); + var profile = new DriverModels.Profile(92L, "53尺拖车", "中文 / 洛杉矶", LocalDate.of(2020, 1, 1), null, null); + var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", "LIC-1", "C", null, + LocalDate.of(2026, 10, 22), "UTC", null, profile); + var response = service.create("e", details); + assertThat(response.warehouseCode()).isEqualTo("WH-US"); + assertThat(response.warehouseName()).isEqualTo("公共海外仓"); + assertThat(response.details().profile().medicalExpiresOn()).isEqualTo(LocalDate.of(2020, 1, 1)); + var captured = ArgumentCaptor.forClass(DriverEntity.class); + verify(drivers).insert(captured.capture()); + var row = captured.getValue(); row.setLicenseFileId(81L); + when(drivers.lockById(row.getId())).thenReturn(row); + assertThat(service.review(row.getId(), "r", new DriverModels.Review(0L, "APPROVED", null)).reviewStatus()).isEqualTo("APPROVED"); + } + + @Test void invalidWarehouseCannotBePersistedAsFreeText() { + when(warehouses.required(92, "OVERSEAS")).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "仓库已停用")); + var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", null, + new DriverModels.Profile(92L, null, null, null, null, null)); + assertThatThrownBy(() -> service.create("e", details)).hasMessageContaining("仓库已停用"); + verify(drivers, never()).insert(any(DriverEntity.class)); + } + + private static DriverModels.Draft draft(LocalDate expiry) { + return new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, expiry, "UTC", null, null); + } + private static DriverEntity row(String review) { + var row = new DriverEntity(); + row.setId(71L); row.setDriverNo("DR-TEST"); row.setName("司机"); row.setPhone("123"); + row.setOwnership("OWNED"); row.setRegion("OVERSEAS"); row.setActive(true); row.setReviewStatus(review); + row.setLicenseNumber("LIC-1"); row.setLicenseType("C"); row.setLicenseExpiresOn(LocalDate.of(2026, 10, 22)); + row.setLicenseZone("UTC"); row.setLicenseFileId(81L); row.setRecordVersion(2L); + row.setUpdatedAt(LocalDateTime.of(2026, 10, 1, 0, 0)); row.setUpdatedBy("editor"); + return row; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectoryTest.java new file mode 100644 index 000000000..b29ec3ff3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectoryTest.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetWarehouseDirectoryTest { + private final FleetWarehouseClient client = mock(FleetWarehouseClient.class); + private final FleetWarehouseDirectory directory = new FleetWarehouseDirectory(client, "test-token"); + + @Test void onlyActiveMatchingOperatingWarehousesCanBeSelected() { + when(client.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of( + new FleetWarehouseClient.Warehouse(1L, "CN", "国内仓", "DOMESTIC", "CN"), + new FleetWarehouseClient.Warehouse(2L, "US", "海外仓", "OVERSEAS", "US"), + new FleetWarehouseClient.Warehouse(3L, "FBA", "目的仓", "FBA", "US"), + new FleetWarehouseClient.Warehouse(4L, "PARTNER", "合作仓", "OVERSEAS_PARTNER", "US")))); + assertThat(directory.options("OVERSEAS")).extracting(DriverModels.WarehouseOption::id).containsExactly("2", "4"); + assertThat(directory.required(1, "DOMESTIC").name()).isEqualTo("国内仓"); + assertThatThrownBy(() -> directory.required(1, "OVERSEAS")).hasMessageContaining("区域不符"); + assertThatThrownBy(() -> directory.required(3, "OVERSEAS")).hasMessageContaining("不存在"); + assertThatThrownBy(() -> directory.required(99, "OVERSEAS")).hasMessageContaining("不存在"); + } + + @Test void failureOrInvalidRegionNeverProducesInventedWarehouseOptions() { + when(client.activeWarehouses("test-token")).thenThrow(new IllegalStateException("unavailable")); + assertThatThrownBy(() -> directory.options("OVERSEAS")).hasMessageContaining("暂不可用"); + assertThatThrownBy(() -> directory.options("unknown")).hasMessageContaining("区域"); + verify(client, times(1)).activeWarehouses("test-token"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGatewayTest.java new file mode 100644 index 000000000..bd14ed6ad --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGatewayTest.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class HttpDriverFileGatewayTest { + @Test void acceptsOnlyMatchingPlatformDriverLicenseMetadata() { + assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId()) + .isEqualTo("81"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(5L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "DELIVERY_BOL", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "72", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 82L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于"); + } + @Test void rejectsMissingOversizeOrUnsafeMetadataAndFailedEnvelope() { + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "text/html", 12L), 71, 81L)).hasMessageContaining("不是有效"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 0L), 71, 81L)).hasMessageContaining("不是有效"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 10485761L), 71, 81L)).hasMessageContaining("不是有效"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(null, 71, 81L)).hasMessageContaining("不可用"); + assertThatThrownBy(() -> HttpDriverFileGateway.verified(new HttpDriverFileGateway.Envelope("A0400", null), 71, 81L)).hasMessageContaining("不可用"); + } + private static HttpDriverFileGateway.Envelope envelope(Long owner, String type, String driver, Long id, String contentType, Long size) { + return new HttpDriverFileGateway.Envelope("200", new HttpDriverFileGateway.Metadata(id, owner, type, driver, "license.pdf", contentType, size)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGatewayTest.java new file mode 100644 index 000000000..d91bc1650 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGatewayTest.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class HttpVehicleCareFileGatewayTest { + @Test void aSuccessfulHttpEnvelopeCannotBeReturnedAsImageBytes() { + var json = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes()); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.imageContent(json)).hasMessageContaining("不可用"); + var image = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[]{1, 2, 3}); + assertThat(HttpVehicleCareFileGateway.imageContent(image)).containsExactly(1, 2, 3); + } + @Test void metadataMustMatchPlatformRecordCategoryAndFileIdentity() { + assertThat(HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L).fileId()).isEqualTo("81"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(9, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_DRIVER_LICENSE", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "72", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 82, "image/png"), 71, 81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "text/html"), 71, 81L)).hasMessageContaining("有效照片"); + } + private HttpVehicleCareFileGateway.Envelope envelope(long owner, String type, String record, long file, String mime) { + return new HttpVehicleCareFileGateway.Envelope("200", new HttpVehicleCareFileGateway.Metadata(file, owner, type, record, "meter.png", mime, 8L)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareApiContractTest.java new file mode 100644 index 000000000..2d1f6ef2c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareApiContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.*; +import java.util.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; + +class VehicleCareApiContractTest { + @Test void operationsPermissionsAndSchemasMatchTheirPublishedContracts() throws Exception { + Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-vehicle-records-v1.yaml"))); + Map paths = (Map)document.get("paths"); + String permissionSql = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); + String initialization = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + String base = PlatformVehicleCareController.class.getAnnotation(RequestMapping.class).value()[0]; + int count = 0; + for (var method : PlatformVehicleCareController.class.getDeclaredMethods()) { + String verb = null; String[] suffix = null; + if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); } + if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); } + if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); } + if (verb == null) continue; + String path = base + (suffix.length == 0 ? "" : suffix[0]); + assertThat(paths.containsKey(path)).as(path).isTrue(); + Map operation = (Map)((Map)paths.get(path)).get(verb); + String expression = method.getAnnotation(PreAuthorize.class).value(); + String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\'')); + assertThat(operation.get("description").toString()).contains(permission); + assertThat(permissionSql).contains("'" + permission + "'"); assertThat(initialization).contains("'" + permission + "'"); count++; + } + assertThat(count).isEqualTo(7); + for (String table : List.of("ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) { + var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;"); + var module = pattern.matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + var deployed = pattern.matcher(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))); + assertThat(module.find()).isTrue(); assertThat(deployed.find()).isTrue(); + assertThat(module.group().replace("\r\n", "\n")).isEqualTo(deployed.group().replace("\r\n", "\n")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java new file mode 100644 index 000000000..8fdde5eab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCareAuthorizationTest { + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + @Test void vehicleListReadsRecordsButDoesNotAuthorizeCreateEditOrPhotoUpload() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformVehicleCareController.class); + var service = context.getBean(VehicleCareService.class); + authenticate("platform:overseas-master-data:vehicle:list"); + controller.detail(1, "VR-1"); verify(service).detail(1, "VR-1"); + assertThatThrownBy(() -> controller.create(1, null, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.update(1, "VR-1", null, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.upload(1, "VR-1", null, 0, "key", null)).isInstanceOf(AccessDeniedException.class); + authenticate("platform:overseas-master-data:driver:list"); + assertThatThrownBy(() -> controller.detail(1, "VR-1")).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.download(1, "VR-1", 1)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } + } + private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); } + @Configuration @EnableMethodSecurity static class Config { + @Bean VehicleCareService service() { return mock(VehicleCareService.class); } + @Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java new file mode 100644 index 000000000..026801b22 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java @@ -0,0 +1,166 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class VehicleCarePersistenceTest { + private JdbcTemplate jdbc; + private VehicleCareService service; + private VehicleCareFileGateway files; + private long vehicleA, vehicleB; + private final Instant occurred = Instant.parse("2026-01-01T00:00:00Z"); + @BeforeEach void setup() throws Exception { + var source = new DriverManagerDataSource("jdbc:h2:mem:care-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(source); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String table : List.of("ll_vehicle", "ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) { + var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); + assertThat(match.find()).as(table).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT")); + } + var config = new MybatisConfiguration(); + config.addMapper(VehicleMapper.class); config.addMapper(VehicleCareMapper.class); config.addMapper(VehicleCareHistoryMapper.class); + var interceptor = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); + config.addInterceptor(interceptor); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config); + var session = new SqlSessionTemplate(factory.getObject()); + var vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class)); + vehicleA = vehicles.create(vehicle("VEH-A")); vehicleB = vehicles.create(vehicle("VEH-B")); + files = mock(VehicleCareFileGateway.class); + when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8)); + var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files, + new ObjectMapper().registerModule(new JavaTimeModule())); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); + service = (VehicleCareService) proxy.getProxy(); + } + @Test void recordsAreScopedToVehicleAndNeverRewriteMasterOdometerOrCreateAnotherRecordOnRetry() { + var request = new VehicleCareModels.Create("key", maintenance()); + var a = service.create(vehicleA, "1", request); + assertThat(service.create(vehicleA, "1", request).recordNo()).isEqualTo(a.recordNo()); + var b = service.create(vehicleB, "1", request); + assertThat(b.recordNo()).isNotEqualTo(a.recordNo()); + assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, "MAINTENANCE")).records()).extracting(VehicleCareModels.Response::recordNo).containsExactly(a.recordNo()); + assertThatThrownBy(() -> service.detail(vehicleB, a.recordNo())).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.update(vehicleB, a.recordNo(), "1", new VehicleCareModels.Update(0L, "错误车辆", maintenance()))).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.download(vehicleB, a.recordNo(), 9001)).hasMessageContaining("不存在"); + assertThat(jdbc.queryForList("SELECT mileage_km FROM ll_vehicle", Long.class)).containsOnly(1000L); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(2); + } + @Test void correctionKeepsImmutableHistoryAndRejectsStaleVersionOrChangedType() { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", maintenance())); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()))).hasMessageContaining("其他运维记录"); + assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "变更类型", fuel()))).hasMessageContaining("不能改变"); + service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "补充核对", maintenance())); + assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "旧页面", maintenance()))).hasMessageContaining("已变化"); + var history = service.history(vehicleA, row.recordNo(), 1, 20).records(); + assertThat(history).extracting(VehicleCareModels.History::action).containsExactly("UPDATE", "CREATE"); + assertThat(history.get(1).snapshot().updatedBy()).isEqualTo("1"); + } + @Test void failedHistoryWriteRollsBackUpdate() { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel())); + jdbc.execute("ALTER TABLE ll_fleet_vehicle_record_history ADD CONSTRAINT fail_update CHECK(action <> 'UPDATE')"); + assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "更正", fuel()))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(vehicleA, row.recordNo()).recordVersion()).isZero(); + assertThat(service.detail(vehicleA, row.recordNo()).updatedBy()).isEqualTo("1"); + } + @Test void uploadRetryReturnsOriginalAssociationAndRejectsAnotherImageOrForeignRecord() { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel())); + var image = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1,2,3}); + var uploaded = service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image); + assertThat(uploaded.files()).hasSize(1); + assertThat(service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image).recordVersion()).isEqualTo(1); + verify(files, times(1)).upload(anyLong(), eq("1"), any()); + assertThatThrownBy(() -> service.upload(vehicleA, row.recordNo(), 1, "upload-key", "1", + new MockMultipartFile("file", "other.png", "image/png", new byte[]{4}))).hasMessageContaining("不同图片"); + assertThatThrownBy(() -> service.upload(vehicleB, row.recordNo(), 1, "new-key", "1", image)).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.download(vehicleA, row.recordNo(), 9999)).hasMessageContaining("不存在"); + verify(files, never()).metadata(anyLong(), anyLong()); + } + @Test void explicitUnitsAndTypedFieldsAreRequiredButHistoricalOdometersMayBeBelowTheMaster() { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create("fuel", fuel())); + assertThat(row.details().distanceUnit()).isEqualTo("MI"); + assertThat(row.details().fuelUnit()).isEqualTo("US_GAL"); + assertThat(row.details().odometer()).isEqualByComparingTo("800"); + var bad = new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("10"), "GAL", "PENDING", null, null); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("bad", bad))).hasMessageContaining("明确"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1); + } + @Test void concurrentDuplicateCreationCreatesOneBusinessRecordAndOneHistory() throws Exception { + var request = new VehicleCareModels.Create("concurrent", fuel()); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + Callable task = () -> { start.await(5, TimeUnit.SECONDS); return service.create(vehicleA, "1", request).recordNo(); }; + try { + var first = executor.submit(task); var second = executor.submit(task); start.countDown(); + assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS)); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(1); + } finally { executor.shutdownNow(); } + } + @Test void incidentTypesKeepProjectsPlacesAndProcessingFactsIsolatedPerVehicle() { + for (String type : List.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION")) { + var row = service.create(vehicleA, "1", new VehicleCareModels.Create(type, incident(type, "PENDING", null, null, null))); + assertThat(service.detail(vehicleA, row.recordNo()).details().incident()).isEqualTo(row.details().incident()); + assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, type)).records()).hasSize(1); + assertThat(service.page(vehicleB, new VehicleCareModels.Query(1, 20, type)).total()).isZero(); + assertThatThrownBy(() -> service.update(vehicleB, row.recordNo(), "1", new VehicleCareModels.Update(0L, "更新处理", row.details()))).hasMessageContaining("不存在"); + if (List.of("REPAIR", "RESCUE").contains(type)) { + assertThat(row.details().incident().item()).isEqualTo("更换轮胎"); + assertThat(row.details().incident().place()).isEqualTo("实际作业地点"); + assertThat(row.paymentStatus()).isEqualTo("UNLINKED"); + } + } + } + @Test void completedRepairDoesNotInventPaymentOrReleaseAndChangingCostKeepsHistory() { + var created = service.create(vehicleA, "1", new VehicleCareModels.Create("repair", incident("REPAIR", "PENDING", null, null, null))); + var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD"))); + var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD"))); + assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0"); + assertThat(completed.paymentStatus()).isEqualTo("UNLINKED"); + assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE"); + var history = service.history(vehicleA, created.recordNo(), 1, 20).records(); + assertThat(history).hasSize(3); + assertThat(history.get(1).snapshot().details().incident().reportedCost()).isEqualByComparingTo("120.50"); + assertThat(history.get(2).snapshot().details().incident().reportedCost()).isNull(); + assertThatThrownBy(() -> service.update(vehicleA, created.recordNo(), "1", new VehicleCareModels.Update(0L, "旧请求", created.details()))).hasMessageContaining("已变化"); + } + @Test void incidentValidationRejectsMissingHandlersCurrencyAndMixedFuelFields() { + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-handler", incident("RESCUE", "COMPLETED", null, null, null)))).hasMessageContaining("处理人"); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-currency", incident("RESCUE", "PENDING", null, BigDecimal.ZERO, null)))).hasMessageContaining("币种"); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("invalid-currency", incident("RESCUE", "PENDING", null, BigDecimal.ONE, "ZZZ")))).hasMessageContaining("币种无效"); + var detail = incident("EXCEPTION", "PENDING", null, null, null); + var mixed = new VehicleCareModels.Draft(detail.recordType(), occurred, "KM", BigDecimal.ONE, null, null, null, null, null, null, detail.incident()); + assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("mixed", mixed))).hasMessageContaining("不能混入"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isZero(); + } + private VehicleCareModels.Draft incident(String type, String status, String handler, BigDecimal cost, String currency) { + boolean priced = List.of("REPAIR", "RESCUE").contains(type); + return new VehicleCareModels.Draft(type, occurred, null, null, null, null, null, null, null, null, + new VehicleCareModels.Incident("现场轮胎故障及处理情况", priced ? "更换轮胎" : null, priced ? "实际作业地点" : null, + handler, status, "EXCEPTION".equals(type) ? "VEHICLE_BREAKDOWN" : null, cost, currency)); + } + private VehicleCareModels.Draft maintenance() { return new VehicleCareModels.Draft("MAINTENANCE", occurred, "KM", new BigDecimal("800"), new BigDecimal("500"), new BigDecimal("700"), null, null, "PENDING", "已超过计划保养里程", null); } + private VehicleCareModels.Draft fuel() { return new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("28.6"), "US_GAL", "PENDING", null, null); } + private VehicleRepository.NewVehicle vehicle(String plate) { return new VehicleRepository.NewVehicle(plate, "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), 30, new BigDecimal("18000"), new BigDecimal("90")); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java new file mode 100644 index 000000000..1c4992e03 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetBookingFreightTest { + final FleetBookingMapper bookings = mock(FleetBookingMapper.class); + final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class); + final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + final FleetBookingReservations reservations = new FleetBookingReservations(bookings, json); + final FleetBookingFreight service = new FleetBookingFreight(bookings, bindings, reservations, json); + FleetBookingEntity booking; + final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:ONT8"); + @BeforeEach void setup() throws Exception { + booking = new FleetBookingEntity(); + booking.setId(1L); booking.setTripId(2L); booking.setBookingNo("FB-1"); booking.setIncomingNo("WT-1"); + booking.setDispatchNo("DP-1"); booking.setCustomerId("C-1"); booking.setCustomerName("客户甲"); + booking.setDestinationKey("FBA:ONT8"); booking.setStatus("BOUND"); booking.setRecordVersion(4L); + booking.setAgreedAmount(new BigDecimal("60.00")); + booking.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8", "测试货物", 12, 2, + BigDecimal.TEN, BigDecimal.ONE, "PALLET", new BigDecimal("30.00"), "CNY", null, null, null))); + booking.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt( + new FleetBookingIncomingGateway.Evidence("WT-1", "IN-1", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-1", + List.of(new FleetBookingIncomingGateway.Pallet("501", "PLT-501", 12, BigDecimal.TEN, BigDecimal.ONE)), 12), + List.of(new FleetBookingModels.PalletSpaces("501", 2)), "OP-1", Instant.parse("2026-10-01T00:00:00Z")))); + var binding = new FleetTripDispatch(); binding.setTripId(2L); binding.setDispatchId(3L); + when(bindings.findByDispatchNo("DP-1")).thenReturn(binding); + when(bookings.incomingForFreight("WT-1")).thenReturn(booking); + } + @Test void quoteUsesConfirmedActualPriceAndSeparatesOtherCargo() { + var preview = service.preview(3, "DP-1", "C-1", List.of(source)); + assertFalse(preview.requiresDeliveryFee()); assertEquals(new BigDecimal("60.00"), preview.quotes().get(0).amount()); + assertEquals("IN-1", preview.quotes().get(0).inboundCode()); + var other = new FleetDispatchGateway.SourceCargo("IN-OTHER", "502", "PLT-502", 8, "FBA:LAX9"); + var mixed = service.preview(3, "DP-1", "C-1", List.of(source, other)); + assertTrue(mixed.requiresDeliveryFee()); assertEquals(1, mixed.quotes().size()); + assertEquals(mixed.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(other, source)).snapshotHash()); + } + @Test void rejectsUnboundDifferentCustomerOrWrongTrip() { + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-2", List.of(source))); + booking.setStatus("RECEIVED"); + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(source))); + booking.setStatus("BOUND"); booking.setTripId(99L); + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(source))); + } + @Test void rejectsPartialOrChangedDestinationAndDetectsNewQuoteVersion() { + var before = service.preview(3, "DP-1", "C-1", List.of(source)); + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of( + new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 11, "FBA:ONT8")))); + assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of( + new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:LAX9")))); + booking.setRecordVersion(5L); + assertNotEquals(before.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(source)).snapshotHash()); + } + @Test void cancellingTheBookingDoesNotEraseThePreviousFinancialSource() throws Exception { + booking.setStatus("CANCELLED"); booking.setDispatchNo(null); + var preview = service.preview(9, "DP-NEW", "C-1", List.of(source)); + assertTrue(preview.requiresDeliveryFee()); assertTrue(preview.quotes().isEmpty()); + assertEquals(List.of("IN-1"), preview.priorInboundCodes()); + assertFalse(json.writeValueAsString(preview).contains("priorInboundCodes")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java new file mode 100644 index 000000000..a1c84d7e0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; + +class FleetTripApiContractTest { + @Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception { + Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml"))); + Map paths = (Map) document.get("paths"); + String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); + String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + int count = 0; + for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class)) { + String base = controller.getAnnotation(RequestMapping.class).value()[0]; + for (var method : controller.getDeclaredMethods()) { + String verb = null; String[] suffix = null; + if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); } + if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); } + if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); } + if (verb == null) continue; + String path = base + (suffix.length == 0 ? "" : suffix[0]); + assertThat(paths.containsKey(path)).as(path).isTrue(); + Map operation = (Map) ((Map) paths.get(path)).get(verb); + String expression = method.getAnnotation(PreAuthorize.class).value(); + var authorities = Pattern.compile("'([^']+)'").matcher(expression); + int permissionCount=0; + while(authorities.find()) { + String permission=authorities.group(1); + assertThat(operation.get("description").toString()).contains(permission); + assertThat(module).contains("'" + permission + "'"); + assertThat(deployed).contains("'" + permission + "'"); + permissionCount++; + } + assertThat(permissionCount).isPositive(); + count++; + } + } + assertThat(count).isEqualTo(28); + verifyRefs(document, document); + } + + @Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception { + String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for (String table : java.util.List.of("ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", "ll_fleet_trip_dispatch", "ll_fleet_external_booking")) { + var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;"); + var a = pattern.matcher(module); var b = pattern.matcher(deployed); + assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue(); + assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n")); + } + assertThat(deployed).contains("PRIMARY KEY (resource_kind,resource_id)") + .contains("UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key)") + .contains("UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version)"); + } + + private static void verifyRefs(Object node, Map root) { + if (node instanceof Map map) { + if (map.get("$ref") instanceof String reference) { + assertThat(reference).startsWith("#/"); + Object value = root; + for (String key : reference.substring(2).split("/")) value = ((Map) value).get(key); + assertThat(value).as(reference).isNotNull(); + } + map.values().forEach(value -> verifyRefs(value, root)); + } else if (node instanceof Iterable values) values.forEach(value -> verifyRefs(value, root)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java new file mode 100644 index 000000000..014c19c1d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetTripAuthorizationTest { + @Test void listPermissionCannotReserveOrReleaseResources() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list")))); + var controller = context.getBean(PlatformFleetTripController.class); + var service = context.getBean(FleetTripService.class); + controller.detail("TR-TEST"); verify(service).detail("TR-TEST"); + assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.assign(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.reviewLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.complete(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.release(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.confirmCapacityPlan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + var booking = context.getBean(PlatformFleetBookingController.class); + booking.list("TR-TEST"); verify(context.getBean(FleetBookingService.class)).list("TR-TEST"); + assertThatThrownBy(() -> booking.create(null,"TR-TEST",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.update(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.confirm(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.cancel(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.incoming(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.receipt(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> booking.bind(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + @Configuration @EnableMethodSecurity + static class Config { + @Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); } + @Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); } + @Bean PlatformFleetBookingController booking(FleetBookingService service, com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers) { return new PlatformFleetBookingController(service,customers); } + @Bean FleetTripService service() { return mock(FleetTripService.class); } + @Bean FleetTripExecutionService execution() { return mock(FleetTripExecutionService.class); } + @Bean FleetTripCompletionService completion() { return mock(FleetTripCompletionService.class); } + @Bean PlatformFleetTripController controller(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) { return new PlatformFleetTripController(service, execution, completion); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java new file mode 100644 index 000000000..dfc5a42c9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -0,0 +1,866 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.fleet.driver.*; +import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import org.springframework.test.util.ReflectionTestUtils; +import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*; +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.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class FleetTripPersistenceTest { + private JdbcTemplate jdbc; + private FleetTripService service; + private DriverService drivers; + private VehicleRepository vehicles; + private VehicleApplicationService vehicleService; + private DataSourceTransactionManager transactions; + private FleetDispatchBindingGuard bindingGuard; + private FleetTripExecutionService execution; + private FleetTripCompletionService completion; + private FleetBookingService bookingService; + private FleetBookingIncomingGateway incoming; + private Instant departure, end; + + @BeforeEach void initialize() throws Exception { + departure = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.MILLIS); + end = departure.plus(12, ChronoUnit.HOURS); + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); transactions = new DataSourceTransactionManager(dataSource); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", + "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order", + "ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation")) { + var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); + assertThat(match.find()).as(table).isTrue(); + jdbc.execute(match.group().replace(" JSON", " LONGTEXT")); + } + var configuration = new MybatisConfiguration(); + for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class, + FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class, + DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper); + var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); + configuration.addInterceptor(pagination); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration); + var session = new SqlSessionTemplate(factory.getObject()); + var directory = mock(FleetWarehouseDirectory.class); + when(directory.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS")); + var files = mock(DriverFileGateway.class); + when(files.upload(anyLong(), anyString(), any())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(0, Long.class) + 1000), "license.pdf", "application/pdf", 8)); + when(files.metadata(anyLong(), anyLong())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(1, Long.class)), "license.pdf", "application/pdf", 8)); + var json = new ObjectMapper().registerModule(new JavaTimeModule()); + drivers = transactional(new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), files, json, directory), DriverService.class); + vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class)); + var resources = session.getMapper(FleetResourceMapper.class); + vehicleService = transactional(new VehicleApplicationService(vehicles, new FleetVehicleAssignmentGuard(resources)), VehicleApplicationService.class); + var bookingMapper = session.getMapper(FleetBookingMapper.class); + var reservations = new FleetBookingReservations(bookingMapper, json); + var bindingMapper = session.getMapper(FleetTripDispatchMapper.class); + bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class); + var workflow = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class)); + ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class)); + ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class)); + ReflectionTestUtils.setField(workflow, "tasks", session.getMapper(DispatchPdaTaskMapper.class)); + ReflectionTestUtils.setField(workflow, "picks", session.getMapper(DispatchPdaPickMapper.class)); + ReflectionTestUtils.setField(workflow, "operations", session.getMapper(DispatchPdaOperationMapper.class)); + ReflectionTestUtils.setField(workflow, "palletLocks", session.getMapper(DispatchPalletLockMapper.class)); + ReflectionTestUtils.setField(workflow, "fleetBindings", bindingGuard); + ReflectionTestUtils.setField(workflow, "objectMapper", json); + ReflectionTestUtils.setField(workflow, "pickAttribution", mock(DispatchPickOrderAttributionService.class)); + var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class), + session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class), + transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); + service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json, + bindingMapper, dispatchGateway, reservations), FleetTripService.class); + execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class); + var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); + when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer( + Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null)); + incoming=mock(FleetBookingIncomingGateway.class); + bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming), FleetBookingService.class); + completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class); + } + + @Test void creationReplayUsesSameBusinessNumberAndRejectsChangedPayload() { + var request = createRequest("creation-one"); + var first = service.create("1", request); + assertThat(service.create("1", request).trip().tripNo()).isEqualTo(first.trip().tripNo()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1); + assertThatThrownBy(() -> service.create("1", new FleetTripModels.Create(92L, departure, end.plusSeconds(1), request.stops(), null, request.idempotencyKey()))) + .hasMessageContaining("不同车次内容"); + assertThat(service.create("2", request).trip().tripNo()).isNotEqualTo(first.trip().tripNo()); + } + + @Test void assignmentLocksBothResourcesAndCancellationReleasesThemWithoutRemovingHistory() { + long vehicle = vehicle(true), driver = driver(true); + String first = trip(), second = trip(); + var assigned = service.assign(first, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排运输")); + assertThat(assigned.driverQualification().canDispatch()).isTrue(); + assertThat(assigned.trip().maxPalletSpaces()).isEqualTo(30); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重复分配"))).hasMessageContaining("占用"); + assertThat(service.cancel(first, "1", new FleetTripModels.Cancel(1L, "取消安排")).trip().status()).isEqualTo("CANCELLED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); + assertThat(service.history(first, 1, 20).records()).extracting(FleetTripModels.History::action).containsExactly("CANCEL", "ASSIGN", "CREATE"); + assertThat(service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重新安排")).trip().vehicleId()).isEqualTo(Long.toString(vehicle)); + } + + @Test void failedReassignmentKeepsOldResourcesAndRollsBackTheNewVehicleClaim() { + long oldVehicle = vehicle(true), occupiedVehicle = vehicle(true), replacement = vehicle(true); + long oldDriver = driver(true), occupiedDriver = driver(true); + String first = trip(), second = trip(); + service.assign(first, "1", new FleetTripModels.Assign(0L, oldVehicle, oldDriver, "安排")); + service.assign(second, "1", new FleetTripModels.Assign(0L, occupiedVehicle, occupiedDriver, "安排")); + assertThatThrownBy(() -> service.assign(first, "1", new FleetTripModels.Assign(1L, replacement, occupiedDriver, "改派"))).hasMessageContaining("占用"); + var retained = service.detail(first).trip(); + assertThat(retained.vehicleId()).isEqualTo(Long.toString(oldVehicle)); + assertThat(retained.recordVersion()).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, replacement)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(4); + } + + @Test void missingCapacityOrUnapprovedDriverCannotBeAssigned() { + String number = trip(); + long unknownCapacity = vehicle(false), validDriver = driver(true); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, unknownCapacity, validDriver, "安排"))).hasMessageContaining("托位、载重和容积"); + long validVehicle = vehicle(true), missingLicense = driver(false); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, validVehicle, missingLicense, "安排"))).hasMessageContaining("驾驶证"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); + assertThat(service.detail(number).trip().recordVersion()).isZero(); + } + + @Test void extendingPlanPastLicenseExpiryFailsAndKeepsTheOldPlan() { + String number = trip(); + service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排")); + assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(1L, departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期"))) + .hasMessageContaining("预计任务结束前到期"); + assertThat(service.detail(number).trip().expectedDriverEndAt()).isEqualTo(end); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1); + } + + @Test void failedHistoryWriteRollsBackTripAndAllResourceClaims() { + String number = trip(); long vehicle = vehicle(true), driver = driver(true); + jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_assignment CHECK(action <> 'ASSIGN')"); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排"))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(number).trip().vehicleId()).isNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); + assertThat(service.detail(number).trip().recordVersion()).isZero(); + } + + @Test void activeTripPreventsMasterDataFromChangingPlateCapacityOrStatus() { + long vehicle = vehicle(true); String number = trip(); + service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver(true), "安排")); + assertThatThrownBy(() -> vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L))).hasMessageContaining(number); + var original = vehicles.findById(vehicle).orElseThrow(); + assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, "CHANGED", original.model(), original.fuelConsumptionPer100Km(), + original.mileageKm(), null, null, original.region(), original.ownership(), null, 30, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number); + assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, original.plateNumber(), original.model(), original.fuelConsumptionPer100Km(), + original.mileageKm(), null, null, original.region(), original.ownership(), null, 20, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number); + service.cancel(number, "1", new FleetTripModels.Cancel(1L, "取消")); + assertThat(vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L)).status()).isEqualTo("MAINTENANCE"); + } + + @Test void simultaneousVehicleAssignmentHasExactlyOneWinner() throws Exception { + String first = trip(), second = trip(); long vehicle = vehicle(true), driver1 = driver(true), driver2 = driver(true); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> attempt(start, first, vehicle, driver1)); + var b = executor.submit(() -> attempt(start, second, vehicle, driver2)); + start.countDown(); + assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE'", Integer.class)).isEqualTo(1); + } finally { executor.shutdownNow(); } + } + + @Test void concurrentCreationWithTheSameKeyCreatesOneTripAndOneHistory() throws Exception { + var create = createRequest("concurrent-create"); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + Callable command = () -> { start.await(5, TimeUnit.SECONDS); return service.create("1", create).trip().tripNo(); }; + try { + var first = executor.submit(command); var second = executor.submit(command); start.countDown(); + assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS)); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1); + } finally { executor.shutdownNow(); } + } + + @Test void tripCombinesCustomersAndSynchronizesSnapshotsWithoutChangingTheirOwnershipOrArrivalTime() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); + delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入本车")); + var bound = service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入本车")); + assertThat(bound.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A", "CLIENT-B"); + long replacement = vehicle(true); + var reassigned = service.assign(number, "1", new FleetTripModels.Assign(3L, replacement, driver(true), "更换车辆司机")); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(reassigned.trip().vehiclePlate()); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(reassigned.trip().vehiclePlate()); + assertThat(jdbc.queryForList("SELECT customer_id FROM ll_delivery_order ORDER BY id", String.class)).containsExactly("CLIENT-A", "CLIENT-B"); + assertThat(jdbc.queryForList("SELECT version FROM ll_delivery_order", Long.class)).containsOnly(2L); + assertThat(jdbc.queryForObject("SELECT expected_arrival_at FROM ll_delivery_order WHERE id=2011", LocalDateTime.class)).isEqualTo(LocalDateTime.of(2030, 1, 1, 12, 0)); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().dispatches()).hasSize(2); + } + + @Test void unbindClearsCurrentSnapshotsButKeepsHistoryAndRequiresUnpickedCargo() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); + assertThatThrownBy(() -> service.cancel(number, "1", new FleetTripModels.Cancel(2L, "取消"))).hasMessageContaining("先解除"); + var tx = new org.springframework.transaction.support.TransactionTemplate(transactions); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireUnbound(11); return null; })).hasMessageContaining(number); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireCoordinatedDeparture(11); return null; })).hasMessageContaining("统一发车"); + assertThat(execution.gate("92", "DP-11").tripNo()).isEqualTo(number); + var assigned = service.detail(number).trip(); + var actual = new FleetDispatchGateway.Assignment(assigned.vehiclePlate(), assigned.driverName(), assigned.driverPhone()); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual, + new FleetDispatchGateway.Assignment("OTHER-PLATE", null, null)); return null; })).hasMessageContaining("统一维护"); + tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual, new FleetDispatchGateway.Assignment(null, null, null)); return null; }); + jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=11"); + assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下"))).hasMessageContaining("已开始执行"); + jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=0 WHERE id=11"); + service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下")); + assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT driver_name FROM ll_delivery_order WHERE id=2011", String.class)).isNull(); + assertThat(service.history(number, 1, 20).records().get(1).snapshot().dispatches()).hasSize(1); + assertThat(service.detail(number).dispatches()).isEmpty(); + service.cancel(number, "1", new FleetTripModels.Cancel(3L, "取消")); + } + + @Test void wrongWarehouseAndStaleVersionCannotBindOrMutateCustomerDocuments() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "93"); + assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).hasMessageContaining("同一起运仓"); + jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=11"); + assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(0L, "DP-11", "加入"))).hasMessageContaining("已被修改"); + assertThat(service.detail(number).dispatches()).isEmpty(); + assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); + } + + @Test void bindingHistoryFailureRollsBackAssociationAndEveryDownstreamSnapshot() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A"); + jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_binding CHECK(action <> 'BIND_DISPATCH')"); + assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(number).dispatches()).isEmpty(); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1L); + assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_delivery_order WHERE id=2011", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT version FROM ll_delivery_order WHERE id=2011", Long.class)).isZero(); + } + + @Test void failedReassignmentOfOneExecutedMemberRollsBackOtherMembersAndResourceClaims() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); + String original = service.detail(number).trip().vehiclePlate(); + jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=10 WHERE id=12"); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(3L, vehicle(true), driver(true), "改派"))).hasMessageContaining("已开始执行"); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(original); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(3L); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(3L, departure, end.plusSeconds(60), stops(), null, "延后"))) + .hasMessageContaining("已开始执行"); + } + + @Test void concurrentBindingToDifferentTripsHasExactlyOneWinner() throws Exception { + String first = assignedTrip(), second = assignedTrip(); dispatch(11, "CLIENT-A", "92"); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> bindAttempt(start, first)); var b = executor.submit(() -> bindAttempt(start, second)); start.countDown(); + assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='BIND_DISPATCH'", Integer.class)).isEqualTo(1); + } finally { executor.shutdownNow(); } + } + + @Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() { + String number=readyTrip();var basis=execution.capacityPlan(number); + execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); + var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create")); + var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); + var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); + when(incoming.create(eq("1"),any())).thenReturn(awaiting); + var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),null,null,null,null,null,null,"incoming-start"); + var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request); + assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue(); + verify(incoming,times(1)).create(eq("1"),any()); + assertThat(started.booking().status()).isEqualTo("CONFIRMED"); + var actual=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_CUSTOMER_CONFIRMATION","actual-receipt", + List.of(new FleetBookingIncomingGateway.Pallet("902","PALLET-902",22,new BigDecimal("150"),new BigDecimal("1.5"))),22); + when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual); + when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual); + assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成"); + assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00"); + when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages())); + var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt")); + assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22); + assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00"); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); + dispatch(13,"9","92"); + jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')"); + var linked=bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external")); + assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13"); + assertThat(execution.capacityPlan(number).planValid()).isFalse(); + assertThat(service.detail(number).dispatches()).hasSize(3); + service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(linked.tripRecordVersion(),"DP-13","撤销未开工关联")); + assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED"); + assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull(); + } + + @Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() { + String number=readyTrip(); var actual=confirm(number); var basis=execution.capacityPlan(number); + execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); + var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one"); + var draft=bookingService.create(number,"1",create); + assertThat(bookingService.create(number,"1",create).booking().bookingNo()).isEqualTo(draft.booking().bookingNo()); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); + var command=new FleetBookingModels.Command(draft.tripRecordVersion(),draft.booking().recordVersion(),"external-confirm-one",null); + var accepted=bookingService.confirm(number,draft.booking().bookingNo(),"1",command); + assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("500"); + assertThat(bookingService.confirm(number,draft.booking().bookingNo(),"1",command).replayed()).isTrue(); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(19); + assertThat(execution.gate("92","DP-11").blockReason()).contains("外接预订"); + assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad()); + var cancel=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"external-cancel-one",null); + var cancelled=bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel); + assertThat(bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel).tripRecordVersion()).isEqualTo(cancelled.tripRecordVersion()); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); + assertThat(execution.gate("92","DP-11").canDepart()).isTrue(); + assertThat(service.history(number,1,20).records().stream().filter(row->row.action().equals("BOOKING_CONFIRM")).findFirst().orElseThrow() + .snapshot().externalBookings().get(0).status()).isEqualTo("CONFIRMED"); + } + + @Test void secondExternalBookingCannotOversellAndOwnPlanCannotConsumeCommittedExternalSpace() { + String number=readyTrip();var basis=execution.capacityPlan(number); + execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); + var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first")); + bookingService.confirm(number,first.booking().bookingNo(),"1",new FleetBookingModels.Command(first.tripRecordVersion(),0L,"confirm-first",null)); + var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second")); + assertThatThrownBy(()->bookingService.confirm(number,second.booking().bookingNo(),"1",new FleetBookingModels.Command(second.tripRecordVersion(),0L,"confirm-second",null))).hasMessageContaining("超过车辆"); + assertThat(bookingService.list(number).records().get(1).status()).isEqualTo("DRAFT"); + var excessive=new FleetExecutionModels.Measurement("901",11,new BigDecimal("100"),new BigDecimal("1"),1); + assertThatThrownBy(()->execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(second.tripRecordVersion(),basis.manifestHash(),List.of(excessive),"增加自有货计划"))).hasMessageContaining("超过车辆"); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(9); + var changed = service.updatePlan(number,"1",new FleetTripModels.Plan(second.tripRecordVersion(),departure,end.plusSeconds(60),stops(),null,"调整时间")); + assertThat(execution.capacityPlan(number).capacityPlan()).isNull(); + service.assign(number,"1",new FleetTripModels.Assign(changed.trip().recordVersion(),Long.valueOf(changed.trip().vehicleId()),driver(true),"同车换司机")); + assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("CONFIRMED"); + } + private static FleetBookingModels.Cargo externalCargo(int spaces){return new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD",null,null,null);} + + @Test void capacityCanBePlannedBeforePickingWithoutCreatingActualLoadOrApprovals() { + String number = readyTrip(); + jdbc.update("DELETE FROM ll_dispatch_pda_pick"); + jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=0"); + jdbc.update("UPDATE ll_dispatch_pda_task SET status='PDA_PENDING'"); + jdbc.update("UPDATE ll_fleet_trip_dispatch SET review_json=NULL"); + var basis = execution.capacityPlan(number); + assertThat(basis.planValid()).isFalse(); assertThat(basis.blockReason()).isNull(); + var plan = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "计划载荷")); + assertThat(plan.planValid()).isTrue(); + assertThat(plan.capacityPlan().pallets()).hasSize(1); + assertThat(plan.capacityPlan().segments().get(0).remaining().palletSpaces()).isEqualTo(29); + assertThat(execution.loading(number).confirmedLoad()).isNull(); + assertThat(execution.gate("92", "DP-11").canPick()).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick", Integer.class)).isZero(); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().capacityPlan()).isEqualTo(plan.capacityPlan()); + var unchanged = service.updatePlan(number, "1", new FleetTripModels.Plan(plan.recordVersion(), departure, end, stops(), "备注", "补充备注")); + assertThat(execution.capacityPlan(number).planValid()).isTrue(); + service.assign(number, "1", new FleetTripModels.Assign(unchanged.trip().recordVersion(), vehicle(true), driver(true), "更换资源")); + assertThat(execution.capacityPlan(number).capacityPlan()).isNull(); + } + + @Test void capacityPlanRejectsStaleCargoAndOverbookingWithoutAffectingConfirmedActualLoad() { + String number = readyTrip(); var actual = confirm(number); var basis = execution.capacityPlan(number); + var tooHeavy = new FleetExecutionModels.Measurement("901", 1, new BigDecimal("19000"), new BigDecimal("1"), 1); + assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(tooHeavy), "预估"))).hasMessageContaining("超过车辆"); + var planned = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "预估")); + assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad()); + assertThat(service.detail(number).loadingReview().status()).isEqualTo("APPROVED"); + jdbc.update("UPDATE ll_dispatch_item SET package_count=11 WHERE id=3011"); + assertThat(execution.capacityPlan(number).planValid()).isFalse(); + assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(planned.recordVersion(), basis.manifestHash(), List.of(measurement()), "旧页面"))).hasMessageContaining("货物明细已变化"); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(planned.recordVersion()); + } + + @Test void dispatchReviewsAreIndependentAndRejectionBlocksOnlyNewPickingForThatCustomer() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); + var tx = new org.springframework.transaction.support.TransactionTemplate(transactions); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; })).hasMessageContaining("调度审核"); + service.reviewDispatch(number, "DP-11", "1", new FleetTripModels.Review(3L, "APPROVED", null)); + var gates = execution.gates("92", List.of("DP-11", "DP-12")); + assertThat(gates.get("DP-11").canPick()).isTrue(); + assertThat(gates.get("DP-12").canPick()).isFalse(); + tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; }); + assertThatThrownBy(() -> service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", " "))).hasMessageContaining("打回原因"); + service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", "核对预约时间")); + assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(12); return null; })).hasMessageContaining("核对预约时间"); + service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(5L, "APPROVED", null)); + assertThat(service.detail(number).dispatches()).allMatch(row -> "APPROVED".equals(row.review().status())); + } + + @Test void loadingReviewRequiresActualLoadAndRemarkOnlyEditPreservesApprovals() { + String number = readyTrip(); var loading = execution.loading(number); + assertThatThrownBy(() -> execution.reviewLoading(number, "1", new FleetTripModels.Review(loading.recordVersion(), "APPROVED", null))).hasMessageContaining("确认本车实际装载"); + var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "核对装载")); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("理货审核"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "before-review", null))).hasMessageContaining("理货审核"); + var rejected = execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "REJECTED", "重新核对重量")); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("重新核对重量"); + var approved = execution.reviewLoading(number, "1", new FleetTripModels.Review(rejected.trip().recordVersion(), "APPROVED", null)); + var changed = service.updatePlan(number, "1", new FleetTripModels.Plan(approved.trip().recordVersion(), departure, end, stops(), "补充备注", "更新备注")); + assertThat(changed.loadingReview().status()).isEqualTo("APPROVED"); + assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); + execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(changed.trip().recordVersion(), loading.manifestHash(), List.of(measurement()), "重新测量")); + assertThat(service.detail(number).loadingReview().status()).isEqualTo("PENDING"); + assertThat(execution.gate("92", "DP-11").canDepart()).isFalse(); + } + + @Test void actualLoadingCountsSharedPhysicalPalletOnceAndDepartureShipsAllCustomersAtomically() { + String number = readyTrip(); + assertThat(execution.gate("92", "DP-11").canDepart()).isFalse(); + var loading = confirm(number); + assertThat(loading.confirmedLoad().cargo()).hasSize(2); + assertThat(loading.confirmedLoad().segments().get(0).occupied().palletSpaces()).isEqualTo(1); + assertThat(loading.confirmedLoad().segments().get(0).occupied().weightKg()).isEqualByComparingTo("100"); + assertThat(execution.gates("92", List.of("DP-11", "DP-12")).values()).allMatch(FleetExecutionModels.Gate::canDepart); + assertThat(execution.gate("92", "DP-11").driverWarning()).contains("有效期覆盖本次预计任务"); + var request = new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "整车发车"); + var result = execution.depart("92", "DP-11", "1", request); + assertThat(result.shippedPackages()).isEqualTo(20); + assertThat(result.dispatches()).extracting(FleetExecutionModels.DepartedDispatch::dispatchNo).containsExactly("DP-11", "DP-12"); + assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("DEPARTED"); + assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("OUTBOUNDED"); + assertThat(jdbc.queryForObject("SELECT COUNT(DISTINCT depart_at) FROM ll_dispatch_order", Integer.class)).isEqualTo(1); + assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); + assertThat(execution.depart("92", "DP-12", "1", request).replayed()).isTrue(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'", Integer.class)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isEqualTo(1); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "changed"))) + .hasMessageContaining("内容不一致"); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().confirmedLoad().cargo()).hasSize(2); + } + + @Test void reassigningPickedTripPreservesWorkAndRequiresFreshLoadingOnTheReplacement() { + String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); + var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id"); + var tasksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id"); + var arrivals = jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id"); + long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456"); + var changed = service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "发车前换车及司机,重新核对装载")); + assertThat(changed.trip().status()).isEqualTo("PLANNED"); + assertThat(changed.trip().vehicleId()).isEqualTo(Long.toString(replacement)); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(changed.trip().vehiclePlate()); + assertThat(jdbc.queryForList("SELECT driver_name FROM ll_delivery_order", String.class)).containsOnly("替班司机"); + assertThat(jdbc.queryForList("SELECT driver_phone FROM ll_delivery_order", String.class)).containsOnly("456"); + assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); + assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id")).isEqualTo(tasksBefore); + assertThat(jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id")).isEqualTo(arrivals); + assertThat(execution.loading(number).confirmedLoad()).isNull(); + assertThat(changed.dispatches()).allMatch(row -> "PENDING".equals(row.review().status())); + assertThat(changed.loadingReview().status()).isEqualTo("PENDING"); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("调度审核"); + approveDispatches(number); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("确认本车实际装载"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class, + Long.parseLong(original.vehicleId()), Long.parseLong(original.driverId()))).isZero(); + var oldLoad = service.history(number, 1, 20).records().stream().filter(row -> row.action().equals("CONFIRM_LOAD")).findFirst().orElseThrow(); + assertThat(oldLoad.snapshot().trip().vehicleId()).isEqualTo(original.vehicleId()); + assertThat(oldLoad.snapshot().confirmedLoad()).isNotNull(); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "old-before-reassign", null))).hasMessageContaining("已被修改"); + var reconfirmed = confirm(number); + assertThat(execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(reconfirmed.recordVersion(), "after-reassign", null)).shippedPackages()).isEqualTo(20); + assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); + } + + @Test void partialPickingAllowsResourceAndTimeChangesButKeepsRouteAndBindingsFrozen() { + String number = readyTrip(); var original = service.detail(number).trip(); + jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=5 WHERE id=12"); + jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING',completed_at=NULL WHERE dispatch_order_id=12"); + jdbc.update("UPDATE ll_dispatch_pda_pick SET packages=5 WHERE id=12"); + var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id"); + var changed = service.assign(number, "1", new FleetTripModels.Assign(original.recordVersion(), vehicle(true), driver(true), "发车前更换资源")).trip(); + var delayed = service.updatePlan(number, "1", new FleetTripModels.Plan(changed.recordVersion(), departure.plusSeconds(60), end.plusSeconds(60), stops(), null, "延后任务时间")).trip(); + assertThat(delayed.expectedDriverEndAt()).isEqualTo(end.plusSeconds(60)); + assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); + assertThat(jdbc.queryForObject("SELECT status FROM ll_dispatch_pda_task WHERE dispatch_order_id=12", String.class)).isEqualTo("PICKING"); + assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(delayed.recordVersion(), "DP-12", "移走已拣单"))).hasMessageContaining("已开始"); + var differentStops = List.of(stops().get(0), new FleetTripModels.Stop("另一目的地", "新地址", "FBA:LAX9")); + assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(delayed.recordVersion(), departure, end, differentStops, null, "更改路线"))).hasMessageContaining("已开始"); + assertThat(service.detail(number).trip().stops()).isEqualTo(stops()); + assertThat(service.detail(number).dispatches()).hasSize(2); + } + + @Test void replacingConfirmedLoadWithInsufficientCapacityOrExpiredTaskCoverageChangesNothing() { + String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); + long small = vehicle(true); var current = vehicles.findById(small).orElseThrow(); + vehicleService.update(small, "1", new VehicleModels.Update(0L, current.plateNumber(), current.model(), current.fuelConsumptionPer100Km(), current.mileageKm(), null, null, + current.region(), current.ownership(), null, 30, new BigDecimal("50"), new BigDecimal("90"))); + assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), small, driver(true), "容量不足换车"))).hasMessageContaining("已确认装载"); + assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(loaded.recordVersion(), departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期跨过证照到期"))).hasMessageContaining("预计任务结束前到期"); + assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId()); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion()); + assertThat(execution.loading(number).confirmedLoad()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, small)).isZero(); + } + + @Test void reassignmentHistoryFailureRestoresOriginalClaimsSnapshotsAndLoad() { + String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); + long replacement = vehicle(true), replacementDriver = driver(true); + jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_reassignment CHECK(operator_id <> 'reject-operator')"); + assertThatThrownBy(() -> service.assign(number, "reject-operator", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "更换车辆司机"))) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId()); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion()); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(original.vehiclePlate()); + assertThat(execution.loading(number).confirmedLoad()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class, replacement, replacementDriver)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + } + + @Test void concurrentReassignmentAndDepartureCannotMixOldAndNewResources() throws Exception { + String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); + long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456"); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + try { + var assign = executor.submit(() -> { + start.await(5, TimeUnit.SECONDS); + try { + service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "临时改派")); + return true; + } catch (org.springframework.web.server.ResponseStatusException stale) { + assertThat(stale.getStatusCode().value()).isEqualTo(409); return false; + } + }); + var depart = executor.submit(() -> { + start.await(5, TimeUnit.SECONDS); + try { + execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "race-with-reassign", null)); + return true; + } catch (org.springframework.web.server.ResponseStatusException stale) { + assertThat(stale.getStatusCode().value()).isEqualTo(409); return false; + } + }); + start.countDown(); + boolean assigned = assign.get(15, TimeUnit.SECONDS), departed = depart.get(15, TimeUnit.SECONDS); + assertThat(List.of(assigned, departed)).containsExactlyInAnyOrder(true, false); + var result = service.detail(number).trip(); + assertThat(result.vehicleId()).isEqualTo(assigned ? Long.toString(replacement) : original.vehicleId()); + assertThat(result.status()).isEqualTo(departed ? "IN_TRANSIT" : "PLANNED"); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(result.vehiclePlate()); + assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(result.vehiclePlate()); + assertThat(jdbc.queryForObject("SELECT SUM(shipped_package_count) FROM ll_dispatch_order", Integer.class)).isEqualTo(departed ? 20 : 0); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, + Long.parseLong(result.vehicleId()))).isEqualTo(1); + if (assigned) assertThat(execution.loading(number).confirmedLoad()).isNull(); + } finally { executor.shutdownNow(); } + } + + @Test void physicalLoadingRejectsDuplicateMissingOverweightAndWrongDestinationMeasurements() { + String number = readyTrip(); var loading = execution.loading(number); var measurement = measurement(); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement, measurement), "核对"))) + .hasMessageContaining("重复计量"); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(), "核对"))) + .hasMessageContaining("每个实物托盘"); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), + List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("18001"), new BigDecimal("1"), 1)), "核对"))).hasMessageContaining("超过车辆"); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), + List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 2)), "核对"))).hasMessageContaining("卸货站"); + jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12"); + var mixed = execution.loading(number); + assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(mixed.recordVersion(), mixed.manifestHash(), List.of(measurement), "核对"))) + .hasMessageContaining("不同卸货目的地"); + } + + @Test void changedCargoAndUnreadyMemberBlockTheEntireVehicleBeforeAnyDepartureWrite() { + String number = readyTrip(); var confirmed = confirm(number); + jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12"); + assertThat(execution.gate("92", "DP-11").blockReason()).contains("货物发生变化"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "changed-cargo", null))) + .hasMessageContaining("货物发生变化"); + jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='ONT8' WHERE dispatch_order_id=12"); + jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING' WHERE dispatch_order_id=12"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "unready-member", null))) + .hasMessageContaining("DP-12"); + assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART"); + assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); + } + + @Test void secondCustomerFailureRollsBackFirstCustomerOutboundAndAllReplayRecords() { + String number = readyTrip(); var confirmed = confirm(number); + jdbc.execute("ALTER TABLE ll_dispatch_order ADD CONSTRAINT fail_second_depart CHECK(id <> 12 OR status <> 'DEPARTED')"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "rollback-whole-trip", null))) + .isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART"); + assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isZero(); + assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); + assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); + } + + @Test void dispatchDepartureRechecksDriverAndRejectsWrongWarehouseAndStaleVersion() { + String number = readyTrip(); var confirmed = confirm(number); + assertThatThrownBy(() -> execution.depart("93", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "wrong-warehouse", null))) + .hasMessageContaining("当前绑定仓"); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(0L, "stale-version", null))) + .hasMessageContaining("已被修改"); + jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1))); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "expired-driver", null))) + .isInstanceOf(ResponseStatusException.class); + assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); + } + + @Test void simultaneousWholeTripDepartureReplaysOnceWithoutDoubleShipping() throws Exception { + String number = readyTrip(); var confirmed = confirm(number); + var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "concurrent-departure", null); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); + Callable action = () -> { start.await(5, TimeUnit.SECONDS); return execution.depart("92", "DP-11", "1", request).replayed(); }; + try { + var a = executor.submit(action); var b = executor.submit(action); start.countDown(); + assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(false, true); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isEqualTo(2); + } finally { executor.shutdownNow(); } + } + + @Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() { + String number = readyTrip(); var confirmed = confirm(number); + long driverId = Long.parseLong(service.detail(number).trip().driverId()); + var current = drivers.detail(driverId, end); + var original = current.details(); + var domestic = new DriverModels.Draft(original.name(), original.phone(), original.ownership(), "DOMESTIC", + original.licenseNumber(), original.licenseType(), original.licenseIssuedOn(), original.licenseExpiresOn(), + original.licenseZone(), original.remark(), original.profile()); + var changed = drivers.update(driverId, "1", new DriverModels.Update(current.recordVersion(), domestic)); + assertThat(drivers.detail(driverId, end).qualification().canDispatch()).isTrue(); + + var gate = execution.gate("92", "DP-11"); + assertThat(gate.canDepart()).isFalse(); + assertThat(gate.blockReason()).contains("海外司机"); + var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "driver-region-check", null); + assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", request)).hasMessageContaining("海外司机"); + assertThat(service.detail(number).trip().recordVersion()).isEqualTo(confirmed.recordVersion()); + assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); + assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); + + drivers.update(driverId, "1", new DriverModels.Update(changed.recordVersion(), original)); + assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); + assertThat(execution.depart("92", "DP-11", "1", request).status()).isEqualTo("IN_TRANSIT"); + } + + @Test void cargoCompletionDoesNotReleaseResourcesAndEachTaskEndsIndependently() { + String number = departedTrip(); markDeliveriesSigned(); + var before = service.detail(number).trip(); + var request = new FleetCompletionModels.Complete(before.recordVersion(), "complete-cargo", "全部货物已处理完"); + var completed = completion.complete(number, "1", request).trip(); + assertThat(completed.status()).isEqualTo("COMPLETED"); + assertThat(completed.completedAt()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + assertThat(completion.complete(number, "1", request).trip().recordVersion()).isEqualTo(completed.recordVersion()); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().completion().outcomes()).hasSize(2); + var vehicleEnded = completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "release-vehicle", "车辆检修")).trip(); + assertThat(vehicleEnded.vehicleRelease().vehicleStatus()).isEqualTo("MAINTENANCE"); + assertThat(vehicleEnded.driverRelease()).isNull(); + assertThat(vehicles.findById(Long.parseLong(vehicleEnded.vehicleId())).orElseThrow().status()).isEqualTo("MAINTENANCE"); + assertThat(jdbc.queryForObject("SELECT resource_kind FROM ll_fleet_resource_assignment", String.class)).isEqualTo("DRIVER"); + jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1))); + var driverEnded = completion.release(number, "1", new FleetCompletionModels.Release(vehicleEnded.recordVersion(), "DRIVER", Instant.now(), "交接点", null, "release-driver", "返程任务结束")).trip(); + assertThat(driverEnded.driverRelease()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); + } + + @Test void incompleteOrCancelledDeliveryDoesNotCountAsCargoCompletion() { + String number = departedTrip(); + assertThat(completion.inspection(number).canComplete()).isFalse(); + markDeliveriesSigned(); + jdbc.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=2012"); + assertThatThrownBy(() -> completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "incomplete-cargo", "核对"))).hasMessageContaining("DP-12"); + assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); + jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9 WHERE id=2012"); + assertThat(completion.inspection(number).blockReason()).contains("数量未覆盖"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + } + + @Test void returnedStatusRequiresImmutableReturnCompletionEvidence() { + String number = departedTrip(); markDeliveriesSigned(); + jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL WHERE id=2012"); + assertThat(completion.inspection(number).canComplete()).isFalse(); + LocalDateTime returnedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS); + jdbc.update("INSERT INTO ll_delivery_operation(id,customer_id,delivery_order_id,operation_node,result,created_at) VALUES(1,'CLIENT-B',2012,'RETURN_COMPLETED','退回已完成',?)", returnedAt); + jdbc.update("UPDATE ll_delivery_order SET updated_at=? WHERE id=2012", returnedAt.plusDays(1)); + assertThat(completion.inspection(number).canComplete()).isTrue(); + var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "return-completion", "签收及退回已核对")).trip(); + assertThat(completed.completedAt()).isEqualTo(returnedAt.atZone(ZoneId.systemDefault()).toInstant()); + } + + @Test void laterReturnRequestDoesNotReopenAlreadySignedOriginalTransport() { + String number = departedTrip(); markDeliveriesSigned(); + var signedAt = jdbc.queryForObject("SELECT signed_at FROM ll_delivery_order WHERE id=2012", LocalDateTime.class); + jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING' WHERE id=2012"); + var inspection = completion.inspection(number); + assertThat(inspection.canComplete()).isTrue(); + assertThat(inspection.outcomes().get(1).status()).isEqualTo("DELIVERED"); + assertThat(inspection.outcomes().get(1).terminalAt()).isEqualTo(signedAt.atZone(ZoneId.systemDefault()).toInstant()); + completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "original-transport-complete", "原运输签收已完成")); + assertThat(jdbc.queryForObject("SELECT status FROM ll_delivery_order WHERE id=2012", String.class)).isEqualTo("RETURN_PENDING"); + } + + @Test void releaseChecksCompletionTimeAndRollsBackVehicleStatusAndClaimWhenHistoryFails() { + String number = departedTrip(); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(), "VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "premature-release", "结束"))) + .hasMessageContaining("先核对全部货物"); + markDeliveriesSigned(); + var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "ready-completion", "核对")).trip(); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", completed.completedAt().minusSeconds(1), "仓库", "IN_SERVICE", "early-release", "结束"))) + .hasMessageContaining("实际任务结束时间"); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "DRIVER", Instant.now().plusSeconds(60), "仓库", null, "future-release", "结束"))) + .hasMessageContaining("实际任务结束时间"); + jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT fail_release CHECK(action <> 'RELEASE_VEHICLE')"); + assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "failed-release", "维修"))) + .isInstanceOf(RuntimeException.class); + assertThat(vehicles.findById(Long.parseLong(completed.vehicleId())).orElseThrow().status()).isEqualTo("IN_SERVICE"); + assertThat(service.detail(number).trip().vehicleRelease()).isNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); + } + + @Test void oldReleaseRetryCannotReleaseAResourceAssignedToTheNextTrip() { + String number = departedTrip(); markDeliveriesSigned(); + var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "completion-replay", "核对")).trip(); + var release = new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "下一起运点", "IN_SERVICE", "vehicle-release-replay", "结束本次车辆任务"); + completion.release(number, "1", release); + String next = trip(); service.assign(next, "1", new FleetTripModels.Assign(0L, Long.parseLong(completed.vehicleId()), driver(true), "下一任务")); + completion.release(number, "1", release); + assertThat(jdbc.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=?", String.class, Long.parseLong(completed.vehicleId()))).isEqualTo(next); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='RELEASE_VEHICLE'", Integer.class)).isEqualTo(1); + } + + private String departedTrip() { + String number = readyTrip(); var loading = confirm(number); + execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-for-completion", null)); + return number; + } + private void markDeliveriesSigned() { + jdbc.update("UPDATE ll_outbound_order SET package_count=10"); + jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS)); + } + + private String readyTrip() { + String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); + delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B"); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); + service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); + approveDispatches(number); + for (long id : List.of(11L, 12L)) { + String customer = id == 11 ? "CLIENT-A" : "CLIENT-B"; + jdbc.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=10 WHERE id=?", id); + jdbc.update("UPDATE ll_dispatch_pda_task SET status='COMPLETED' WHERE dispatch_order_id=?", id); + jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id) VALUES(?,?,?,901,'PALLET-901',10,'RECEIVED','ONT8','92')", id + 3000, id, customer); + jdbc.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages,product_quantity,idempotency_key,picked_at) VALUES(?,?,?,?,?,10,0,?,CURRENT_TIMESTAMP)", id, customer, id, id + 3000, id + 4000, "pick-" + id); + } + return number; + } + private FleetExecutionModels.Loading confirm(String number) { + var loading = execution.loading(number); + var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "实际装载核对")); + execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "APPROVED", null)); + return execution.loading(number); + } + private void approveDispatches(String number) { + for (var binding : service.detail(number).dispatches()) service.reviewDispatch(number, binding.dispatchNo(), "1", + new FleetTripModels.Review(service.detail(number).trip().recordVersion(), "APPROVED", null)); + } + private static FleetExecutionModels.Measurement measurement() { return new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 1); } + + private boolean bindAttempt(CountDownLatch start, String number) throws Exception { + start.await(5, TimeUnit.SECONDS); + try { service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "并发关联")); return true; } + catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; } + } + private String assignedTrip() { + String number = trip(); service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排")); return number; + } + private void dispatch(long id, String customer, String warehouse) { + jdbc.update("INSERT INTO ll_dispatch_order(id, customer_id, dispatch_no, overseas_warehouse_id, package_count) VALUES(?,?,?,?,10)", id, customer, "DP-" + id, warehouse); + jdbc.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,task_no,status,warehouse_id,pushed_at) VALUES(?,?,?,?,'PDA_PENDING',?,CURRENT_TIMESTAMP)", + id, customer, id, "TASK-" + id, warehouse); + } + private void delivery(long dispatchId, String customer) { + jdbc.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,idempotency_key) VALUES(?,?,?,?,?)", + dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId); + jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)", + dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0)); + } + + private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception { + start.await(5, TimeUnit.SECONDS); + try { service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "并发安排")); return true; } + catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; } + } + private String trip() { return service.create("1", createRequest(UUID.randomUUID().toString())).trip().tripNo(); } + private FleetTripModels.Create createRequest(String key) { return new FleetTripModels.Create(92L, departure, end, stops(), null, key); } + private static List stops() { return List.of(new FleetTripModels.Stop("起运仓", "提货地址", null), new FleetTripModels.Stop("派送仓", "送货地址", "FBA:ONT8")); } + private long vehicle(boolean capacity) { + return vehicles.create(new VehicleRepository.NewVehicle("TEST-" + UUID.randomUUID(), "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), capacity ? 30 : null, capacity ? new BigDecimal("18000") : null, capacity ? new BigDecimal("90") : null)); + } + private long driver(boolean approve) { return driver(approve, "司机", "123"); } + private long driver(boolean approve, String name, String phone) { + var draft = new DriverModels.Draft(name, phone, "OWNED", "OVERSEAS", "LIC-" + UUID.randomUUID(), "Class A", null, LocalDate.now(ZoneOffset.UTC).plusDays(30), "UTC", null, null); + var driver = drivers.create("1", draft); long id = Long.parseLong(driver.id()); + if (approve) { + drivers.uploadLicense(id, 0, "1", new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7".getBytes())); + drivers.review(id, "1", new DriverModels.Review(1L, "APPROVED", null)); + } + return id; + } + private T transactional(T target, Class type) { + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions, new AnnotationTransactionAttributeSource())); + return type.cast(proxy.getProxy()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java index 91fffa32d..7a63467e7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationServi import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,7 +24,7 @@ class VehicleApplicationServiceTest { @BeforeEach void setUp() { repository = mock(VehicleRepository.class); - service = new VehicleApplicationService(repository); + service = new VehicleApplicationService(repository, mock(VehicleAssignmentGuard.class)); } @Test @@ -52,13 +53,30 @@ class VehicleApplicationServiceTest { failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST)); } + @Test void rejectsPartialOrNonPositiveCapacityWithoutGuessingFromModel() { + var partial = new VehicleModels.Create("ABC-001", "大车30托", new BigDecimal("12.50"), 1000L, + null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, 30, null, new BigDecimal("90")); + assertThatThrownBy(() -> service.create("9", partial)).hasMessageContaining("同时填写"); + var zero = new VehicleModels.Create("ABC-001", "大车30托", new BigDecimal("12.50"), 1000L, + null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, 30, BigDecimal.ZERO, new BigDecimal("90")); + assertThatThrownBy(() -> service.create("9", zero)).hasMessageContaining("大于零"); + org.mockito.Mockito.verify(repository, org.mockito.Mockito.never()).create(org.mockito.ArgumentMatchers.any()); + } + + @Test void exportUsesPlateAsBusinessIdentifierAndIncludesAllCapacityDimensions() { + when(repository.list(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(vehicle(3, 1000))); + String csv = new String(service.exportCsv(new VehicleModels.Query(1, 20, null, null, null, null)), java.nio.charset.StandardCharsets.UTF_8); + assertThat(csv).contains("最大托位,最大载重(kg),可用容积(m³)").contains("\"30\",\"18000\",\"90\"") + .doesNotContain("车辆ID"); + } + private static VehicleModels.Update update(long version, long mileage) { return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, - "Driver", "123", "OVERSEAS", "OWNED", null); + "Driver", "123", "OVERSEAS", "OWNED", null, 30, new BigDecimal("18000"), new BigDecimal("90")); } private static VehicleSnapshot vehicle(long version, long mileage) { return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, - "Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null); + "Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null, 30, new BigDecimal("18000"), new BigDecimal("90")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java index 469322c13..59fefbe2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java @@ -60,6 +60,7 @@ class PdaOutboundDispatchContractTest { DispatchPdaController dispatch = new DispatchPdaController(); OutboundPdaController outbound = new OutboundPdaController(); ReflectionTestUtils.setField(dispatch, "service", dispatchService); + ReflectionTestUtils.setField(dispatch, "trips", mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class)); ReflectionTestUtils.setField(outbound, "service", outboundService); AuthenticatedUser user = pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java index e7de9f255..ae25afa78 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -64,7 +64,8 @@ class OutboxLeaseDatabaseTest { var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration); var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); - var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); + var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper, + org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.state.OverseasFeeCancellation.class)); var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "BusinessFeeSourceChanged", now.minusSeconds(1), now.minusMinutes(3)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java index cf0f50759..479887c13 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -23,7 +23,8 @@ class UnloadCompletedOutboxRepositoryTest { void feeClaimAlsoRechecksRetryWindow() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), LastlegEventOutbox.class); LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); - var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); + var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper, + mock(com.baiyue.lastleg.financefee.state.OverseasFeeCancellation.class)); ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java new file mode 100644 index 000000000..b17cd4223 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.transfer.fleet; + +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.transfer.dto.*; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; +class FleetIncomingAdapterTest { + @Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() { + var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper()); + var detail=mock(WarehouseTransferResponses.Detail.class); + when(detail.transferNo()).thenReturn("WT-1");when(detail.inboundCode()).thenReturn("WT-1");when(detail.operatingWarehouseId()).thenReturn("92");when(detail.fbaWarehouseCode()).thenReturn("ONT8"); + when(detail.status()).thenReturn("WAIT_PDA_RECEIVING");when(detail.pallets()).thenReturn(List.of()); + when(service.createAndSubmit(eq("9"),eq("1"),any())).thenReturn(detail);when(service.review(eq("9"),eq("WT-1"),eq("1"),any())).thenReturn(detail); + var cargo=new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,2,new BigDecimal("100"),new BigDecimal("1"),"PALLET",new BigDecimal("50"),"USD",null,null,null); + var result=adapter.create("1",new FleetBookingIncomingGateway.Incoming("9","92","起运仓","FB-1",cargo,LocalDateTime.of(2030,1,1,12,0),null,null,null,null,null)); + assertThat(result.receivedPackages()).isZero();assertThat(result.pallets()).isEmpty();assertThat(result.status()).isEqualTo("WAIT_PDA_RECEIVING"); + var captor=ArgumentCaptor.forClass(WarehouseTransferRequests.Create.class);verify(service).createAndSubmit(eq("9"),eq("1"),captor.capture()); + assertThat(captor.getValue().inboundCode()).isNull();assertThat(captor.getValue().sourceLastlegOrderIds()).isEmpty(); + assertThat(captor.getValue().deliveryMethod()).isEqualTo("CUSTOMER_DELIVERY");assertThat(captor.getValue().operatingWarehouseId()).isEqualTo("92"); + assertThat(captor.getValue().idempotencyKey()).isEqualTo("FLEET:FB-1:INCOMING"); + verify(service,never()).confirmMeasurement(any(),any(),any(),any()); + } + @Test void privateAddressCannotSilentlyChangeTheBookedDestination() { + var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper()); + var cargo=new FleetBookingModels.Cargo(FleetDispatchGateway.destinationKey("PRIVATE",null,null,"Road 1","90001"),null,1,1,BigDecimal.ONE,BigDecimal.ONE,"ORDER",BigDecimal.TEN,"USD",null,null,null); + assertThatThrownBy(()->adapter.create("1",new FleetBookingIncomingGateway.Incoming("9","92","仓","FB-1",cargo,LocalDateTime.now(),null,"Road 2","90001","A","123"))).hasMessageContaining("卸货站不一致"); + verifyNoInteractions(service); + } +} diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index b37fa3273..a2dde1a0e 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -1,8 +1,12 @@ { "schemaVersion": 1, + "forbidPackageCycles": true, "description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", "allowedSliceEdges": [ + "fleet->shared-technical", + "fleet->overseas-master-data", "dispatch-delivery->dropship", + "dispatch-delivery->fleet", "dispatch-delivery->inbound-pda", "dispatch-delivery->inventory", "dispatch-delivery->relabel-transfer-discard", @@ -28,7 +32,6 @@ "relabel-transfer-discard->inventory", "relabel-transfer-discard->shared-technical", "relabel-transfer-discard->tailleg-handoff", - "security-projection->shared-technical", "tailleg-handoff->dispatch-delivery", "tailleg-handoff->dropship", "tailleg-handoff->inbound-pda", @@ -42,14 +45,152 @@ "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", - "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java", - "com.baiyue.lastleg.security.controller.LastlegSecurityController": "src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java", - "com.baiyue.lastleg.security.dto.LastlegSecurityDtos": "src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java", - "com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java", - "com.baiyue.lastleg.security.service.LastlegSecurityProjectionService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java", - "com.baiyue.lastleg.security.service.LastlegSecurityValidationService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java", - "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity": "src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java" + "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" }, + "forbiddenSlices": ["security-projection"], + "forbiddenTypePrefixes": [ + "com.baiyue.lastleg.security.controller.LastlegSecurity", + "com.baiyue.lastleg.security.dto.LastlegSecurity", + "com.baiyue.lastleg.security.service.LastlegSecurity", + "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" + ], + "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", + "target": "com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway", + "reason": "转运适配器实现外接来货端口,仅传登记与实测证据;车队不读取转运Mapper或库存表。" + }, + { + "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", + "target": "com.baiyue.lastleg.fleet.trip.FleetDispatchGateway", + "reason": "仅复用端口定义的稳定卸货目的地匹配键,禁止放宽整个转运切片对车队实现的引用。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.dto.RelabelRequests", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.dto.RelabelResponses", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.service.RelabelApplicationService", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.discard.pda.controller.PdaCargoDiscardController", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "PDA废弃入口复用统一Token身份与绑定仓解析,不另建本地安全投影。" + }, + { + "source": "com.baiyue.lastleg.discard.pda.service.PdaCargoDiscardService", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "PDA废弃作业使用同一已验证身份范围。" + }, + { + "source": "com.baiyue.lastleg.transfer.controller.PdaWarehouseTransferController", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "转运PDA入口复用同一绑定仓身份解析。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlan", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter", + "target": "com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate", + "reason": "海外费用按业务月份读取当前正式月度汇率,缺失或未生效拒绝;仅此换算器可使用这两项现有实现引用。" + }, + { + "source": "com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter", + "target": "com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper", + "reason": "海外费用按业务月份读取当前正式月度汇率,缺失或未生效拒绝;仅此换算器可使用这两项现有实现引用。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.controller.PdaReturnReceiptController", + "target": "com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway", + "reason": "退货签收照片复用当前业务文件网关。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.entity.RelabelApplication", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.entity.RelabelItem", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.mapper.RelabelItemMapper", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + } +], "applicationDependencyRule": { "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", "allowedInternalPrefix": "com.baiyue.lastleg.masterdata.vehicle.application", @@ -62,13 +203,13 @@ ] }, "sliceSpecificRules": { - "security-projection": { - "allowedOutgoing": ["shared-technical"], - "allowedIncoming": [] + "fleet": { + "allowedOutgoing": ["overseas-master-data", "shared-technical"], + "allowedIncoming": ["dispatch-delivery"] }, "overseas-master-data": { "allowedOutgoing": ["shared-technical"], - "allowedIncoming": ["procurement-trucking-looseboard"] + "allowedIncoming": ["procurement-trucking-looseboard", "fleet"] } } } diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 7e0ece991..ab7582ab3 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 136]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 145]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|136) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|145) AS initialized_tables;$/) } }) @@ -56,7 +56,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 370, iam_role_permission: 551, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 392, iam_role_permission: 573, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -81,6 +81,14 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') assert.ok(admin.has('lastleg:cargo-discard:approve'), 'existing discard review endpoint must be grantable to platform administrator') + for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { + assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) + } + for (const action of ['list', 'create', 'edit', 'assign', 'cancel']) { + assert.ok(admin.has(`platform:fleet:trip:${action}`)) + } + assert.ok(!permissions.some(row => row.permission_code === 'platform:order:complete'), + '订单完成必须由可信履约回写产生,不能恢复人工完成权限') for (const code of ['platform:order:declaration:edit', 'platform:firstleg:inbound:hold', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-business:purchase:list', 'platform:overseas-master-data:quote:list', 'platform:finance:fee-source:review', diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 9c527c0ea..bb49494a9 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,18 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', + 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', + '原运费覆盖只读:按实际客户与头程入仓ID批量读取Finance已接受来源;目标端只查本库,无下游调用;不提供费用写入或资金命令'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '运维图片上传无远端幂等键,调用方保留请求结果;元数据与下载按平台/记录复合归属核对,禁止客户端自动重试'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerClient.java', + 'lastleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', + '外接登记读取权威有效客户与下单资格,内部服务令牌保护;不依据采购历史或创建客户主档'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '司机仓绑定候选只读权威仓库目录,使用内部服务令牌,不传用户JWT或修改仓库主档'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java', 'lastleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用及资金指令按eventId/hash直投对应Finance内部入口,严格DeliveryAck'), http('common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java', @@ -169,6 +181,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '内部服务令牌上传驾驶证,无上传幂等键;读取按平台公共作用域、司机业务键及文件ID核对类型/大小/归属,不自动重放上传'), http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '派送资料上传无幂等键,元数据按客户/派送单/类别校验,当前与历史下载只读'), http('lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index d4d269793..0a673d213 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -32,6 +32,8 @@ const expectedOnlineEdges = [ 'iam-service->auth-service', 'iam-service->warehouse-service', 'lastleg-service->file-service', + 'lastleg-service->finance-service', + 'lastleg-service->iam-service', 'lastleg-service->print-service', 'lastleg-service->warehouse-overseas-service', 'lastleg-service->warehouse-service', @@ -100,7 +102,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata', const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 63) + assert.equal(reviewed.length, 66) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -116,7 +118,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 22) + assert.equal(reviewed.length, 24) assert.deepEqual(discovered.sort(), reviewed) }) @@ -206,7 +208,11 @@ test('unified authority checks remain infrastructure edges and direct events rem assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway->auth-service')) assert.ok(graphEdges('INFRASTRUCTURE').includes('common-security->auth-service')) assert.ok(graphEdges('BACKGROUND').includes('lastleg-service->finance-service')) - assert.ok(!graphEdges('REQUEST').includes('lastleg-service->finance-service')) + const financeReads = SYNCHRONOUS_CLIENTS.filter(client => client.source === 'lastleg-service' + && client.target === 'finance-service' && client.contexts.includes('REQUEST')) + assert.deepEqual(financeReads.map(client => [client.path, client.access, client.idempotency]), [[ + 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'READ', 'SAFE_READ' + ]]) assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target))) }) @@ -264,9 +270,9 @@ test('known user entries expose every longest simple REQUEST path over the two-h 'file-service->iam-service->auth-service', 'file-service->iam-service->warehouse-service' ] }, - 'lastleg-service': { hops: 3, paths: [ - 'lastleg-service->file-service->iam-service->auth-service', - 'lastleg-service->file-service->iam-service->warehouse-service' + 'lastleg-service': { hops: 6, paths: [ + 'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', + 'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service' ] }, 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, 'warehouse-overseas-service': { hops: 1, paths: [ @@ -280,6 +286,16 @@ test('known user entries expose every longest simple REQUEST path over the two-h } }) +test('delivery fee coverage is a single readonly Finance hop and cannot write financial state', async () => { + const client = await readFile(path.join(root, 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java'), 'utf8') + const service = await readFile(path.join(root, 'finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java'), 'utf8') + assert.equal([...client.matchAll(/@(?:Get|Post|Put|Delete|Patch)Mapping/g)].length, 1) + assert.ok(client.includes('/internal/finance/v1/delivery-fee-coverage/sources')) + assert.ok(service.includes('@Transactional(readOnly = true)')) + assert.ok(!/\.client\.|Feign|RestClient|WebClient|HttpClient|jdbc\.update|\.execute\(/.test(service)) + assert.ok(service.includes("f.customer_id=:customerId AND f.inbound_id IN (:inboundIds)")) +}) + test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { for (const profile of ['test']) { const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8')