diff --git a/.logs/appointment-calendar-release-manifest.json b/.logs/appointment-calendar-release-manifest.json new file mode 100644 index 000000000..146fe4870 --- /dev/null +++ b/.logs/appointment-calendar-release-manifest.json @@ -0,0 +1,23 @@ +{ + "deploy/sql/README.md": "4c7475566d5b3ab7ebaeb26a9369c3b2daa2911adf648bd9df1cae348de9f5c1", + "deploy/sql/头程数据库统一增量-MySQL8.sql": "55c9736c6998f59d48a76c5ba60e2a801dd0b8acbf209fcf0dae4830fb3fb485", + "deploy/sql/海外区域统一增量-MySQL8.sql": "3fda99ff623735dc42844a50d9a80090ab478aaf916ebdf5fa21e5a6d054117a", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java": "74f1f56fb224798004382e0f4b36140cf81a073f50a1ac212bc393e0d2dde159", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java": "58edea28fff5a8357aa662b794d23983d7da987f7f11f9b4bc64a8b406e466c5", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java": "76556dc0b615c19d8b3aa8c4453af56dce72bb8782343fce6a09b8a36163a467", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java": "881f000a5f9a2ebe10858ee4ab97fb2595189aef641bb90dc73b9c37bc6d1d7b", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java": "44c3927abb813c5d6f5c8d553795c8bf50fb945e9862984f1fe011da20d3695d", + "firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml": "8fffd13df663c6eaaf7eaffaa063b24d531f33241d7adb94850ee146af6d22dd", + "firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java": "f38e11e2509a030854c0b6f40f8f9577ed729bcbbff0666b180504f38aa93c0b", + "firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java": "87a0fbe6f7b9a271e331641c7c8da27282a6f1df2ae9b3167d543bf78119f7e6", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java": "17102d4a32640ccfd92a7e7e738a248d4c1f5ae8906b8b965f455eb9be572603", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java": "80f3a96bee0fa7630afff100ddb051977e283902c4e7ef247b8152fb37c2400f", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java": "88824f1a88d24b9bd34c9e7023aaa00d2653849a9131bf619d7df0f5fd3a7a0f", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java": "4395cce32c574ebee0b4437f6ec8bbe0a5aee62c19a6a451ee0f243eab7be558", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java": "886e9e5460e47962f5701ceeb5e94ba9d2a123c4c83f2791b739178c8bfe874a", + "lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml": "06965771d34d761b869f90ee6ff3f2dca7589121c67a5adbe2081677ce5a563c", + "lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java": "a17e359b39000036fd9bd80d6b0744018541fc7bbe846a0585ece629ff09dc6c", + "lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java": "9ed34bea7163166d0f79f882f12b23033dae13ad13bd4eedc7e202aed5defb8f", + "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java": "c3a4cedac09f16f37442a6709b9d704a607f2e0238e9997687e44153c378f27a", + "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java": "5d9e8aded16aa4de3338d65edd1a2e6b4e42961498f22d99c702e0f8d491bcd6" +} \ No newline at end of file diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 61905f623..6c452094b 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,9 @@ # 单测试环境数据库空库初始化 +费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 + +排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;活动来源键与真实物理托盘分别唯一,防止跨车重复占用。智能草稿按拥有者、起运仓和版本维护,容器保存真实来源键,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。 + 本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。 `migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。 @@ -47,7 +51,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 220 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 225 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -86,7 +90,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、419 个权限、600 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、454 个权限、618 条角色权限、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 d58001da3..e52387846 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1995,6 +1995,7 @@ CREATE TABLE fl_fee_item ( fee_code varchar(50) NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码', fee_type_name varchar(128) NOT NULL COMMENT '费用类型名称', supplier_category varchar(16) DEFAULT NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商', + default_price decimal(12,2) DEFAULT NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改', pickup_payable tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应付是否参与', pickup_receivable tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应收是否参与', warehouse_payable tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应付是否参与', @@ -6356,10 +6357,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'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 (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'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 (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'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 (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'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 (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'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 (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'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 (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'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 (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'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 (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'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 (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'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 (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6381,6 +6381,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'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 (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'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 (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'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 (5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create','新增排车单',NULL,'POST',765,'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 (5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete','确认装车完成',NULL,'POST',766,'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 (5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart','确认实际发车',NULL,'POST',767,'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 (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'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 (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'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 (5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6458,7 +6461,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view','资金账户','/finance/account','GET',50,'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 (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create','提交充值申请',NULL,'POST',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 (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'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 (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',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 (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'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 (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',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 (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6737,7 +6739,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107013,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107014,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107016,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107017,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107018,0,CURRENT_TIMESTAMP(3)); @@ -6761,6 +6762,9 @@ 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,5107062,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107063,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107065,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107066,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107067,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107070,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107071,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107072,0,CURRENT_TIMESTAMP(3)); @@ -7038,6 +7042,9 @@ 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 (4000004,5107050,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107060,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107065,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107066,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107067,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107070,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107071,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107072,0,CURRENT_TIMESTAMP(3)); @@ -7121,7 +7128,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305020,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305021,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306000,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306001,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306100,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306101,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306110,0,CURRENT_TIMESTAMP(3)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d4a6b28a1..39047fe4f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -325,11 +325,13 @@ CREATE TABLE ll_destuff_pallet_cost_allocation ( CREATE TABLE ll_dispatch_draft ( id bigint NOT NULL COMMENT '主键ID', draft_token varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能排车草稿随机标识', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户租户ID', - operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿操作人ID', - plan_ids_json varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所选入仓计划ID列表JSON', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿拥有者身份,仅本人可维护及恢复命令', + warehouse_id BIGINT NOT NULL COMMENT '本草稿唯一起运公共仓库引用', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '草稿并发版本,显式零版本合法', + sources_json LONGTEXT NOT NULL COMMENT '服务端核验的真实来源及客户版本快照数组', + plan_ids_json longtext COLLATE utf8mb4_unicode_ci COMMENT '所选入仓计划ID列表JSON,容纳最多五百个完整长整型引用', containers_json longtext COLLATE utf8mb4_unicode_ci COMMENT '草稿装车布局JSON快照', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态:ACTIVE有效、RELEASED释放、CONVERTED已整批转换', expires_at datetime(3) DEFAULT NULL COMMENT '草稿失效时间', released_at datetime(3) DEFAULT NULL COMMENT '草稿释放时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -345,13 +347,32 @@ CREATE TABLE ll_dispatch_draft_container ( draft_token varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能排车草稿随机标识', sequence_no int NOT NULL COMMENT '草稿车厢顺序号', capacity int NOT NULL COMMENT '草稿车厢托盘容量', + source_keys_json LONGTEXT NOT NULL COMMENT '本装车容器已取得本草稿占用的来源键数组', 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 '更新时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_draft_container_sequence (draft_id,sequence_no), KEY idx_ll_dispatch_draft_container_token (draft_token) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车托盘'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车容器,每个非空容器转换一张整车单'; + +CREATE TABLE ll_dispatch_draft_command ( + id BIGINT NOT NULL COMMENT '草稿命令内部主键', + idempotency_key VARCHAR(128) NOT NULL COMMENT '草稿操作全局唯一幂等键', + operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作主体,恢复必须为本人', + action VARCHAR(32) NOT NULL COMMENT '草稿创建、维护、选货、释放或转换动作', + target_token VARCHAR(128) NOT NULL COMMENT '命令目标草稿标识或创建范围标识', + request_hash CHAR(64) NOT NULL COMMENT '原动作、主体、版本及完整载荷摘要', + result_json LONGTEXT NULL COMMENT '本事务完成后保存的不可变原结果,不提交空结果', + created_at DATETIME(3) NOT NULL COMMENT '命令创建UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_draft_command_key (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿持久命令及原结果'; CREATE TABLE ll_dispatch_item ( + customer_name VARCHAR(255) NULL COMMENT '本条真实客户名称快照', + source_key VARCHAR(255) NOT NULL COMMENT '本条货物稳定权威来源键', + source_version CHAR(64) NOT NULL COMMENT '选货时核对的真实来源版本摘要', + source_snapshot_json JSON NOT NULL COMMENT '本条货物不可变来源及关联事实快照', id bigint NOT NULL COMMENT '主键ID', dispatch_order_id bigint NOT NULL COMMENT '派车单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', @@ -359,7 +380,7 @@ CREATE TABLE ll_dispatch_item ( plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', order_id bigint DEFAULT NULL COMMENT '订单ID', - order_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '订单号', + order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本条真实来源订单业务号快照', order_refs varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联订单信息', inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓单号快照', pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘号', @@ -392,42 +413,69 @@ CREATE TABLE ll_dispatch_item ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车货物冻结明细'; CREATE TABLE ll_dispatch_order ( - id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派车单号', - overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', - overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', - vehicle_plate varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车牌号', - driver_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机姓名', - driver_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机电话', - carrier_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运商名称', - route varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '运输路线', - depart_at datetime(3) DEFAULT NULL COMMENT '发车时间', - fba_appointment_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA预约号', - fba_appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', - vehicle_appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', - estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', - item_count int NOT NULL DEFAULT '0' COMMENT '明细数量', - pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数', - package_count int NOT NULL DEFAULT '0' COMMENT '箱数', - picked_package_count int NOT NULL DEFAULT '0' COMMENT 'PDA累计已拣箱数', - shipped_package_count int NOT NULL DEFAULT '0' COMMENT '累计已出库箱数', - partial_completion_reason varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT 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 '创建时间', - updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + id BIGINT NOT NULL COMMENT '整车排车主单内部主键', + dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', + trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '起运海外仓名称快照', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '主单生命周期:ACTIVE有效、CANCELLED已取消', + stage VARCHAR(32) NOT NULL DEFAULT 'BEFORE_LOADING' COMMENT '作业阶段:BEFORE_LOADING装车前、LOADING装车中、AFTER_LOADING装车后、DELIVERING派送中、DELIVERED派送完成', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '整车公共并发版本,首版为零', + cargo_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本趟货物修订版本', + cargo_hash CHAR(64) NOT NULL COMMENT '当前整车真实货物及来源摘要', + review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', + review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次', + review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照', + loading_review_json JSON NULL COMMENT '当前理货审核事实快照', + resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', + vehicle_plate VARCHAR(64) NULL COMMENT '本趟车辆真实车牌快照', + route VARCHAR(2000) NULL COMMENT '真实运输路线展示快照', + planned_departure_at DATETIME(3) NULL COMMENT '计划发车时间,草稿未知时为空', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + loading_completed_at DATETIME(3) NULL COMMENT '实际确认装车完成时间', + loading_completion_hash CHAR(64) NULL COMMENT '实际装车完成事实摘要', + loading_confirmation_json JSON NULL COMMENT '装车确认时逐实托真实实载完整快照', + arrangement_key VARCHAR(128) NULL COMMENT '本车唯一派送安排事实标识', + arrangement_business_hash CHAR(64) NULL COMMENT '本车派送安排业务内容摘要', + departed_at DATETIME(3) NULL COMMENT '本趟实际发车时间', + delivery_completed_at DATETIME(3) NULL COMMENT '全部派送正常签收后的整车完成时间', + item_count INT NOT NULL DEFAULT 0 COMMENT '当前排车作业明细数', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '真实实物托盘去重数', + package_count INT NOT NULL DEFAULT 0 COMMENT '本车计划箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA真实累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '本车实际发运箱数', + remark VARCHAR(2000) 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 '主单最后更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no), - KEY idx_ll_dispatch_order_list (customer_id,status,depart_at), - KEY idx_ll_dispatch_order_vehicle (customer_id,vehicle_plate) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程派车单'; + UNIQUE KEY uk_dispatch_trip (trip_id), + KEY idx_ll_dispatch_order_list (overseas_warehouse_id,status,stage,created_at,id), + KEY idx_ll_dispatch_order_vehicle (vehicle_plate,status,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台整车排车作业主单,客户归属在真实明细'; + +CREATE TABLE ll_dispatch_history ( + id BIGINT NOT NULL COMMENT '排车历史内部主键', + dispatch_id BIGINT NOT NULL COMMENT '本次历史所属整车主单ID', + record_version BIGINT NOT NULL COMMENT '操作完成后的整车公共版本', + action VARCHAR(40) NOT NULL COMMENT '本次真实业务动作', + operator_id VARCHAR(128) NOT NULL COMMENT '可信操作主体ID', + reason VARCHAR(2000) NULL COMMENT '审核驳回或变更原因', + snapshot_json JSON NOT NULL COMMENT '动作完成当时的完整主单事实快照', + occurred_at DATETIME(3) NOT NULL COMMENT '真实操作发生时间', + PRIMARY KEY (id), + KEY idx_dispatch_history_version (dispatch_id,record_version,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车版本与审核轮次的追加历史'; CREATE TABLE ll_dispatch_pallet_lock ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', source_pallet_key varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘业务键', active_key varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的资源排他键', + active_warehouse_pallet_id bigint DEFAULT NULL COMMENT '活动占用的真实仓库托盘,释放后为空,跨客户及来源别名唯一', plan_id bigint DEFAULT NULL COMMENT '入仓计划ID', plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', @@ -444,6 +492,7 @@ CREATE TABLE ll_dispatch_pallet_lock ( return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), + UNIQUE KEY uk_ll_dispatch_active_warehouse_pallet (active_warehouse_pallet_id), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车托盘排他锁'; @@ -524,8 +573,7 @@ CREATE TABLE ll_dispatch_pda_pick_order ( CREATE TABLE ll_dispatch_pda_task ( id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - dispatch_order_id bigint NOT NULL COMMENT '派车单ID', + dispatch_order_id bigint NOT NULL COMMENT '唯一整车排车单引用,客户归属保留在扫码明细', task_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA任务号', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务状态', operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', @@ -536,9 +584,9 @@ CREATE TABLE ll_dispatch_pda_task ( 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 '更新时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (customer_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (dispatch_order_id), UNIQUE KEY uk_ll_dispatch_pda_task_no (task_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA拣货任务'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车PDA拣货任务,一张排车单仅一条任务'; CREATE TABLE ll_dropship_carton ( id bigint NOT NULL COMMENT '主键ID', @@ -1827,7 +1875,71 @@ CREATE TABLE ll_outbound_order ( KEY idx_ll_outbound_list (customer_id,status,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程出库单'; +CREATE TABLE ll_dispatch_command ( + id BIGINT NOT NULL COMMENT '持久命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '命令所属整车排车主单ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '命令所属真实排车业务号', + action VARCHAR(40) NOT NULL COMMENT '本次整车作业动作', + operator_id VARCHAR(128) NOT NULL COMMENT '原始可信操作主体ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '跨操作主体全局唯一的原始请求键', + record_version BIGINT NOT NULL COMMENT '原请求明确提交的排车版本,零为合法首版', + command_hash CHAR(64) NOT NULL COMMENT '绑定原主体动作业务号版本和完整载荷的严格摘要', + result_json LONGTEXT NOT NULL COMMENT '按动作类型恢复的不可变成功结果JSON', + created_at DATETIME(3) NOT NULL COMMENT '成功命令记录时间,使用UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_command_key (idempotency_key), + KEY idx_dispatch_command_history (dispatch_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车公共持久命令账本'; + +CREATE TABLE ll_dispatch_delivery_arrangement ( + id BIGINT NOT NULL COMMENT '安排事实内部主键', + dispatch_order_id BIGINT NOT NULL COMMENT '唯一整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + arrangement_key VARCHAR(128) NOT NULL COMMENT '本次唯一安排业务事实标识', + business_hash CHAR(64) NOT NULL COMMENT '排除操作者及纯并发版本的安排业务摘要', + cargo_hash CHAR(64) NOT NULL COMMENT '本车实际拣货来源摘要', + loading_completion_hash CHAR(64) NOT NULL COMMENT '装车完成事实摘要', + original_command_key VARCHAR(255) NOT NULL COMMENT '首次安排的全局请求键', + original_operator_id VARCHAR(128) NOT NULL COMMENT '首次安排操作主体', + result_json JSON NOT NULL COMMENT '全部客户出库及客户目的地派送不可变结果', + cargo_json JSON NOT NULL COMMENT '逐客户逐拣货归属的不可变真实来源', + created_at DATETIME(3) NOT NULL COMMENT '本次安排实际创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_arrangement_dispatch (dispatch_order_id), + UNIQUE KEY uk_dispatch_arrangement_key (arrangement_key), + UNIQUE KEY uk_dispatch_arrangement_command (original_command_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车唯一派送安排事实'; + +CREATE TABLE ll_delivery_facts_changed ( + id BIGINT NOT NULL COMMENT '持久信号内部主键', + customer_id VARCHAR(128) NOT NULL COMMENT '真实签收货物客户ID', + dispatch_id BIGINT NOT NULL COMMENT '整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + delivery_id BIGINT NOT NULL COMMENT '本客户派送单ID', + delivery_no VARCHAR(128) NOT NULL COMMENT '真实派送业务号', + delivery_version BIGINT NOT NULL COMMENT '触发信号的真实派送版本', + facts_hash CHAR(64) NOT NULL COMMENT '该版本真实签收事实摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '签收事实实际发生时间', + state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '待核对PENDING或已核对PROCESSED', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已记录的汇总失败次数', + next_attempt_at DATETIME(3) NOT NULL COMMENT '下次可重试时间', + last_error_code VARCHAR(64) NULL COMMENT '最近失败诊断码不保存敏感异常正文', + processed_at DATETIME(3) NULL COMMENT '与主阶段汇总同事务确认时间', + PRIMARY KEY (id), + UNIQUE KEY uk_delivery_fact_version (customer_id,delivery_id,delivery_version), + KEY idx_delivery_fact_due (state,next_attempt_at,id), + KEY idx_delivery_fact_dispatch (dispatch_id,state,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正常签收事实变化的本地持久待汇总信号'; + CREATE TABLE ll_outbound_pallet_item ( + return_sku_quantity BIGINT NULL COMMENT '本来源退货出库SKU件数,空表示整箱出库', + dispatch_item_id BIGINT NULL COMMENT '本次整车排车作业明细ID', + source_attribution_id BIGINT NULL COMMENT '不可变PDA真实拣货归属ID', + source_order_id BIGINT NULL COMMENT '本行真实来源尾程订单ID', + source_inventory_position_id BIGINT NULL COMMENT '本行实际拣货库存仓位ID', + source_return_receipt_id BIGINT NULL COMMENT '本行实际退回再派实收记录ID', + source_return_carton_id BIGINT NULL COMMENT '本行实际退货换标来源箱ID', + carton_marks VARCHAR(4000) NULL COMMENT '本行真实拣货箱唛快照', id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', outbound_order_id bigint NOT NULL COMMENT '出库单ID', @@ -1848,6 +1960,7 @@ CREATE TABLE ll_outbound_pallet_item ( 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), + UNIQUE KEY uk_outbound_source_attribution (customer_id,source_attribution_id), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出库托盘明细'; @@ -2946,8 +3059,8 @@ CREATE TABLE ll_fleet_trip ( 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,含承担的返程转场', + planned_departure_at DATETIME(3) NULL COMMENT '计划发车UTC,空单草稿可未填写,提交审核前核齐', + expected_driver_end_at DATETIME(3) NULL COMMENT '司机预计任务结束UTC,含返程转场,草稿可未填写', departed_at DATETIME(3) NULL COMMENT '实际发车UTC', completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC', stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点', @@ -3119,20 +3232,18 @@ CREATE TABLE ll_fleet_resource_assignment ( ) 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 '共享运输车次业务编号', + dispatch_id BIGINT NOT NULL COMMENT '整车排车单内部引用,一车一个有效资源关联', + dispatch_no VARCHAR(64) 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), + UNIQUE KEY uk_fleet_trip_dispatch_trip (trip_id), CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='整车排车与本趟资源记录一对一关联,历史独立保留'; CREATE TABLE ll_fleet_trip_operations_record ( id BIGINT NOT NULL COMMENT '出车实测历史主键', 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 52f3dc3ba..850060c90 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -9,6 +9,8 @@ import java.util.Arrays; enum BusinessFileCategory { FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, 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), @@ -67,7 +69,12 @@ enum BusinessFileCategory { .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型")); } + /** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */ void validateOwner(long tenantId) { + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) + && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); + } if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台"); } diff --git a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml new file mode 100644 index 000000000..e384ffb41 --- /dev/null +++ b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml @@ -0,0 +1,167 @@ +openapi: 3.0.3 +info: + title: 平台排车车辆与司机证据内部文件契约 + version: 1.0.0 + description: >- + 复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。 + 两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, + 不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用 + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。 + File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、 + PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId + 联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。 + 上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。 + Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。 + 本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。 + 统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。 +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: + operationId: uploadDispatchEvidence + summary: 上传本排车的车辆或司机证据 + parameters: + - name: X-Operator-Id + in: header + required: true + description: Lastleg 已认证操作者的正数 Long ID + schema: {$ref: '#/components/schemas/PositiveLongString'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名 + responses: + '200': {$ref: '#/components/responses/MetadataOrError'} + /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: + operationId: readDispatchEvidenceMetadata + summary: 按本排车与证据类别联合校验并读取元数据 + responses: + '200': {$ref: '#/components/responses/MetadataOrError'} + /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: + operationId: downloadDispatchEvidence + summary: 按完整复合归属读取文件内容 + responses: + '200': + description: >- + 成功返回真实文件;错误返回 application/json 统一业务错误。 + A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效, + A0404 表示文件不存在、非有效文件或复合归属不匹配。 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名} + Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数} + content: + application/pdf: {schema: {type: string, format: binary}} + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}} +components: + securitySchemes: + InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + TenantId: + name: tenantId + in: path + required: true + description: 两类排车证据只属于平台,客户作用域一律拒绝 + schema: {type: string, enum: ['0']} + BusinessType: + name: businessType + in: path + required: true + schema: {$ref: '#/components/schemas/DispatchEvidenceType'} + BusinessId: + name: businessId + in: path + required: true + description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入 + schema: {$ref: '#/components/schemas/PositiveLongString'} + FileId: + name: fileId + in: path + required: true + schema: {$ref: '#/components/schemas/PositiveLongString'} + responses: + MetadataOrError: + description: >- + 统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件; + A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配; + S0503为存储不可用,S0500为元数据保存失败等内部异常。 + content: + application/json: + schema: + oneOf: + - {$ref: '#/components/schemas/MetadataEnvelope'} + - {$ref: '#/components/schemas/ErrorEnvelope'} + schemas: + PositiveLongString: + type: string + pattern: '^[1-9][0-9]*$' + maxLength: 19 + description: 正数Long的十进制字符串,值不得超过9223372036854775807 + DispatchEvidenceType: + type: string + enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE] + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + message: {type: string} + data: {$ref: '#/components/schemas/Metadata'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt] + properties: + fileId: {$ref: '#/components/schemas/PositiveLongString'} + ownerScope: {type: string, enum: [PLATFORM]} + tenantId: {type: string, enum: ['0']} + businessType: {$ref: '#/components/schemas/DispatchEvidenceType'} + businessId: {$ref: '#/components/schemas/PositiveLongString'} + fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]} + sizeBytes: + type: string + pattern: '^[1-9][0-9]*$' + description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串 + example: '1024' + createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移} + ErrorEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]} + message: {type: string} + data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} diff --git a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java new file mode 100644 index 000000000..2444e080c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java @@ -0,0 +1,245 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.controller.InternalBusinessFileController; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DispatchEvidenceFileTest { + private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE"; + private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE"; + private static final int MAX_BYTES = 10 * 1024 * 1024; + @TempDir Path root; + + /** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */ + @BeforeAll static void metadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"), + FileObjectEntity.class); + } + + /** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var upload = pdf(); + for (long customer : new long[]{-1, 1, 900}) { + assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class); + } + for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) { + assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class); + verifyNoInteractions(files, storage); + } + + /** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception { + var storage = storage(); + var category = BusinessFileCategory.require(type); + for (var file : new MockMultipartFile[]{pdf(), + new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")), + new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) { + var saved = storage.store(0, "71", file, category); + assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes()); + assertThat(saved.contentType()).isEqualTo(file.getContentType()); + assertThat(saved.size()).isEqualTo(file.getSize()); + assertThat(saved.sha256()).matches("[0-9a-f]{64}"); + } + for (var file : new MockMultipartFile[]{ + new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]), + new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)), + new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")), + new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")), + new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()), + new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()), + new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) { + assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class); + } + + /** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception { + var storage = storage(); + var category = BusinessFileCategory.require(type); + byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES); + var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content); + var stored = storage.store(0, "71", boundary, category); + assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES); + var oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); + when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1); + assertThatThrownBy(() -> storage.store(0, "71", oversized, category)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + verify(oversized, never()).getInputStream(); + verify(oversized, never()).transferTo(any(Path.class)); + } + + /** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = storage(); + var service = service(files, storage); + var ids = new AtomicLong(500); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> { + FileObjectEntity row = call.getArgument(0); + row.setId(ids.incrementAndGet()); + return 1; + }); + var first = service.upload(0, type, "71", 7, pdf()); + var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf", + "%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII)); + var second = service.upload(0, type, "71", 7, secondFile); + var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class); + verify(files, times(2)).insert(rows.capture()); + assertThat(first.ownerScope()).isEqualTo("PLATFORM"); + assertThat(first.tenantId()).isZero(); + assertThat(first.businessType()).isEqualTo(type); + assertThat(first.businessId()).isEqualTo("71"); + assertThat(first.fileId()).isEqualTo(501L); + assertThat(second.fileId()).isEqualTo(502L); + assertThat(first.fileName()).isEqualTo("evidence.pdf"); + assertThat(first.contentType()).isEqualTo("application/pdf"); + assertThat(first.sizeBytes()).isEqualTo(pdf().getSize()); + assertThat(first.sha256()).matches("[0-9a-f]{64}"); + assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt()); + assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey()); + assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes()); + assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes()); + verify(files, never()).updateById(any(FileObjectEntity.class)); + } + + /** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var row = new FileObjectEntity(); + row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71"); + row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf"); + row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize()); + row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now()); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(boundValue(query, "status")).isEqualTo("ACTIVE"); + assertThat(boundValue(query, "deleted")).isEqualTo(false); + return Long.valueOf(0).equals(boundValue(query, "tenant_id")) + && type.equals(boundValue(query, "business_type")) + && "71".equals(boundValue(query, "business_id")) + && Long.valueOf(501).equals(boundValue(query, "id")) ? row : null; + }); + when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes()); + var metadata = service.metadata(0, type, "71", 501); + assertThat(metadata.fileId()).isEqualTo(501L); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); + assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes()); + verify(storage).read(row.getObjectKey()); + clearInvocations(storage); + String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE; + for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) { + assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.download(0, candidate, "71", 501)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class); + verifyNoInteractions(storage); + } + + /** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) { + var service = mock(InternalBusinessFileService.class); + var controller = new InternalBusinessFileController(); + ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token"); + ReflectionTestUtils.setField(controller, "service", service); + for (String token : new String[]{null, "", "wrong-token"}) { + assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class); + } + verifyNoInteractions(service); + } + + /** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */ + private static Object boundValue(LambdaQueryWrapper query, String column) { + var matcher = Pattern.compile("(? 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 -- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 -- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 -- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 -- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 -- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 -- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 -- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 -- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 -- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 -- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。 -- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 -- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 -- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。 -- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 -- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 - -## 国内货柜履约装柜与 PDA 资源结算 - -- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 -- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 -- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 -- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 -- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 -- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 -- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 -- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 -- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 -- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。 +- 平台接口核对目标客户、权限、版本、幂等、历史与审计;客户查询只使用认证客户。不得跨服务读库或在本模块复制 Order、Warehouse、Finance、File 的权威事实。 +- Mapper 显式 `@Mapper`,只用启动类根包扫描;新增/移动 Mapper 或扫描配置变化时,从后端根执行 `node scripts/mapper-scan-contract.mjs`。 +- Java 签名与行为变更分别遵循后端根的测试编译及直接受影响测试门禁;本模块测试从后端根执行 `mvn -pl firstleg -am test`。 +- 移包或跨切片依赖变化时,从后端根执行 `node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs` 并核对内部模块化基线;禁止用基线更新掩盖临时循环依赖。 +- 对外 API、数据库、公共签名、事件或权限变化按后端根对应门禁核对直接消费者,不为文档整理启动 Java、数据库、HTTP 或部署。 +- 本文件或详细索引变更后,统一执行一次 `./scripts/agents-index-contract.ps1`;详细条款迁移不得丢失业务语义,不提高读取预算掩盖增长。 ## 全量验收导航 -本模块承载订单履约、入库测量计费、订单编排、柜次、排柜、订舱、拖车、PDA 和 `LOADED` 门禁等全量链路;按本次验收范围读取对应业务域文档、Controller、DTO、OpenAPI、当前 SQL 定义和调用方,不以历史规划终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。 +本服务覆盖测量计费、订单编排、装柜出仓及直接事件链。验收按受影响链路进入详细索引和正式业务域文档;PDA 设备证据与服务端合同分别核对,不以历史规划终点限制联调。仅作索引调整时运行离线门禁。 diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md new file mode 100644 index 000000000..f161fd472 --- /dev/null +++ b/firstleg/docs/business-index.md @@ -0,0 +1,107 @@ +# Firstleg 业务详细索引 + +本文件保存当前入口、业务边界与修改细则,作为 `../AGENTS.md` 的按需补充,不是历史归档。状态、权限、计价、资源或事件任务必须核对对应条款;不要求普通定位任务全文读取。 + +所有代码、SQL、脚本及 `../`、`../../` 路径均以 `firstleg/` 服务目录起算;业务包简写相对 `src/main/java/com/baiyue/firstleg/`,不相对于本文件所在的 `docs/`。从后端根用 `rg -n -F '类名或业务词' firstleg/docs/business-index.md` 读取命中行和相关规则段。 + +维护时只修改所属条款,主 AGENTS 表只维护路由;接口以服务端合同为准,架构及运行状态沿工作区根指定主契约核对。 + +## 模块入口 + +预约日历到港只读:`portschedule/controller/PlatformPortScheduleController.java` 的 `/arrival-calendar`,沿 `PortScheduleService`、`PortScheduleMapper` 查询有效柜次及实际到港节点;合同 `src/main/resources/openapi/arrival-calendar-v1.yaml`,权限 `platform:appointment:list`。不在日历维护第二套船节点。 + +表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。 + +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + +柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 + +| 目标 | 入口 | +|---|---| +| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | +| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | +| 客户工作台筛选范围 | `service/MerchantWorkbenchFilterService.java`、`mapper/MerchantWorkbenchFilterMapper.java`、`dto/MerchantWorkbenchFilters.java`;内部入口 `InternalInboundOrderController` 的 `/unified-orders/workbench-filters`,按客户只读计算履约、费用待确认与最新已装柜计划的报关待上传订单范围,Order 负责最终筛选和分页 | +| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | +| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 | +| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | +| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | +| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成;所有头程箱唛打印面均不显示 SKU,SKU 只保留在业务快照及一件代发 A4 托盘信息单;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托、逐托封托和在未进入装柜计划前解除封托;合托解封时同步恢复所有参与任务及入仓单。预计托数与已生成托盘数不一致时禁止作业,全部箱和实际参与托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | +| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | +| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | +| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | +| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | +| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | +| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | +| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | +| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | +| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | +| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | +| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` | +| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | +| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | +| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | +| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | +| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | +| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | +| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | +| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | +| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | +| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | +| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | +| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | +| 已开船预约事项完整来源 | `portschedule/controller/InternalAppointmentWorkSourceController.java`、`service/AppointmentWorkSourceService.java`、`mapper/AppointmentWorkSourceMapper.java`;按完整来源版本游标提供所有实际开船或后续节点的有效货柜,保留无仓绑定与完成货柜;原始当地时间无时区时不得转UTC;契约 `src/main/resources/openapi/appointment-work-source-v1.yaml` | +| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` | +| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | +| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | +| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | +| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | +| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | +| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | +| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | +| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`LastlegEventClient.java` | +| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | +| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | + +## 修改约束 + +- 移动包、拆分端口/适配器或引入新的跨切片依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-firstleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 +- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。 +- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 +- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 +- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 +- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 +- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 +- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 +- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 +- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 +- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 +- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 +- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 +- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 +- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。 +- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 +- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 +- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。 +- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 +- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 + +## 国内货柜履约装柜与 PDA 资源结算 + +- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 +- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 +- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 +- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 +- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 +- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 +- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 +- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 +- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 +- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。 + diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java index 019125cae..5f56392b0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java @@ -4,6 +4,9 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import java.math.BigDecimal; /** 费用项目请求模型。 */ public final class FeeItemDtos { @@ -21,26 +24,9 @@ public final class FeeItemDtos { @NotNull Boolean warehousePayable, @NotNull Boolean warehouseReceivable, @NotNull Boolean allocationFee, - @Size(max = 500) String description + @Size(max = 500) String description, + @DecimalMin("0.00") @Digits(integer = 10, fraction = 2) BigDecimal defaultPrice ) { - public SaveRequest(String category, String tradeScope, String feeCode, - String feeTypeName, Boolean pickupPayable, - Boolean pickupReceivable, Boolean warehousePayable, - Boolean warehouseReceivable, Boolean allocationFee, - String description) { - this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable, - pickupReceivable, warehousePayable, warehouseReceivable, - allocationFee, description); - } - - /** Backward-compatible constructor for callers predating the stable fee code. */ - public SaveRequest(String category, String tradeScope, String feeTypeName, - Boolean pickupPayable, Boolean pickupReceivable, - Boolean warehousePayable, Boolean warehouseReceivable, - Boolean allocationFee, String description) { - this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable, - warehousePayable, warehouseReceivable, allocationFee, description); - } } public record StatusRequest( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java index 2cc751bb3..414877558 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java @@ -1,11 +1,14 @@ package com.baiyue.firstleg.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baiyue.common.core.model.BaseEntity; import lombok.Getter; import lombok.Setter; +import java.math.BigDecimal; /** 费用项目主数据。 */ @Getter @@ -20,6 +23,9 @@ public class FeeItemEntity extends BaseEntity { private String feeCode; private String supplierCategory; private String feeTypeName; + /** 业务选费时带入的默认价格,可清空且不限制实际成交金额。 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private BigDecimal defaultPrice; private Boolean pickupPayable; private Boolean pickupReceivable; private Boolean warehousePayable; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java index 6d7258f87..8948e0317 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java @@ -101,6 +101,7 @@ public class FeeItemService { } } + /** 保存项目属性及可清空的默认价格,实际费用仍以业务录入金额为准。 */ private static void copy(FeeItemEntity value, SaveRequest request) { boolean payable = Boolean.TRUE.equals(request.pickupPayable()) || Boolean.TRUE.equals(request.warehousePayable()); @@ -111,6 +112,7 @@ public class FeeItemService { value.setSupplierCategory(payable ? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null); value.setFeeTypeName(request.feeTypeName().trim()); + value.setDefaultPrice(request.defaultPrice()); value.setPickupPayable(request.pickupPayable()); value.setPickupReceivable(request.pickupReceivable()); value.setWarehousePayable(request.warehousePayable()); diff --git a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql index 1338459d4..9b3d5e4ec 100644 --- a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql +++ b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item ( fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码', supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商', fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称', + default_price DECIMAL(12,2) NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改', pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与', pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与', warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与', diff --git a/firstleg/src/main/resources/openapi/fee-item-v1.yaml b/firstleg/src/main/resources/openapi/fee-item-v1.yaml index 31ba1ec1d..b8f0e5097 100644 --- a/firstleg/src/main/resources/openapi/fee-item-v1.yaml +++ b/firstleg/src/main/resources/openapi/fee-item-v1.yaml @@ -102,6 +102,7 @@ components: feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'} supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier} feeTypeName: {type: string, maxLength: 128} + defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;为空不自动带价,业务录入时可修改,不追改已有费用} pickupPayable: {type: boolean} pickupReceivable: {type: boolean} warehousePayable: {type: boolean} @@ -118,6 +119,7 @@ components: feeCode: {type: string} supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier} feeTypeName: {type: string} + defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;仅用于业务选择费用项目时带入} pickupPayable: {type: boolean} pickupReceivable: {type: boolean} warehousePayable: {type: boolean} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java index 1f89e555e..d1e6b7f6b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java @@ -96,6 +96,10 @@ class FeeManagementContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); assertThat(schemas).containsKeys("FeeItemSaveRequest", "FeeItemResource", "FeeItemPage", "FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse"); + for (String schema : List.of("FeeItemSaveRequest", "FeeItemResource")) { + assertThat(stringMap(properties(schemas, schema).get("defaultPrice"))) + .containsEntry("nullable", true).containsEntry("minimum", 0); + } for (String response : List.of("FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse")) { assertThat(list(stringMap(schemas.get(response)).get("required"))).contains("timestamp"); assertThat(properties(schemas, response)).containsKeys( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index a0fdc7b00..1cf2d0953 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -110,6 +110,7 @@ class FeeManagementServiceTest { assertThat(created.getId()).isEqualTo(11L); assertThat(created.getAllocationFee()).isTrue(); + assertThat(created.getDefaultPrice()).isEqualByComparingTo("60.00"); assertThat(created.getWarehouseReceivable()).isTrue(); assertThat(created.getWarehousePayable()).isTrue(); assertThat(created.getSupplierCategory()).isNull(); @@ -122,7 +123,7 @@ class FeeManagementServiceTest { @DisplayName("自定义费用编码统一转为大写并检查客户内唯一性") void normalizesCustomFeeCode() { FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", - "storage_fee", "仓库租赁费", false, false, true, true, true, "应付仓库租赁费"); + "storage_fee", null, "仓库租赁费", false, false, true, true, true, "应付仓库租赁费", null); FeeItemEntity created = feeItems.create(9L, "admin", request); @@ -134,7 +135,7 @@ class FeeManagementServiceTest { @DisplayName("并发创建相同费用编码返回业务冲突") void rejectsConcurrentDuplicateFeeCode() { FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", - "STORAGE_FEE", "仓库租赁费", false, false, true, true, true, null); + "STORAGE_FEE", null, "仓库租赁费", false, false, true, true, true, null, null); doThrow(new DuplicateKeyException("duplicate fee code")).when(repository).insertItem(any()); assertThatThrownBy(() -> feeItems.create(9L, "admin", request)) @@ -156,6 +157,38 @@ class FeeManagementServiceTest { verify(repository, never()).itemCodeExists("STORAGE_FEE", 11L); } + /** 清空默认价只修改公共项目,不触碰已生成的费用。 */ + @Test + void clearsDefaultPrice() { + FeeItemEntity existing = item(true, true); + existing.setDefaultPrice(new BigDecimal("60.00")); + when(repository.findItem(11L)).thenReturn(existing); + when(repository.updateItem(any())).thenReturn(1); + FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", + null, null, "仓库租赁费", false, false, true, true, true, null, null); + + assertThat(feeItems.update(11L, 9L, "admin", request).getDefaultPrice()).isNull(); + verify(repository).updateItem(existing); + } + + /** 请求价格允许空值和零元,拒绝负数、过多小数和超出字段范围的金额。 */ + @Test + void validatesDefaultPrice() { + try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + for (String price : List.of("0", "60.25", "9999999999.99")) { + var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null, + "操作费", false, true, false, false, false, null, new BigDecimal(price)); + assertThat(validator.validate(request)).isEmpty(); + } + for (String price : List.of("-1", "60.255", "10000000000")) { + var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null, + "操作费", false, true, false, false, false, null, new BigDecimal(price)); + assertThat(validator.validate(request)).isNotEmpty(); + } + } + } + @Test @DisplayName("任一客户引用公共费用项目后都禁止删除") void rejectsDeletingItemBoundByAnyCustomer() { @@ -478,9 +511,10 @@ class FeeManagementServiceTest { eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class)); } + /** 构造带默认价格的公共费用项目。 */ private static FeeItemDtos.SaveRequest itemRequest() { - return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费", - false, false, true, true, true, "应付仓库租赁费"); + return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", null, null, "仓库租赁费", + false, false, true, true, true, "应付仓库租赁费", new BigDecimal("60.00")); } private static InboundFeeDetailDtos.SaveRequest detailRequest(String direction) { diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index cb9388409..1fa6fa1b3 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -31,7 +31,6 @@ VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), - (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), @@ -283,8 +282,7 @@ SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' AND permission.permission_code IN ( - 'platform:overseas-warehouse:inbound-order:fba-appointment', - 'platform:overseas-warehouse:inbound-order:smart-dispatch' + 'platform:overseas-warehouse:inbound-order:fba-appointment' ) WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' diff --git a/iam/src/main/resources/db/phase5-delivery-permissions.sql b/iam/src/main/resources/db/phase5-delivery-permissions.sql index 5d850ee2b..69a5fc79e 100644 --- a/iam/src/main/resources/db/phase5-delivery-permissions.sql +++ b/iam/src/main/resources/db/phase5-delivery-permissions.sql @@ -9,6 +9,9 @@ BEGIN IF EXISTS ( SELECT 1 FROM iam_permission WHERE (id = 5107064 AND permission_code <> 'platform:overseas-warehouse:dispatch:list') + OR (id = 5107065 AND permission_code <> 'platform:overseas-warehouse:dispatch:create') + OR (id = 5107066 AND permission_code <> 'platform:overseas-warehouse:dispatch:loading-complete') + OR (id = 5107067 AND permission_code <> 'platform:overseas-warehouse:dispatch:depart') OR (id = 5107016 AND permission_code <> 'platform:overseas-warehouse:dispatch:edit') OR (id = 5107017 AND permission_code <> 'platform:overseas-warehouse:dispatch:cancel') OR (id = 5107018 AND permission_code <> 'platform:overseas-warehouse:dispatch:transfer-delivery') @@ -25,10 +28,12 @@ BEGIN OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit') OR (id = 5107082 AND permission_code <> 'platform:overseas-warehouse:delivery:status') OR (id = 5306000 AND permission_code <> 'merchant:warehouse:delivery:list') - OR (id = 5306001 AND permission_code <> 'merchant:warehouse:delivery:create') ) OR EXISTS ( SELECT 1 FROM iam_permission WHERE (permission_code = 'platform:overseas-warehouse:dispatch:list' AND id <> 5107064) + OR (permission_code = 'platform:overseas-warehouse:dispatch:create' AND id <> 5107065) + OR (permission_code = 'platform:overseas-warehouse:dispatch:loading-complete' AND id <> 5107066) + OR (permission_code = 'platform:overseas-warehouse:dispatch:depart' AND id <> 5107067) OR (permission_code = 'platform:overseas-warehouse:dispatch:edit' AND id <> 5107016) OR (permission_code = 'platform:overseas-warehouse:dispatch:cancel' AND id <> 5107017) OR (permission_code = 'platform:overseas-warehouse:dispatch:transfer-delivery' AND id <> 5107018) @@ -45,7 +50,6 @@ BEGIN OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081) OR (permission_code = 'platform:overseas-warehouse:delivery:status' AND id <> 5107082) OR (permission_code = 'merchant:warehouse:delivery:list' AND id <> 5306000) - OR (permission_code = 'merchant:warehouse:delivery:create' AND id <> 5306001) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限ID或编码冲突'; END IF; @@ -59,8 +63,11 @@ INSERT INTO iam_permission permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'ACTIVE',1,0), + (5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create','新增排车单',NULL,'POST',765,'ACTIVE',1,0), + (5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete','确认装车完成',NULL,'POST',766,'ACTIVE',1,0), + (5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart','确认实际发车',NULL,'POST',767,'ACTIVE',1,0), (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,0), - (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'ACTIVE',1,0), + (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,0), (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0), (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0), (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0), @@ -74,8 +81,7 @@ VALUES (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), (5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'ACTIVE',1,0), (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0), - (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0), - (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0) + (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), @@ -92,6 +98,9 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -117,8 +126,7 @@ SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3) WHERE role.tenant_id>0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') AND permission.status='ACTIVE' AND permission.deleted=0; -- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。 @@ -130,6 +138,9 @@ CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission ( ) ENGINE=MEMORY; INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VALUES ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:create'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:loading-complete'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:depart'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:cancel'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:transfer-delivery'), @@ -160,6 +171,9 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_TALLY','OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -186,7 +200,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0; --- 客户默认管理员获得派送列表/详情只读及独立创建;其他客户角色不自动扩权。 +-- 客户默认管理员只获得派送列表和详情读取;整车派送由平台统一安排。 INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) FROM iam_role role @@ -194,8 +208,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') AND permission.status='ACTIVE' AND permission.deleted=0; DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; @@ -209,6 +222,9 @@ BEGIN WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 AND permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -224,8 +240,7 @@ BEGIN 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:edit', - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create')) <> 18 THEN + 'merchant:warehouse:delivery:list')) <> 20 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -234,6 +249,9 @@ BEGIN WHERE role.role_code='PLATFORM_ADMIN' AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -248,7 +266,7 @@ BEGIN 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:customer-visit:edit')) <> 16 THEN + 'platform:overseas-warehouse:customer-visit:edit')) <> 19 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; END IF; IF EXISTS (SELECT 1 FROM iam_role role @@ -260,6 +278,9 @@ BEGIN WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -274,14 +295,14 @@ BEGIN 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:customer-visit:edit')) <> 16) THEN + 'platform:overseas-warehouse:customer-visit:edit')) <> 19) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; END IF; SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count FROM tmp_phase5_delivery_role_permission; - IF v_warehouse_operator_permission_count <> 16 + IF v_warehouse_operator_permission_count <> 19 OR v_warehouse_tally_permission_count <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; END IF; @@ -291,6 +312,9 @@ BEGIN WHERE role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -313,11 +337,10 @@ BEGIN ON grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id LEFT JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' AND role.status='ACTIVE' AND role.deleted=0 - GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 2) THEN + GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 1) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整'; END IF; END$$ diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index cd0957681..d96d8e78d 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -146,7 +146,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:create`、`platform:overseas-warehouse:dispatch:loading-complete`、`platform:overseas-warehouse:dispatch:depart`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 @@ -364,7 +364,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板的列表入口分别返回 `merchant:cross-border:procurement:list`、`merchant:cross-border:domestic-trucking:list`、`merchant:cross-border:pallet-shipping:list`,创建和编辑另行授权;海外仓入库返回 `merchant:warehouse:inbound:list`,尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:overseas-warehouse:*` 权限。客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。 + description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板的列表入口分别返回 `merchant:cross-border:procurement:list`、`merchant:cross-border:domestic-trucking:list`、`merchant:cross-border:pallet-shipping:list`,创建和编辑另行授权;海外仓入库返回 `merchant:warehouse:inbound:list`,尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:overseas-warehouse:*` 权限。客户派送仅返回 `merchant:warehouse:delivery:list`,整车派送由平台统一安排。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。 responses: '200': description: 当前商户成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index d23670498..936df385e 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -10,6 +10,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class IamOpenApiContractTest { + /** 核对当前授权接口公开说明,整车写权限独立且客户派送只读。 */ @Test void shouldHandleCase01() throws IOException { String document; @@ -21,6 +22,9 @@ class IamOpenApiContractTest { assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:") .contains("/api/merchant/v1/iam/authorization/permissions:") .contains("platform:overseas-warehouse:dispatch:list") + .contains("platform:overseas-warehouse:dispatch:create") + .contains("platform:overseas-warehouse:dispatch:loading-complete") + .contains("platform:overseas-warehouse:dispatch:depart") .contains("platform:overseas-warehouse:dispatch:edit") .contains("platform:overseas-warehouse:dispatch:cancel") .contains("platform:overseas-warehouse:dispatch:transfer-delivery") @@ -45,7 +49,7 @@ class IamOpenApiContractTest { .contains("platform:firstleg:terminal-drayage:empty-return:confirm") .contains("不以 `container:list/edit` 兼容放行国外操作") .contains("merchant:warehouse:delivery:list") - .contains("merchant:warehouse:delivery:create") + .doesNotContain("merchant:warehouse:delivery:create") .contains("merchant:cross-border:procurement:list") .contains("merchant:cross-border:domestic-trucking:list") .contains("merchant:cross-border:pallet-shipping:list") 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 4314ad8fe..619cd0d41 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -39,8 +39,7 @@ class OverseasPhase4PermissionSqlContractTest { private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); private static final Set CUSTOM_ADMIN_PERMISSIONS = Set.of( - "platform:overseas-warehouse:inbound-order:fba-appointment", - "platform:overseas-warehouse:inbound-order:smart-dispatch"); + "platform:overseas-warehouse:inbound-order:fba-appointment"); private static final List REQUIRED = List.of( "platform:overseas-warehouse:last-mile-order:list", "platform:overseas-warehouse:discard:list", @@ -48,7 +47,6 @@ class OverseasPhase4PermissionSqlContractTest { "platform:overseas-warehouse:warehouse-transfer:approve", "platform:overseas-warehouse:inbound-order:list", "platform:overseas-warehouse:inbound-order:fba-appointment", - "platform:overseas-warehouse:inbound-order:smart-dispatch", "platform:overseas-warehouse:relabel-order:status", "platform:overseas-warehouse:pallet:list", "platform:overseas-warehouse:inventory:adjust", @@ -77,7 +75,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(71).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(70).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()", @@ -85,7 +83,8 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).doesNotContain( "'platform:overseas-warehouse:warehouse-transfer:list','仓储转运审核'"); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); - assertThat(sql).doesNotContain(":carton:", ":product:"); + assertThat(sql).doesNotContain(":carton:", ":product:", + "platform:overseas-warehouse:inbound-order:smart-dispatch"); assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); assertCustomAdminGrant(sql); assertCustomAdminNavigationGrant(sql); diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java index ee5ac806d..9d97b9d25 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -16,6 +16,9 @@ class Phase5DeliveryPermissionSqlContractTest { private static final Pattern CODE = Pattern.compile("(?:platform:overseas-warehouse|merchant:warehouse):[a-z0-9:-]+"); private static final Set REQUIRED = Set.of( "platform:overseas-warehouse:dispatch:list", + "platform:overseas-warehouse:dispatch:create", + "platform:overseas-warehouse:dispatch:loading-complete", + "platform:overseas-warehouse:dispatch:depart", "platform:overseas-warehouse:dispatch:edit", "platform:overseas-warehouse:dispatch:cancel", "platform:overseas-warehouse:dispatch:transfer-delivery", @@ -31,15 +34,16 @@ class Phase5DeliveryPermissionSqlContractTest { "platform:overseas-warehouse:delivery:documents", "platform:overseas-warehouse:customer-visit:list", "platform:overseas-warehouse:customer-visit:edit", - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"); + "merchant:warehouse:delivery:list"); + /** 核对整车权限目录只包含当前正式编码。 */ @Test void definesOnlyTheFrozenPhase5DeliveryCatalogWithoutAliases() throws Exception { String sql = sql(); assertThat(codes(sql)).containsExactlyInAnyOrderElementsOf(REQUIRED); assertThat(codes(sql)).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).doesNotContain("merchant:warehouse:delivery:create"); assertThat(sql).contains( "guard_phase5_delivery_permission_ids", "权限ID或编码冲突", @@ -47,27 +51,32 @@ class Phase5DeliveryPermissionSqlContractTest { "列表权限同时覆盖列表和详情只读"); } + /** 核对菜单、动作方法及独立装车发车权限。 */ @Test void keepsMenuRoutesAndHttpMethodsAlignedWithTheFrozenAdminAndClientContracts() throws Exception { String sql = sql(); assertThat(sql).contains( "'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET'", - "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", + "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST'", "'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET'", "'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'", "'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'", + "(5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create'", + "(5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete'", + "(5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart'", "(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST'", "'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'", "'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT'", "'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET'"); assertThat(sql).doesNotContain( - "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST'", + "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", "'merchant:warehouse:delivery:list','海外仓派送单','/overseas-warehouse/delivery-orders'"); } + /** 核对后台操作岗位、理货只读岗位与PDA之间的最小授权边界。 */ @Test void grantsAdministratorsAndWarehouseRolesByTheFrozenMinimumMatrix() throws Exception { String sql = sql(); @@ -76,6 +85,9 @@ class Phase5DeliveryPermissionSqlContractTest { "UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')", "tmp_phase5_delivery_role_permission", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:list')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:create')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:loading-complete')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:depart')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create')", @@ -92,6 +104,9 @@ class Phase5DeliveryPermissionSqlContractTest { "role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')", "PDA岗位错误获得后台权限"); assertThat(sql).doesNotContain( + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:create')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:loading-complete')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:depart')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:create')", @@ -104,6 +119,7 @@ class Phase5DeliveryPermissionSqlContractTest { "('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:"); } + /** 核对临时角色矩阵一次读取及完整性判断。 */ @Test void validatesTheWarehouseRoleMatrixWithOneTemporaryTableReadPerStatement() throws Exception { String sql = sql(); @@ -114,7 +130,7 @@ class Phase5DeliveryPermissionSqlContractTest { "SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)", "COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)", "INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count", - "IF v_warehouse_operator_permission_count <> 16", + "IF v_warehouse_operator_permission_count <> 19", "OR v_warehouse_tally_permission_count <> 5 THEN", "IF EXISTS (SELECT 1 FROM iam_role role", "AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", @@ -126,10 +142,11 @@ class Phase5DeliveryPermissionSqlContractTest { "OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission"); } + /** 客户默认管理员只获得本客户派送读取权限。 */ @Test void grantsOnlyTheDefaultCustomerAdministratorAutomatically() throws Exception { String sql = sql(); - int marker = sql.indexOf("-- 客户默认管理员获得派送列表/详情只读及独立创建"); + int marker = sql.indexOf("-- 客户默认管理员只获得派送列表和详情读取"); int assertion = sql.indexOf("DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions", marker); assertThat(marker).isGreaterThanOrEqualTo(0); assertThat(assertion).isGreaterThan(marker); @@ -137,11 +154,11 @@ class Phase5DeliveryPermissionSqlContractTest { String grant = sql.substring(marker, assertion); assertThat(grant).contains( "role.role_code='MERCHANT_OWNER'", - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"); - assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN"); + "merchant:warehouse:delivery:list"); + assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN", "merchant:warehouse:delivery:create"); } + /** 国内空库基线必须同步当前权限与默认角色关联。 */ @Test void domesticCleanInitializationContainsTheCompletePermissionCatalog() throws Exception { String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); @@ -151,7 +168,10 @@ class Phase5DeliveryPermissionSqlContractTest { .contains("(4000004,5107064,0,CURRENT_TIMESTAMP(3))") .contains("(4000004,5107094,0,CURRENT_TIMESTAMP(3))") .contains("(4000100,5306000,0,CURRENT_TIMESTAMP(3))") - .contains("(4000100,5306001,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107065,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107066,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107067,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("merchant:warehouse:delivery:create", "(4000100,5306001,0,CURRENT_TIMESTAMP(3))") .doesNotContain("guard_phase5_delivery_permission_ids", "tmp_phase5_delivery_role_permission"); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java index 3d58e8216..cd2209cc3 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java @@ -20,6 +20,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { private static final Pattern PLATFORM_CODE = Pattern.compile("platform:overseas-warehouse:[a-z0-9:-]+"); + /** 没有自定义管理员时不产生虚假的缺权错误。 */ @Test void noCustomAdministratorDoesNotCreateAnAssertionViolation() throws Exception { try (Connection connection = database()) { @@ -29,6 +30,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { } } + /** 按当前完整目录补齐管理员授权,重复执行不增加关联。 */ @Test void onePartiallyGrantedCustomAdministratorIsCompletedIdempotently() throws Exception { try (Connection connection = database()) { @@ -37,50 +39,53 @@ class Phase5DeliveryPermissionSqlExecutionTest { assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(16); + assertThat(grantCount(connection, 11L)).isEqualTo(19); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(16); - assertThat(totalGrantCount(connection)).isEqualTo(16); + assertThat(grantCount(connection, 11L)).isEqualTo(19); + assertThat(totalGrantCount(connection)).isEqualTo(19); } } + /** 分别核对每个管理员,缺少任一权限不能被其他角色抵消。 */ @Test void multipleCustomAdministratorsExposeTheSingleIncompleteRole() throws Exception { try (Connection connection = database()) { insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 22L, "Platform_Custom_Admin"); - grantFirstPermissions(connection, 21L, 16); - grantFirstPermissions(connection, 22L, 15); + grantFirstPermissions(connection, 21L, 19); + grantFirstPermissions(connection, 22L, 18); assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 21L)).isEqualTo(16); - assertThat(grantCount(connection, 22L)).isEqualTo(16); + assertThat(grantCount(connection, 21L)).isEqualTo(19); + assertThat(grantCount(connection, 22L)).isEqualTo(19); assertThat(customAdministratorViolations(connection)).isZero(); } } + /** 完整管理员重复授权保持各自权限集合稳定。 */ @Test void multipleCompleteCustomAdministratorsRemainCompleteAfterReplay() throws Exception { try (Connection connection = database()) { insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 32L, "platform_custom_admin"); - grantFirstPermissions(connection, 31L, 16); - grantFirstPermissions(connection, 32L, 16); + grantFirstPermissions(connection, 31L, 19); + grantFirstPermissions(connection, 32L, 19); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); executeGrant(connection); - assertThat(grantCount(connection, 31L)).isEqualTo(16); - assertThat(grantCount(connection, 32L)).isEqualTo(16); - assertThat(totalGrantCount(connection)).isEqualTo(32); + assertThat(grantCount(connection, 31L)).isEqualTo(19); + assertThat(grantCount(connection, 32L)).isEqualTo(19); + assertThat(totalGrantCount(connection)).isEqualTo(38); } } + /** 管理员授权不得扩散到理货只读岗位或停用账号。 */ @Test void administratorGrantDoesNotEscalateAReadOnlyRoleOrDisabledAdministrator() throws Exception { try (Connection connection = database()) { @@ -99,7 +104,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { executeGrant(connection); executeGrant(connection); - assertThat(grantCount(connection, 41L)).isEqualTo(16); + assertThat(grantCount(connection, 41L)).isEqualTo(19); assertThat(grantCount(connection, 42L)).isEqualTo(1); assertThat(grantCount(connection, 43L)).isZero(); try (Statement statement = connection.createStatement(); @@ -206,6 +211,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { } } + /** 逐角色核对当前后台权限全集,避免跨角色合并计数。 */ private static int customAdministratorViolations(Connection connection) throws Exception { String placeholders = String.join(",", platformPermissions().stream().map(code -> "?").toList()); String sql = """ @@ -216,7 +222,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { AND (SELECT COUNT(*) FROM iam_role_permission grant_row JOIN iam_permission permission ON permission.id=grant_row.permission_id WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id - AND permission.permission_code IN (%s)) <> 16 + AND permission.permission_code IN (%s)) <> 19 """.formatted(placeholders); try (PreparedStatement query = connection.prepareStatement(sql)) { int index = 1; @@ -239,11 +245,12 @@ class Phase5DeliveryPermissionSqlExecutionTest { return sql.substring(start, end); } + /** 从实际管理员授予语句提取当前后台权限全集。 */ private static Set platformPermissions() throws Exception { LinkedHashSet codes = new LinkedHashSet<>(); String grant = administratorGrantSql(); PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add); - assertThat(codes).hasSize(16); + assertThat(codes).hasSize(19); return codes; } diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index f8d83d00f..f5359e6c1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -64,18 +64,16 @@ class AuthorizationSnapshotServiceTest { verify(mapper).findSnapshot(42L, 0L, 9L, "PLATFORM"); } + /** 按当前有效角色返回客户派送读取权限。 */ @Test - void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() { + void exposesCustomerDeliveryReadPermissionFromCurrentRoleAssignments() { when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"))); + "merchant:warehouse:delivery:list"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); - assertThat(result).containsExactlyInAnyOrder( - "merchant:warehouse:delivery:create", - "merchant:warehouse:delivery:list"); + assertThat(result).containsExactly("merchant:warehouse:delivery:list"); } @Test diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 6e6fbc6e6..5964dc0a0 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节为分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处。根规则已在上下文时不重复读取,本服务不另设例外。 ## 职责与边界 @@ -16,7 +15,7 @@ 业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。 -拼车接货与车次准备:`fleet/trip/`、`fleet/business/`;合同`fleet-preparation-v1.yaml`,流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 +拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;车次准备查同包 `FleetTripPreparationService.java`,本土业务接线查 `fleet/business/`;合同 `fleet-preparation-v1.yaml`,流程变更再查 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 @@ -55,8 +54,8 @@ | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | -| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java` | -| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java` | +| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | +| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` | @@ -73,10 +72,10 @@ | 司机档案、证照上传/审核、资格预览与历史 | `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`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联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` 在新拣货/完成前核对当前单审核;已成功操作的同键重放保留原结果 | +| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 外接询价、确认占位及取消 | `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` 为车次事务内子操作,不独立对外调用 | +| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 | | 车次货物结案、车辆和司机分别释放 | `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` | @@ -85,29 +84,7 @@ ## 海外仓全流程开发路由 -- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 -- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 -- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 -- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 - -- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。 - -- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 -- 服务端正式入口包括 `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/signedPackages,实退须原来源入账流水/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请求发出认定账务已撤销。 +本服务覆盖尾程入仓、库存、库内作业、排车/PDA、派送、异常、采购和车队。状态、数量、资金、资源或跨单联动变化前,须查 `docs/business-index.md` 本节条款;同柜/车/托不合并客户、仓库或来源,页面资格只取服务端结果。 ## 数据库表索引与注释约束 @@ -119,53 +96,16 @@ ## 事件建单规则 -- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 -- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 -- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 -- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 -- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 -- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 -- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 -- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 -- 通用 `/internal/lastleg/events` 只白名单接收装柜完成、实际船节点与资金结果事件;未知或IAM安全事件必须拒绝。 -- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 -- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 +校验事件身份、客户/来源、版本/hash、Inbox 幂等与事务;装柜事件不自动完成入仓。修改前查 `docs/business-index.md` 本节条款,ACK/重试按统一通信契约。 ## 查询与租户约束 -- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 -- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 -- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 -- 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。普通尾程入库只确认某入仓单在托盘上的箱数,没有逐箱唛到托盘的快照;贴标下单由客户填写我方箱唛,库存仓位只作为冻结记账锚点和现场大概位置,不能据此声称箱唛位于某个具体托盘。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。 -- 换标箱唛经现场确认后执行 `pallet-operations/relabel-disassemble`:允许仅移动该换标单对应的 `hold_packages`,不得解冻为普通可用库存;事务内同步更新 `ll_relabel_item.source_inventory_position_id`。若客户确认后已生成换标出库单,还必须在拣货开始前同步拆分或改写 `ll_outbound_pallet_item` 的托盘、箱唛、箱数与来源仓位快照,使 PDA 后续按新旧托盘分别扫描且每个指定箱唛只出库一次。 -- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 -- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 -- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 -- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 -- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 -- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 -- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 +查询、详情、导出与写入保持一致客户隔离、访问域和绑定仓,不信任请求归属。采购、废弃、主/子状态变化前查 `docs/business-index.md` 本节条款。 ## 修改与验证 -- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。 - -- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。 - -- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。 - -- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。 - -- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。 - -- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。 - -- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 - -- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 - -- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 -- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 -- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。 -- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 -- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 +- 后端根数据库分层、中文注释、API、Mapper、事件和权限门禁全部适用,SQL遵循本文件数据库约束。 +- 换标/托盘/返工、客户状态、锁与并发行为变化前,须查 `docs/business-index.md` 本节及对应条款。 +- 跨子域依赖变化核对 `docs/lastleg-internal-modularization-baseline.md`,从后端根运行 `node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs`;不以基线掩盖循环依赖。 +- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`;事件回归覆盖重复/来源唯一性、回滚、并发单号、内部身份及客户越权。 +- 指引/索引变更统一运行 `./scripts/agents-index-contract.ps1`,不以离线检查代替部署/验收。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 0ec4d6a1e..37aa6e714 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -80,3 +80,82 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | + +## 海外仓全流程开发路由 + +- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 +- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 +- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 +- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 + +- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。 + +- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 +- 服务端正式入口包括 `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,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 +- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 +- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 +- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 +- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 +- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 +- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 +- 仓库 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请求发出认定账务已撤销。 + +## 事件建单规则 + +- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 +- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 +- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 +- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 +- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 +- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 +- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- 通用 `/internal/lastleg/events` 只白名单接收装柜完成、实际船节点与资金结果事件;未知或IAM安全事件必须拒绝。 +- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 + +## 查询与租户约束 + +- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 +- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 +- 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。普通尾程入库只确认某入仓单在托盘上的箱数,没有逐箱唛到托盘的快照;贴标下单由客户填写我方箱唛,库存仓位只作为冻结记账锚点和现场大概位置,不能据此声称箱唛位于某个具体托盘。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。 +- 换标箱唛经现场确认后执行 `pallet-operations/relabel-disassemble`:允许仅移动该换标单对应的 `hold_packages`,不得解冻为普通可用库存;事务内同步更新 `ll_relabel_item.source_inventory_position_id`。若客户确认后已生成换标出库单,还必须在拣货开始前同步拆分或改写 `ll_outbound_pallet_item` 的托盘、箱唛、箱数与来源仓位快照,使 PDA 后续按新旧托盘分别扫描且每个指定箱唛只出库一次。 +- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 +- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 +- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 +- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 +- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 +- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 +- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 + +## 修改与验证 + +- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。 + +- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。 + +- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。 + +- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。 + +- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。 + +- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。 + +- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 + +- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 + +- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 +- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。 +- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 +- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 0428cd0ff..80182e0a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -2,18 +2,26 @@ package com.baiyue.lastleg.appointmentwork; import jakarta.validation.constraints.*; import java.util.List; +import java.util.Map; /** Real dispatch allocation boundary, implemented by the original appointment domain. */ public interface AppointmentWorkDispatchPort { - record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId, + record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotNull @PositiveOrZero Long recordVersion,@NotBlank String customerId, @NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { } - record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { } + record PhysicalItem(Long itemId,String customerId,Long planPalletId,String sourceStage,Integer packages) { } record BatchSource(String sourceKey,String destinationKey,int packageCount) { } - record BatchReference(String batchKey,String batchNo,String customerId,List sources) { } + record BatchReference(String batchKey,String batchNo,Long recordVersion,String customerId,List sources) { } + record CandidatePhysical(String customerId,Long planPalletId) { } + /** sources数量由本托真实逐票量和原批次剩余量共同限定;不包含零余量的伪可选项。 */ + record CandidateBatches(List appointmentBatches,List blockReasons,boolean occupied) { } + /** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */ + Map candidateBatches(List candidates); /** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */ java.util.Map> references(List dispatchIds); - void bind(String customerId,long dispatchId,List items,List allocations); - void requireReplay(String customerId,long dispatchId,List allocations); + /** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */ + void lockBatches(Map expectedBatchVersions); + /** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */ + void bindItems(long dispatchId,List touchedItems,List allocations); void requireDeparture(String dispatchNo); /** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */ String departureBlockReason(String dispatchNo); 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 b2410ec24..7658c2a67 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 @@ -6,6 +6,10 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.delivery.service.DispatchDeliveryService; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,23 +26,34 @@ import org.springframework.web.server.ResponseStatusException; @Tag(name = "Outbound and delivery") public class DeliveryTransferController { @Resource private DeliveryTransferService service; + @Resource private DispatchDeliveryService dispatchDelivery; + /** 只读辅助入口复用同一整车组合预览,不保留单客户报价协议。 */ @GetMapping("/dispatches/{dispatchNo}/external-freight") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") - public ApiResponse externalFreight( + public ApiResponse> externalFreight( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { requirePlatform(user); - return ApiResponse.success(service.externalFreight(dispatchNo)); + return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups()); } + /** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */ + @GetMapping("/dispatches/{dispatchNo}/delivery-arrangement") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") + public ApiResponse arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + requirePlatform(user); + return ApiResponse.success(dispatchDelivery.preview(actor(user),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") - public ApiResponse transfer( + @Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单") + public ApiResponse transfer( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody DeliveryRequests.Transfer request) { + @Valid @RequestBody ArrangeDelivery request) { requirePlatform(user); - return ApiResponse.success(service.platformTransfer(dispatchNo, operator(user), request)); + return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request)); } @PostMapping("/outbound-orders/one-click-dropship") @@ -160,6 +175,8 @@ public class DeliveryTransferController { private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + /** 平台安排不能使用请求体提供的客户或操作者身份。 */ + private Actor actor(AuthenticatedUser user) { return new Actor(operator(user),ActorMode.PLATFORM,null); } private String listScope(AuthenticatedUser user, String requestedCustomer) { requirePlatform(user); return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java index 9c0827c27..ca3a5ddb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -31,14 +31,6 @@ public class MerchantDeliveryController { return ApiResponse.success(service.deliveryPage(customer(user), query)); } - @PostMapping - @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')") - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam String dispatchNo, - @Valid @RequestBody DeliveryRequests.Transfer request) { - return ApiResponse.success(service.merchantTransfer(customer(user), dispatchNo, operator(user), request)); - } - @GetMapping("/{deliveryNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @@ -71,5 +63,4 @@ public class MerchantDeliveryController { if (!"MERCHANT".equals(user.appCode()) || user.tenantId() <= 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); return String.valueOf(user.tenantId()); } - private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } } 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 index b18bd481e..8878c851b 100644 --- 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 @@ -2,8 +2,10 @@ 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.delivery.service.DispatchDeliveryService; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.PreviewGroup; import com.baiyue.lastleg.security.OverseasPermissions; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -16,13 +18,15 @@ import org.springframework.web.server.ResponseStatusException; @RequiredArgsConstructor @RequestMapping("/api/platform/v1/last-leg/dispatches") public class PlatformDeliveryFeeCoverageController { - private final DeliveryFeeCoverageService service; + private final DispatchDeliveryService service; + /** 原费辅助查询复用真实整车分组,不能再从头部推定单客户。 */ @GetMapping("/{dispatchNo}/fee-coverage") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") - public ApiResponse preview(@AuthenticationPrincipal AuthenticatedUser user, + 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)); + String operator=user.userId()==null?user.username():user.userId().toString(); + return ApiResponse.success(service.preview(new Actor(operator,ActorMode.PLATFORM,null),dispatchNo).groups()); } } 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 index 805b90c08..64e3c9e8d 100644 --- 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 @@ -9,10 +9,6 @@ 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 e4943f8a5..029acd54c 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,7 +6,6 @@ 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; @@ -16,46 +15,6 @@ import java.time.LocalDate; public final class DeliveryRequests { private DeliveryRequests() {} - public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, - @NotNull LocalDateTime plannedDeliveryAt, - @NotBlank @Size(max = 32) String deliveryMethod, - @Size(max = 128) String waybillNo, - @Size(max = 255) String carrierName, - @NotBlank @Size(max = 64) String pickupMethod, - @NotBlank @Size(max = 64) String loadingType, - @Size(max = 64) String vehicleType, - @jakarta.validation.constraints.Pattern(regexp = "15托|30托") String vehicleSize, - @Size(max = 128) String driverName, - @Size(max = 64) String driverPhone, - @Size(max = 64) String vehiclePlate, - @Size(max = 128) String appointmentAccount, - @Size(max = 128) String sellerName, - @Size(max = 1000) String remark, - @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, - @NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, - @NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency, - @NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount, - @Min(1) Long supplierId, - @Size(max = 200) String supplierName, - @Size(max = 32) String supplierCategory, - @jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, - LocalDateTime paidAt, - @jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, - @Size(max = 128) String paymentReferenceNo, - @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds, - @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity, - @Size(max = 32) String quantityUnit, - @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice, - LocalDate periodStart, - LocalDate periodEnd, - @Size(max = 500) String remark) {} - public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String documentNo, String lxCode, String warehouseId, String status, String dispatchNo, String waybillNo, String vehiclePlate, 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 a30ed1d3e..15d65e1de 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 @@ -8,14 +8,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; public final class DeliveryResponses { private DeliveryResponses() {} - public record TransferDelivery(String deliveryNo, String deliveryStatus, - String destinationWarehouseId, String destinationWarehouseCode, - String destinationWarehouseName, int palletCount, int packageCount) {} - - public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, - int palletCount, int packageCount, - List deliveries, boolean replayed) {} - public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes, String status, int palletCount, int packageCount, BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java new file mode 100644 index 000000000..d59c14ccc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.delivery.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 签收事务内保存的本地待汇总事实,不携带整车已完成结论。 */ +@Data +public class DeliveryFactsChanged { + private Long id; + private String customerId; + private Long dispatchId; + private String dispatchNo; + private Long deliveryId; + private String deliveryNo; + private Long deliveryVersion; + private String factsHash; + private LocalDateTime occurredAt; + private String state; + private Integer attemptCount; + private LocalDateTime nextAttemptAt; + private String lastErrorCode; + private LocalDateTime processedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java new file mode 100644 index 000000000..baeb4f5ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.delivery.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 每趟车唯一安排事实,保留完整客户结果及真实来源。 */ +@Data +public class DispatchDeliveryArrangement { + private Long id; + private Long dispatchOrderId; + private String dispatchNo; + private String arrangementKey; + private String businessHash; + private String cargoHash; + private String loadingCompletionHash; + private String originalCommandKey; + private String originalOperatorId; + private String resultJson; + private String cargoJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java index 3345ff9d3..d70dc6a4f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -16,6 +16,14 @@ public class OutboundPalletItem { private Long id; private String customerId; private Long outboundOrderId; + /** 排车出库明确关联原作业明细,不从托盘展示字段猜来源。 */ + private Long dispatchItemId; + /** 对应本次真实PDA拣货归属,作为客户和数量核对的不可变来源。 */ + private Long sourceAttributionId; + private Long sourceOrderId; + private Long sourceInventoryPositionId; + private Long sourceReturnReceiptId; + private Long sourceReturnCartonId; private Long warehousePalletId; private String palletNo; private String destinationWarehouseId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java new file mode 100644 index 000000000..5f70d7a76 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 本地签收信号的唯一写入、扫描、确认及失败记录。 */ +@Mapper +public interface DeliveryFactsChangedMapper { + /** 同一派送版本只登记一次,不覆盖首次不可变事实。 */ + @Insert(""" + INSERT INTO ll_delivery_facts_changed + (id,customer_id,dispatch_id,dispatch_no,delivery_id,delivery_no,delivery_version,facts_hash, + occurred_at,state,attempt_count,next_attempt_at) + VALUES (#{id},#{customerId},#{dispatchId},#{dispatchNo},#{deliveryId},#{deliveryNo}, + #{deliveryVersion},#{factsHash},#{occurredAt},'PENDING',0,#{occurredAt}) + ON DUPLICATE KEY UPDATE id=id + """) + int insertOnce(DeliveryFactsChanged fact); + + /** 唯一键等待后读取当前已保存事实,不能用旧快照确认异载荷重放。 */ + @Select(""" + SELECT * FROM ll_delivery_facts_changed + WHERE customer_id=#{customerId} AND delivery_id=#{deliveryId} AND delivery_version=#{version} + """) + DeliveryFactsChanged find(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId, + @Param("version") long version); + + /** 按已扫描信号身份读取,不取得父级之前的子行锁。 */ + @Select("SELECT * FROM ll_delivery_facts_changed WHERE id=#{id}") + DeliveryFactsChanged findById(@Param("id") long id); + + /** 普通读候选,不在申请整车父锁之前锁定信号行。 */ + @Select(""" + SELECT * FROM ll_delivery_facts_changed + WHERE state='PENDING' AND next_attempt_at<=#{now} + ORDER BY next_attempt_at,id LIMIT #{limit} + """) + List pending(@Param("now") LocalDateTime now, @Param("limit") int limit); + + /** 父级汇总同事务仅确认指定的已核对信号,不能吞掉之后产生的版本。 */ + @Update(""" + UPDATE ll_delivery_facts_changed SET state='PROCESSED',processed_at=#{now},last_error_code=NULL + WHERE id=#{id} AND dispatch_id=#{dispatchId} AND state='PENDING' + """) + int acknowledge(@Param("id") long id, @Param("dispatchId") long dispatchId, + @Param("now") LocalDateTime now); + + /** 汇总回滚后以独立短事务记录退避,不改成已完成。 */ + @Update(""" + UPDATE ll_delivery_facts_changed + SET attempt_count=attempt_count+1,last_error_code=#{errorCode},next_attempt_at=GREATEST(next_attempt_at,#{nextAttemptAt}) + WHERE id=#{id} AND state='PENDING' + """) + int failed(@Param("id") long id, @Param("errorCode") String errorCode, + @Param("nextAttemptAt") LocalDateTime nextAttemptAt); +} 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 9b949b501..9caf75f13 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,18 @@ import java.util.List; @Mapper public interface DeliveryOrderMapper extends BaseMapper { + /** 整车签收事实使用完整真实客户单据集合。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id") + List findAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 取得全部出库锁后一次取得全部派送锁,禁止逐客户混取来源锁。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id FOR UPDATE") + List lockAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 服务内签收信号按整车及派送号定位真实客户,不接受浏览器客户范围。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_no=#{dispatchNo} AND delivery_no=#{deliveryNo}") + List findForSignal(@Param("dispatchNo") String dispatchNo, @Param("deliveryNo") String deliveryNo); + @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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java index d83da8796..ccfb38ccf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java @@ -18,18 +18,22 @@ public interface DeliverySourceEvidenceMapper { "WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND id=#{orderId} FOR UPDATE") Source lockSource(@Param("customerId") String customerId, @Param("orderId") long orderId); + /** 从本客户真实扫码及逐票归属定位共享整车任务,不依赖不存在的任务头客户。 */ @Select("SELECT DISTINCT t.dispatch_order_id FROM ll_dispatch_pda_pick_order a " + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + - "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "JOIN ll_dispatch_pda_task t ON t.id=p.task_id " + "WHERE a.customer_id=#{customerId} AND a.order_id=#{orderId} ORDER BY t.dispatch_order_id") List dispatchIds(@Param("customerId") String customerId, @Param("orderId") long orderId); @Select("SELECT d.id AS delivery_id,d.dispatch_order_id AS dispatch_id,d.delivery_group_key AS group_key," + - "d.package_count AS packages,d.signed_packages,d.signed_at,ds.depart_at AS departed_at FROM ll_delivery_order d " + - "LEFT JOIN ll_dispatch_order ds ON ds.customer_id=d.customer_id AND ds.id=d.dispatch_order_id " + + "d.package_count AS packages,d.signed_packages,d.signed_at,ds.departed_at AS departed_at FROM ll_delivery_order d " + + "LEFT JOIN ll_dispatch_order ds ON ds.id=d.dispatch_order_id " + + "AND EXISTS (SELECT 1 FROM ll_dispatch_item di_member WHERE di_member.dispatch_order_id=ds.id AND di_member.customer_id=d.customer_id) " + "WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY d.id") + /** 头部不再代表单客户;由真实货物客户成员证明派送归属,保持单据客户过滤。 */ List deliveries(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); + /** 共享任务下只读取同一真实客户的扫码、订单和回仓凭据,卸货组沿原权威规则生成。 */ @Select("SELECT a.id AS attribution_id,t.dispatch_order_id AS dispatch_id," + "CASE WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL " + "THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) " + @@ -38,7 +42,7 @@ public interface DeliverySourceEvidenceMapper { "WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) ELSE NULL END AS group_key," + "a.order_id,a.packages,a.receipt_id,a.return_receipt_id,r.inventory_ledger_id AS return_ledger_id,r.received_packages AS return_packages FROM ll_dispatch_pda_pick_order a " + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + - "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "JOIN ll_dispatch_pda_task t ON t.id=p.task_id " + "LEFT JOIN ll_dispatch_item di ON di.customer_id=p.customer_id AND di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id " + "LEFT JOIN ll_return_stock_receipt r ON r.customer_id=a.customer_id AND r.id=a.return_receipt_id AND r.order_id=a.order_id " + "AND EXISTS (SELECT 1 FROM ll_inventory_ledger rl JOIN ll_exception_order re ON re.customer_id=rl.customer_id " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java new file mode 100644 index 000000000..9e7ed734b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baiyue.lastleg.delivery.entity.DispatchDeliveryArrangement; +import org.apache.ibatis.annotations.*; + +/** 整车唯一安排事实的持久化,不以客户出库头替代整车身份。 */ +@Mapper +public interface DispatchDeliveryArrangementMapper { + /** 普通读组合预览中的当前安排事实。 */ + @Select("SELECT * FROM ll_dispatch_delivery_arrangement WHERE dispatch_order_id=#{dispatchId}") + DispatchDeliveryArrangement find(@Param("dispatchId") long dispatchId); + + /** 调用方先持共同父锁,再读取唯一安排。 */ + @Select("SELECT * FROM ll_dispatch_delivery_arrangement WHERE dispatch_order_id=#{dispatchId} FOR UPDATE") + DispatchDeliveryArrangement lock(@Param("dispatchId") long dispatchId); + + /** 单车、安排号及首次请求键均由唯一约束防重复。 */ + @Insert(""" + INSERT INTO ll_dispatch_delivery_arrangement + (id,dispatch_order_id,dispatch_no,arrangement_key,business_hash,cargo_hash,loading_completion_hash, + original_command_key,original_operator_id,result_json,cargo_json,created_at) + VALUES (#{id},#{dispatchOrderId},#{dispatchNo},#{arrangementKey},#{businessHash},#{cargoHash}, + #{loadingCompletionHash},#{originalCommandKey},#{originalOperatorId},#{resultJson},#{cargoJson},#{createdAt}) + """) + int insert(DispatchDeliveryArrangement arrangement); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java index a71e718ce..c560d427c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -9,6 +9,14 @@ import java.util.List; @Mapper public interface OutboundOrderMapper extends BaseMapper { + /** 整车跨客户读取全部出库单,不用第一张代表全车。 */ + @Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id") + List findAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 父锁后先收齐所有客户出库锁,再锁派送和来源。 */ + @Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id FOR UPDATE") + List lockAllByDispatch(@Param("dispatchId") long dispatchId); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1") OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); @Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java new file mode 100644 index 000000000..db94a64a1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Destination; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.util.*; + +/** 整车按真实客户与目的地拆分,物理托盘和来源行分别统计。 */ +@Component +public class DeliveryCargoGroups { + private final ObjectMapper json; + + /** 使用服务统一序列化器计算稳定业务摘要。 */ + public DeliveryCargoGroups(ObjectMapper json) { this.json=json; } + + /** 本域分组结果不作为另一套公网DTO或跨域Entity。 */ + public record Group(String key,String customerId,String customerName,Destination destination, + String cargoHash,List cargo,long packages,long palletCount) { } + + /** 只接受完整真实拣货来源,不从展示入仓码或计划数补齐事实。 */ + public List groups(Context context) { + if (context==null || context.counts()==null || context.cargo()==null || context.cargo().isEmpty()) + throw conflict("排车单没有真实待派送货物"); + var groups=new TreeMap>(); + var attributions=new HashSet(); + var palletDestinations=new HashMap(); + for (var row:context.cargo()) { + if (row==null || row.dispatchItemId()==null || row.dispatchItemId()<=0 || blank(row.customerId()) || "0".equals(row.customerId()) + || row.orderId()==null || row.orderId()<=0 || row.packages()<=0 || row.pickId()==null || row.pickId()<=0 || row.attributionId()==null || row.attributionId()<=0 + || blank(row.sourceKey()) || row.destination()==null || blank(row.destination().key())) + throw conflict("货物缺少真实客户、拣货或来源归属"); + if ("RETURN_RELABEL".equals(row.sourceStage()) + ? row.returnCartonId()==null || row.returnCartonId()<=0 + : row.inventoryPositionId()==null || row.inventoryPositionId()<=0) + throw conflict("本次拣货缺少实际库存仓位或退货箱来源"); + if (!attributions.add(row.attributionId())) throw conflict("本次派送包含重复拣货来源"); + if (blank(row.destination().type()) || !Set.of("FBA","PRIVATE").contains(row.destination().type())) + throw conflict("货物派送目的地类型不完整"); + if (row.warehousePalletId()!=null) { + var previous=palletDestinations.putIfAbsent(row.warehousePalletId(),row.destination().key()); + if (previous!=null && !previous.equals(row.destination().key())) throw conflict("同一实物托盘不同目的地须先拆托"); + } + if ("FBA".equals(row.destination().type()) && blank(row.destination().warehouseCode()) + || "PRIVATE".equals(row.destination().type()) && blank(row.destination().address())) + throw conflict("货物缺少真实目的仓或派送地址"); + groups.computeIfAbsent(groupKey(row.customerId(),row.destination()),ignored->new ArrayList<>()).add(row); + } + long total=context.cargo().stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact); + if (!context.pickingReconciled() || total!=context.counts().packageCount() + || total!=context.counts().pickedPackageCount() && total!=context.counts().shippedPackageCount()) + throw conflict("实际拣货来源与本车箱数尚未核平"); + var result=new ArrayList(); + for (var entry:groups.entrySet()) { + var rows=entry.getValue().stream().sorted(Comparator.comparing(CargoLine::dispatchItemId) + .thenComparing(CargoLine::attributionId)).toList(); + var first=rows.get(0); + if (rows.stream().anyMatch(row->!Objects.equals(row.destination(),first.destination()))) + throw conflict("同组货物的目的地信息不一致,请核对后安排"); + long packages=rows.stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact); + long pallets=rows.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count(); + result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),first.destination(), + hash(rows),rows,packages,pallets)); + } + return List.copyOf(result); + } + + /** 客户是组身份的一部分,同目的仓的其他客户永不合单。 */ + public String groupKey(String customerId,Destination destination) { + return hash(List.of(customerId,destination.key())); + } + + /** 本地派送来源证明沿用单据分组键,明确映射既有FBA和私人地址语义。 */ + public String documentGroupKey(Destination destination) { + if ("PRIVATE".equals(destination.type()) && !blank(destination.address())) + return "PRIVATE:"+destination.address().trim()+":"+Objects.toString(destination.postalCode(),"").trim(); + if ("FBA".equals(destination.type()) && !blank(destination.warehouseCode())) + return "CODE:"+destination.warehouseCode().trim().toUpperCase(Locale.ROOT); + throw conflict("派送目的地缺少真实业务标识"); + } + + /** 摘要仅使用规范化领域值,不包含操作者或偶然集合遍历顺序。 */ + public String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); } + catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { + throw new IllegalStateException("派送业务摘要生成失败",error); + } + } + + /** 缺失业务事实直接阻断,不用内部编号回退展示。 */ + private static boolean blank(String value) { return value==null || value.isBlank(); } + /** 业务冲突不产生部分单据。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java new file mode 100644 index 000000000..3d59a40c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import org.springframework.stereotype.Component; +import java.time.Instant; +import java.util.*; + +/** 五阶段只接受原实发全部正常签收,不将真实退回当作签收数量。 */ +@Component +public class NormalDeliveryCompletionPolicy { + private final DeliveryCargoGroups hashes; + + /** 复用本域确定性摘要器,不申请历史来源订单锁。 */ + public NormalDeliveryCompletionPolicy(DeliveryCargoGroups hashes) { this.hashes=hashes; } + + /** 按已安排单据精确集合和原实发数量核对,空集合不算完成。 */ + public NormalDeliveryFacts evaluate(Context context,ArrangementResult arrangement,List actual,Instant now) { + var reasons=new ArrayList(); + var expected=arrangement==null?List.of():arrangement.deliveries(); + var seen=new HashMap(); + if (context.departedAt()==null || now==null || context.departedAt().isAfter(now)) reasons.add("尚无有效实际发车事实"); + if (expected==null || expected.isEmpty()) reasons.add("缺少本车已安排派送单"); + for (var fact:actual) if (fact==null || fact.deliveryNo()==null || seen.putIfAbsent(fact.deliveryNo(),fact)!=null) + reasons.add("派送事实缺失或重复"); + var expectedNos=expected==null?List.of():expected.stream().map(DeliveryReference::deliveryNo).sorted().toList(); + if (new HashSet<>(expectedNos).size()!=expectedNos.size() || !seen.keySet().equals(new HashSet<>(expectedNos))) + reasons.add("派送单未精确覆盖本车安排"); + long total=0; Instant completedAt=null; + if (expected!=null) for (var reference:expected) { + var fact=seen.get(reference.deliveryNo()); + if (fact==null) continue; + if (!Objects.equals(fact.customerId(),reference.customerId()) || !Objects.equals(fact.groupKey(),reference.groupKey()) + || !Objects.equals(fact.outboundNo(),reference.outboundNo()) || fact.originalPackages()!=reference.packageCount()) + reasons.add("派送客户、目的地或原实发来源不一致"); + total=Math.addExact(total,fact.originalPackages()); + // full签收字段是原运输事实;签后另退不重开,但签剩余/拒收/无签收永不冒充原全签。 + if (!fact.outboundCompleted() || fact.status()==null || !Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(fact.status()) || fact.originalPackages()<=0 + || fact.signedPackages()!=fact.originalPackages() || fact.signedAt()==null) + reasons.add("仍有货物未正常全部签收,异常或退回不能代替签收"); + else if (context.departedAt()==null || fact.signedAt().isBefore(context.departedAt()) + || now==null || fact.signedAt().isAfter(now)) reasons.add("签收时间与真实发车事实不一致"); + else if (completedAt==null || fact.signedAt().isAfter(completedAt)) completedAt=fact.signedAt(); + } + if (total<=0 || context.counts()==null || total!=context.counts().shippedPackageCount()) + reasons.add("签收单据未完整覆盖本趟真实实发箱数"); + var sorted=actual.stream().filter(Objects::nonNull).sorted(Comparator.comparing(NormalDeliveryFact::customerId,Comparator.nullsFirst(String::compareTo)) + .thenComparing(NormalDeliveryFact::deliveryNo,Comparator.nullsFirst(String::compareTo))).toList(); + boolean normal=reasons.isEmpty(); + return new NormalDeliveryFacts(context.dispatchNo(),hashes.hash(Arrays.asList(context.dispatchNo(),expectedNos,sorted)), + normal,normal?completedAt:null,expectedNos,sorted,reasons.stream().distinct().toList()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java new file mode 100644 index 000000000..d4f199332 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +/** 未执行出库取消的数据访问,业务资格与费用撤销仍由Service编排。 */ +@Repository +public class DeliveryCancellationRepository { + private final OutboundOrderMapper outbounds; + private final OutboundPalletItemMapper items; + private final DeliveryOrderMapper deliveries; + private final OutboundPdaTaskMapper tasks; + private final DeliveryOperationMapper operations; + private final RelabelApplicationMapper relabels; + private final ReturnInventoryCartonMapper cartons; + private final WarehouseTransferOrderMapper transfers; + private final LastlegOrderMapper sources; + private final LastlegOrderStatusHistoryMapper histories; + + /** 装配原领域Mapper,不能将SQL搬到取消Service。 */ + public DeliveryCancellationRepository(OutboundOrderMapper outbounds,OutboundPalletItemMapper items,DeliveryOrderMapper deliveries, + OutboundPdaTaskMapper tasks,DeliveryOperationMapper operations,RelabelApplicationMapper relabels, + ReturnInventoryCartonMapper cartons,WarehouseTransferOrderMapper transfers,LastlegOrderMapper sources,LastlegOrderStatusHistoryMapper histories) { + this.outbounds=outbounds;this.items=items;this.deliveries=deliveries;this.tasks=tasks;this.operations=operations; + this.relabels=relabels;this.cartons=cartons;this.transfers=transfers;this.sources=sources;this.histories=histories; + } + /** 先普通定位真实父身份。 */ + public OutboundOrder find(String no) { return outbounds.findGlobalByNo(no); } + /** 父级协调完成后才申请出库锁。 */ + public OutboundOrder lock(String no) { return outbounds.lockGlobalByNo(no); } + /** 独立换标出库保留其原域确认锁。 */ + public RelabelApplication lockRelabel(OutboundOrder order) { return relabels.lockByOutbound(order.getCustomerId(),order.getOutboundNo()); } + /** 子明细始终按出库客户读取。 */ + public List items(OutboundOrder order) { return items.findByOutbound(order.getCustomerId(),order.getId()); } + /** 一次取得本出库全部派送锁,不边持来源锁边锁下一张派送。 */ + public List deliveries(OutboundOrder order) { return deliveries.lockByOutbound(order.getCustomerId(),order.getId()); } + /** 退货实际箱必须在原域锁内核对。 */ + public ReturnInventoryCarton lockCarton(String mark) { return cartons.lockMark(mark); } + /** 只释放尚未执行的预留箱,不生成退库流水。 */ + public void save(ReturnInventoryCarton value) { cartons.updateById(value); } + /** 更新已锁派送取消状态。 */ + public void save(DeliveryOrder value) { deliveries.updateById(value); } + /** PDA任务与出库同事务取消。 */ + public OutboundPdaTask task(OutboundOrder value) { return tasks.lockByOutbound(value.getCustomerId(),value.getId()); } + /** 保存已锁任务状态。 */ + public void save(OutboundPdaTask value) { tasks.updateById(value); } + /** 保存已锁出库状态。 */ + public void save(OutboundOrder value) { outbounds.updateById(value); } + /** 保存尚未执行的出库明细状态。 */ + public void save(OutboundPalletItem value) { items.updateById(value); } + /** 锁原转仓单以恢复其明确业务状态。 */ + public WarehouseTransferOrder lockTransfer(String customer,String no) { return transfers.lockNo(customer,no); } + /** 保存转仓恢复状态。 */ + public void save(WarehouseTransferOrder value) { transfers.updateById(value); } + /** 相关来源在费用锁前以数字ID顺序锁定。 */ + public LastlegOrder lockSource(String customer,long id) { return sources.lockById(customer,id); } + /** 来源候选一次读齐并固定顺序,不能按展示入仓码挑一票。 */ + public List sources(String customer,String outboundNo) { + return sources.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId,customer) + .eq(LastlegOrder::getOutboundOrderNo,outboundNo).orderByAsc(LastlegOrder::getId)); + } + /** 按真实转换历史恢复,而非统一写某个旧状态。 */ + public LastlegOrderStatusHistory previous(String customer,long id,List events) { + return histories.selectOne(new LambdaQueryWrapper().eq(LastlegOrderStatusHistory::getCustomerId,customer) + .eq(LastlegOrderStatusHistory::getOrderId,id).in(LastlegOrderStatusHistory::getEventId,events) + .orderByAsc(LastlegOrderStatusHistory::getCreatedAt,LastlegOrderStatusHistory::getId).last("LIMIT 1")); + } + /** 显式清空业务关联并增加版本,防止MyBatis跳过null留下旧单号。 */ + public boolean restore(LastlegOrder value,String outboundNo,String status,String subStatus,LocalDateTime now) { + return sources.update(null,new LambdaUpdateWrapper().eq(LastlegOrder::getId,value.getId()) + .eq(LastlegOrder::getCustomerId,value.getCustomerId()).eq(LastlegOrder::getOutboundOrderNo,outboundNo) + .set(LastlegOrder::getCurrentStatus,status).set(LastlegOrder::getSubOrderStatus,subStatus) + .set(LastlegOrder::getOutboundOrderNo,null).set(LastlegOrder::getDeliveryOrderNo,null) + .set(LastlegOrder::getVersion,Objects.requireNonNullElse(value.getVersion(),0)+1).set(LastlegOrder::getUpdatedAt,now))==1; + } + /** 保存不可变取消历史。 */ + public void history(LastlegOrderStatusHistory value) { histories.insert(value); } + /** 完整原因由业务层分段保存,不截断丢失。 */ + public void operation(DeliveryOperation value) { operations.insert(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java new file mode 100644 index 000000000..3dcc6318a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; + +/** 派送资料及签收关联的数据访问,业务事务和来源证明由Service编排。 */ +@Repository +public class DeliveryDocumentRepository { + private final DeliveryOrderMapper orders; + private final OutboundOrderMapper outbounds; + private final DeliveryDocumentRevisionMapper revisions; + private final DeliveryOperationMapper operations; + private final LastlegOrderMapper sources; + private final DeliverySourceEvidenceMapper evidence; + + /** 装配各表Mapper,不在资料服务直接访问持久化接口。 */ + public DeliveryDocumentRepository(DeliveryOrderMapper orders, OutboundOrderMapper outbounds, + DeliveryDocumentRevisionMapper revisions, DeliveryOperationMapper operations, + LastlegOrderMapper sources, DeliverySourceEvidenceMapper evidence) { + this.orders=orders; this.outbounds=outbounds; this.revisions=revisions; + this.operations=operations; this.sources=sources; this.evidence=evidence; + } + + /** 客户和派送号共同定位资料单。 */ + public DeliveryOrder find(String customerId, String no) { return orders.findByNo(customerId,no); } + /** 签收先锁自己的派送,随后才能申请来源订单锁。 */ + public DeliveryOrder lock(String customerId, String no) { return orders.lockByNo(customerId,no); } + /** 出库事实普通读,避免在派送锁之后反向申请出库锁。 */ + public OutboundOrder outbound(String customerId, String no) { return outbounds.findByNo(customerId,no); } + /** 读取当前资料命令以保持原请求重放语义。 */ + public DeliveryDocumentRevision command(String customerId,long id,String key) { return revisions.findCommand(customerId,id,key); } + /** 保存已校验的资料和签收字段。 */ + public void save(DeliveryOrder order) { orders.updateById(order); } + /** 同事务保存不可变资料版本。 */ + public void revision(DeliveryDocumentRevision revision) { revisions.insert(revision); } + /** 同事务保存业务历史。 */ + public void operation(DeliveryOperation operation) { operations.insert(operation); } + /** 读取资料版本历史,不暴露其他客户附件。 */ + public List history(String customerId,long id) { return revisions.history(customerId,id); } + /** 签收仍需拒绝未完成的真实退回流程。 */ + public long activeReturns(String customerId,long id) { return operations.activeReturns(customerId,id); } + /** 只读取本派送的真实退回入账事实。 */ + public List returns(String customerId,long id) { return operations.returnReceipts(customerId,id); } + /** 拣货来源按真实客户与整车读取,不拿当前订单指针替代历史。 */ + public List picks(String customerId,long dispatchId) { return evidence.picks(customerId,dispatchId); } + /** 批量读取候选来源,再由Service复核客户归属。 */ + public List sourceOrders(Collection ids) { return ids.isEmpty()?List.of():sources.selectBatchIds(ids); } + /** 非头程本地单据仍读取其本客户的既有当前关系。 */ + public List locallyLinked(String customerId,String deliveryNo) { + return sources.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId,customerId) + .eq(LastlegOrder::getDeliveryOrderNo,deliveryNo)); + } + /** 来源锁由证明服务持有后重新读取当前行,避免旧快照覆盖。 */ + public LastlegOrder source(long id) { return sources.selectById(id); } + /** 只保存Service根据真实来源签收证明得到的子状态。 */ + public void saveSource(LastlegOrder order) { sources.updateById(order); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java new file mode 100644 index 000000000..cfdf3756d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import com.baiyue.lastleg.delivery.mapper.DeliveryFactsChangedMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 信号持久化由派送域维护,主流程经正式端口消费。 */ +@Repository +public class DeliveryFactsChangedRepository { + private final DeliveryFactsChangedMapper mapper; + private final DeliveryOrderMapper deliveries; + + /** 装配唯一的MyBatis数据入口。 */ + public DeliveryFactsChangedRepository(DeliveryFactsChangedMapper mapper, DeliveryOrderMapper deliveries) { + this.mapper = mapper; this.deliveries = deliveries; + } + + /** 公共信号端口只认整车和派送号对应的唯一真实单据。 */ + public List deliveries(String dispatchNo, String deliveryNo) { + return deliveries.findForSignal(dispatchNo, deliveryNo); + } + + /** 确认前复核信号原身份,普通读不反向申请父锁。 */ + public DeliveryFactsChanged find(long id) { return mapper.findById(id); } + + /** 保存一次事实并返回当前唯一版本供业务校验。 */ + public DeliveryFactsChanged insertOnce(DeliveryFactsChanged fact) { + mapper.insertOnce(fact); + return mapper.find(fact.getCustomerId(), fact.getDeliveryId(), fact.getDeliveryVersion()); + } + + /** 有界普通扫描,不在此处持有父锁前的子行锁。 */ + public List pending(LocalDateTime now, int limit) { + return mapper.pending(now, limit); + } + + /** 只确认调用方已经核对的指定信号与排车身份。 */ + public int acknowledge(long id, long dispatchId, LocalDateTime now) { + return mapper.acknowledge(id, dispatchId, now); + } + + /** 记录可重试失败且保留PENDING,不能覆盖已成功确认的信号。 */ + public void failed(long id, String code, LocalDateTime nextAttemptAt) { + mapper.failed(id, code, nextAttemptAt); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java new file mode 100644 index 000000000..47c4cd3bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 整车派送数据访问;锁顺序明确区分子单和来源,不直写主流程表。 */ +@Repository +public class DispatchDeliveryRepository { + private final DispatchDeliveryArrangementMapper arrangements; + private final OutboundOrderMapper outbounds; + private final OutboundPalletItemMapper items; + private final DeliveryOrderMapper deliveries; + private final DeliveryOperationMapper operations; + private final LastlegOrderMapper sources; + private final LastlegOrderStatusHistoryMapper histories; + + /** 装配派送域唯一持久化入口。 */ + public DispatchDeliveryRepository(DispatchDeliveryArrangementMapper arrangements,OutboundOrderMapper outbounds, + OutboundPalletItemMapper items,DeliveryOrderMapper deliveries,DeliveryOperationMapper operations, + LastlegOrderMapper sources,LastlegOrderStatusHistoryMapper histories) { + this.arrangements=arrangements;this.outbounds=outbounds;this.items=items; + this.deliveries=deliveries;this.operations=operations; + this.sources=sources;this.histories=histories; + } + /** 父锁外预览仅普通读安排事实。 */ + public DispatchDeliveryArrangement arrangement(long id,boolean lock) { return lock?arrangements.lock(id):arrangements.find(id); } + /** 固定顺序取得或读取全部客户出库。 */ + public List outbounds(long id,boolean lock) { return lock?outbounds.lockAllByDispatch(id):outbounds.findAllByDispatch(id); } + /** 仅在取得全体出库锁之后取得全体派送锁。 */ + public List deliveries(long id,boolean lock) { return lock?deliveries.lockAllByDispatch(id):deliveries.findAllByDispatch(id); } + /** 一次安排仅插入一个客户自己的出库头。 */ + public void create(OutboundOrder value) { outbounds.insert(value); } + /** 不可变出库来源明细在同一安排事务保存。 */ + public void create(OutboundPalletItem value) { items.insert(value); } + /** 每客户目的地分别保存派送单。 */ + public void create(DeliveryOrder value) { deliveries.insert(value); } + /** 仅更新已锁定并复验的客户出库执行事实。 */ + public void save(OutboundOrder value) { outbounds.updateById(value); } + /** 仅更新已锁定并复验的客户派送执行事实。 */ + public void save(DeliveryOrder value) { deliveries.updateById(value); } + /** 最后保存单车唯一安排,失败整体回滚不留半车。 */ + public void create(DispatchDeliveryArrangement value) { arrangements.insert(value); } + /** 保存真实业务操作事实,不将零约定冒充费用。 */ + public void operation(DeliveryOperation value) { operations.insert(value); } + /** 正常完成只普通读当前异常数量,不反向锁历史来源。 */ + public long activeReturns(DeliveryOrder value) { return operations.activeReturns(value.getCustomerId(),value.getId()); } + /** 只读取本派送真实退回流水。 */ + public List returns(DeliveryOrder value) { + return operations.returnReceipts(value.getCustomerId(),value.getId()); + } + /** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */ + public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); } + /** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */ + public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now) { + if (sources.update(null,new LambdaUpdateWrapper().eq(LastlegOrder::getId,source.getId()) + .eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo) + .set(LastlegOrder::getDeliveryOrderNo,deliveryNo) + .set(LastlegOrder::getSubOrderStatus,"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY") + .set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1) + .set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化"); + } + /** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */ + public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); } +} 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 dc7b541a0..b75ae4269 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 @@ -5,12 +5,10 @@ import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; -import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.repository.DeliveryDocumentRepository; import com.baiyue.lastleg.delivery.model.*; import com.baiyue.lastleg.customervisit.service.CustomerVisitService; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -28,20 +26,18 @@ import java.util.*; @Service public class DeliveryDocumentService { @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; - @Resource private DeliveryOrderMapper orders; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private DeliveryDocumentRevisionMapper revisions; - @Resource private DeliveryOperationMapper operations; + @Resource private DeliveryDocumentRepository repository; + @Resource private com.baiyue.lastleg.dispatch.port.DispatchSignalPort signals; @Resource private DeliveryDocumentFileGateway files; @Resource private ObjectMapper objectMapper; - @Resource private LastlegOrderMapper lastlegOrders; @Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox; @Resource private CustomerVisitService customerVisits; @Resource private SourceDeliveryCompletionQuery sourceCompletion; - @Resource private DeliverySourceEvidenceMapper sourceEvidence; + /** 客户和派送号共同定位当前资料。 */ public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } + /** 客户只查看本客户派送的签收类附件,不返回其他资料。 */ public MerchantReceipts merchantReceipts(String customerId, String deliveryNo) { Detail snapshot = detail(customerId, deliveryNo); return new MerchantReceipts(snapshot.deliveryNo(), snapshot.categories().stream() @@ -49,23 +45,25 @@ public class DeliveryDocumentService { .flatMap(category -> category.files().stream()).toList()); } + /** 下载仍复核业务与类别归属,不放开文件公共访问。 */ public Download merchantReceiptDownload(String customerId, String deliveryNo, long fileId) { return download(customerId, deliveryNo, DeliveryDocumentCategory.RECEIPT, fileId, null); } + /** 上传准备不等于资料保存或真实签收完成。 */ public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category, String operatorId, MultipartFile file) { DeliveryOrder order = require(customerId, deliveryNo); return files.upload(customerId, order.getId(), category, operatorId, file); } - /** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */ + /** 先锁本派送并校验全部资料,签收及本地待汇总事实在同一事务提交。 */ @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Detail save(String customerId, String deliveryNo, String operatorId, Save request) { - DeliveryOrder order = orders.lockByNo(customerId, deliveryNo); + DeliveryOrder order = repository.lock(customerId, deliveryNo); if (order == null) throw missing(); String hash = hash(request); - DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey()); + DeliveryDocumentRevision replay = repository.command(customerId, order.getId(), request.idempotencyKey()); if (replay != null) { if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同"); Detail saved = read(replay.getSnapshotJson()); @@ -96,11 +94,11 @@ public class DeliveryDocumentService { if (autoCompleted) { if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED") && !"RETURN_PENDING".equals(order.getStatus())) throw conflict("当前派送状态不允许通过签收文件完成"); - // A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible. - var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo()); + // 派送锁内只普通读出库,未提交的发车不构成真实出库资格,也不反向申请父锁。 + var outbound = repository.outbound(customerId, order.getOutboundNo()); if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送"); - if (operations.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收"); - try { signedPackages = DeliveryCargoQuantities.from(order, operations.returnReceipts(customerId, order.getId())).remaining(); } + if (repository.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收"); + try { signedPackages = DeliveryCargoQuantities.from(order, repository.returns(customerId, order.getId())).remaining(); } catch (IllegalArgumentException error) { throw conflict(error.getMessage()); } if (signedPackages <= 0) throw conflict("本次派送已无待签收货物"); } @@ -113,7 +111,7 @@ public class DeliveryDocumentService { order.setSignedAt(now); order.setSignedPackages(signedPackages); } - orders.updateById(order); + repository.save(order); if (autoCompleted) { var completion = completeRelatedOrders(order, operatorId, now); fleetPool.signed(new SignedDelivery( @@ -121,29 +119,34 @@ public class DeliveryDocumentService { completion.sourcePackages(),operatorId,now); deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant()); customerVisits.ensureForCompletedDelivery(order, now); + // 只写持久信号;主流程提交后另事务汇总,不能在此持子锁回调父流程。 + if (order.getDispatchOrderId() != null) signals.enqueue(order.getDispatchNo(), order.getDeliveryNo(), + order.getVersion(), now.atZone(executionZone).toInstant()); } Detail result = detail(order, false); DeliveryDocumentRevision revision = new DeliveryDocumentRevision(); revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion()); revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result)); - revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision); + revision.setOperatorId(operatorId); revision.setCreatedAt(now); repository.revision(revision); DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类"); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); if (autoCompleted) recordAutomaticCompletion(order, operatorId, now); return result; } + /** 资料历史保持不可变版本并受原客户范围约束。 */ public List history(String customerId, String deliveryNo) { DeliveryOrder order = require(customerId, deliveryNo); - return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(), + return repository.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(), revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList(); } + /** 当前或历史附件必须确实绑定在本派送对应资料版本。 */ public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) { DeliveryOrder order = require(customerId, deliveryNo); Detail snapshot = detail(order, false); if (revisionVersion != null) { - snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion())) + snapshot = repository.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion())) .findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing); } DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category) @@ -152,36 +155,39 @@ public class DeliveryDocumentService { return new Download(file, files.download(customerId, order.getId(), category, fileId)); } public record Download(DeliveryDocumentDtos.File file, byte[] content) { } + /** 业务编号不属于当前客户时按不存在处理。 */ private DeliveryOrder require(String customerId, String deliveryNo) { - DeliveryOrder order = orders.findByNo(customerId, deliveryNo); + DeliveryOrder order = repository.find(customerId, deliveryNo); if (order == null) throw missing(); return order; } + /** 三类资料只读投影不根据附件数量推断库存或整车阶段。 */ private Detail detail(DeliveryOrder order, boolean replayed) { List categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson()); return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(), (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); } + /** 原始单据版本0有效,后续保存按版本增加。 */ private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } private record Completion(List orders, Map proofs, Map sourcePackages) { } + /** 先根据真实拣货收齐来源,再按跨趟来源锁协议证明正常签收。 */ private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) { var related = new TreeMap(); Map sourcePackages = new TreeMap<>(); if (delivery.getDispatchOrderId() != null) { - var actualPicks = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() + var actualPicks = repository.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() .filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())).toList(); for (var pick : actualPicks) sourcePackages.merge(pick.orderId(), pick.packages(), Math::addExact); var ids = actualPicks.stream() .map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList(); - if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) { + if (!ids.isEmpty()) for (var tail : repository.sourceOrders(ids)) { if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致"); related.put(tail.getId(), tail); } } - // Non-dispatch local workflows retain their established relation; never use this to prove a domestic source. - for (var tail : lastlegOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()).eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()))) { + // 本地非排车单保留既有关系,不能把当前指针当作头程全来源签收证明。 + for (var tail : repository.locallyLinked(delivery.getCustomerId(), delivery.getDeliveryNo())) { if (!DeliveryCompletedOutboxService.domestic(tail)) related.putIfAbsent(tail.getId(),tail); else if (!related.containsKey(tail.getId())) pendingCompletion(delivery, operatorId, now, inboundLabel(tail) + ":缺少历史实际拣货来源,未发布整来源签收事件"); @@ -190,10 +196,10 @@ public class DeliveryDocumentService { .map(LastlegOrder::getSourceInboundId).distinct().toList(); var proofs = sourceIds.isEmpty() ? Map.of() : sourceCompletion.proveAll(delivery.getCustomerId(),sourceIds); - // All source locks are now held. Re-read current rows instead of overwriting with pre-lock snapshots. + // 来源锁已按证明协议持有,重新读取当前行,不能用加锁前快照覆盖并发签收。 var current = new ArrayList(); for (long id : related.keySet()) { - var tail = lastlegOrders.selectById(id); + var tail = repository.source(id); if (tail == null || !delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源已变化"); if (DeliveryCompletedOutboxService.domestic(tail)) { var proof = proofs.get(tail.getSourceInboundId()); @@ -205,38 +211,47 @@ public class DeliveryDocumentService { } } else tail.setSubOrderStatus(Objects.equals(delivery.getSignedPackages(),delivery.getPackageCount()) ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); - tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail); + tail.setUpdatedAt(now); repository.saveSource(tail); current.add(tail); } return new Completion(List.copyOf(current),proofs,Map.copyOf(sourcePackages)); } + /** 入仓业务号缺失明确显示空值标记,不显示内部ID。 */ private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); } + /** 未核齐的来源只保留诊断事实,不伪造全来源完成。 */ private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) { var operation = new DeliveryOperation(); operation.setCustomerId(delivery.getCustomerId()); operation.setDeliveryOrderId(delivery.getId()); operation.setOperatorId(operatorId); operation.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); operation.setResult(reason); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); } + /** 保存本次实际签收箱数,真实退回不得混入签收数量。 */ private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setOperationNode("RECEIPT_AUTO_COMPLETE"); operation.setResult("签收文件保存成功,本次实际签收" + order.getSignedPackages() + "箱;原实发" + order.getPackageCount() + "箱"); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); } + /** 资料原请求以严格载荷摘要重放,版本和文件集合不得改写。 */ private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } + /** 资料与操作结果必须可持久化,否则同事务回滚。 */ private String write(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); } } + /** 损坏历史直接阻断,不回退为当前资料成功。 */ private Detail read(String value) { try { return objectMapper.readValue(value, Detail.class); } catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); } } + /** 隐藏不存在或不属于本客户的业务资料。 */ private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); } + /** 同版本竞争或业务资格变化要求刷新核对。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + /** 参数范围不合法时不产生任何资料变更。 */ private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java new file mode 100644 index 000000000..bdc630e7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.repository.DeliveryFactsChangedRepository; +import com.baiyue.lastleg.dispatch.port.DispatchSignalPort; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.Instant; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +/** 签收只写持久信号,绝不持派送子锁回调整车完成服务。 */ +@Service +public class DeliveryFactsChangedService implements DispatchSignalPort { + private final DeliveryFactsChangedRepository repository; + private final ObjectMapper json; + + /** 装配事实持久化和确定性序列化。 */ + public DeliveryFactsChangedService(DeliveryFactsChangedRepository repository, ObjectMapper json) { + this.repository = repository; + this.json = json; + } + + /** 直接消费主流程实际骨架,信号仅在原签收事务内持久化。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void enqueue(String dispatchNo, String deliveryNo, long deliveryVersion, Instant occurredAt) { + if (blank(dispatchNo) || blank(deliveryNo) || occurredAt == null) throw new IllegalArgumentException("签收信号身份不完整"); + var rows = repository.deliveries(dispatchNo, deliveryNo); + if (rows.size() != 1 || !Objects.equals(rows.get(0).getVersion(), deliveryVersion)) + throw new IllegalStateException("签收信号与当前真实派送版本不一致"); + signed(rows.get(0), LocalDateTime.ofInstant(occurredAt, ZoneId.systemDefault())); + } + + /** 有界普通读取候选,不在父汇总事务前领取或锁定信号。 */ + @Override + @Transactional(readOnly = true) + public List due(Instant now, int limit) { + if (now == null || limit < 1 || limit > 200) throw new IllegalArgumentException("信号扫描范围不合法"); + return repository.pending(LocalDateTime.ofInstant(now, ZoneId.systemDefault()), limit).stream() + .map(f -> new Signal(f.getId(),f.getDispatchNo(),f.getDeliveryNo(),f.getDeliveryVersion(), + f.getAttemptCount(),f.getNextAttemptAt().atZone(ZoneId.systemDefault()).toInstant())).toList(); + } + + /** 与主阶段CAS共同提交指定信号确认,不能提前确认后来产生的版本。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void acknowledge(Signal signal, Instant acknowledgedAt) { + var stored = requireSignal(signal); + if (acknowledgedAt == null) throw new IllegalArgumentException("信号确认时间不能为空"); + repository.acknowledge(stored.getId(),stored.getDispatchId(),LocalDateTime.ofInstant(acknowledgedAt,ZoneId.systemDefault())); + } + + /** 调用方汇总回滚后独立记录退避,已确认的信号不会退回PENDING。 */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void failed(Signal signal, Instant nextRetryAt, String errorCode) { + var stored = requireSignal(signal); + if (nextRetryAt == null || errorCode == null || !errorCode.matches("[A-Z0-9_]{1,64}")) + throw new IllegalArgumentException("信号重试信息不合法"); + repository.failed(stored.getId(),errorCode,LocalDateTime.ofInstant(nextRetryAt,ZoneId.systemDefault())); + } + + /** 只允许操作扫描时的真实业务身份,不凭一个内部ID修改别的信号。 */ + private DeliveryFactsChanged requireSignal(Signal signal) { + if (signal == null || signal.signalId() == null) throw new IllegalArgumentException("信号不能为空"); + var stored = repository.find(signal.signalId()); + if (stored == null || !Objects.equals(stored.getDispatchNo(),signal.dispatchNo()) + || !Objects.equals(stored.getDeliveryNo(),signal.deliveryNo()) + || !Objects.equals(stored.getDeliveryVersion(),signal.deliveryVersion())) + throw new IllegalStateException("信号原业务身份不一致"); + return stored; + } + + /** 与签收共同提交唯一版本,缺失真实排车身份不得补造信号。 */ + @Transactional(propagation = Propagation.MANDATORY) + public void signed(DeliveryOrder delivery, LocalDateTime now) { + if (delivery.getDispatchOrderId() == null) return; + if (delivery.getId() == null || delivery.getDispatchOrderId() <= 0 || blank(delivery.getCustomerId()) + || blank(delivery.getDispatchNo()) || blank(delivery.getDeliveryNo()) || delivery.getVersion() == null + || delivery.getVersion() < 1 || delivery.getSignedAt() == null || delivery.getSignedPackages() == null + || delivery.getSignedPackages() <= 0 || now == null) throw new IllegalStateException("签收汇总信号缺少真实派送事实"); + var fact = new DeliveryFactsChanged(); + fact.setId(IdWorker.getId()); fact.setCustomerId(delivery.getCustomerId()); + fact.setDispatchId(delivery.getDispatchOrderId()); fact.setDispatchNo(delivery.getDispatchNo()); + fact.setDeliveryId(delivery.getId()); fact.setDeliveryNo(delivery.getDeliveryNo()); + fact.setDeliveryVersion(delivery.getVersion()); fact.setOccurredAt(now); + fact.setFactsHash(hash(List.of(delivery.getCustomerId(), delivery.getDispatchOrderId(), delivery.getDispatchNo(), + delivery.getId(), delivery.getDeliveryNo(), delivery.getVersion(), delivery.getSignedPackages(), delivery.getSignedAt()))); + var saved = repository.insertOnce(fact); + if (saved == null || !Objects.equals(saved.getDispatchId(), fact.getDispatchId()) + || !Objects.equals(saved.getDispatchNo(), fact.getDispatchNo()) + || !Objects.equals(saved.getDeliveryNo(), fact.getDeliveryNo()) + || !Objects.equals(saved.getFactsHash(), fact.getFactsHash())) + throw new IllegalStateException("同一签收版本对应的持久事实不一致"); + } + + /** 以规范化真实字段生成摘要,不保存整车完成的推断结论。 */ + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json.writeValueAsBytes(value))); } + catch (NoSuchAlgorithmException | JsonProcessingException error) { throw new IllegalStateException("签收信号摘要生成失败", error); } + } + + /** 业务身份缺失直接拒绝,不使用内部ID代替业务编号。 */ + private static boolean blank(String value) { return value == null || value.isBlank(); } +} 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 deleted file mode 100644 index be764c7ec..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java +++ /dev/null @@ -1,122 +0,0 @@ -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 externalInboundCodes) { - 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(), externalInboundCodes)).toList(); - } - } - /** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */ - public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set externalInboundCodes) { - 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, externalInboundCodes))) included.add(service); - return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(), - List.copyOf(references), operator, Instant.now()), Set.copyOf(externalInboundCodes)); - } - private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service, - List references, Set externalInboundCodes) { - if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) 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 2f54fb267..f86d18bbc 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 @@ -2,7 +2,6 @@ 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; @@ -94,7 +93,6 @@ import java.util.stream.Collectors; @Service public class DeliveryTransferService { - @Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities; @Resource private DispatchOrderMapper dispatchOrders; @Resource private DispatchItemMapper dispatchItems; @Resource private OutboundOrderMapper outboundOrders; @@ -131,282 +129,9 @@ public class DeliveryTransferService { @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private ObjectMapper objectMapper; @Resource private FleetDispatchBindingGuard fleetBindings; - @Resource private FleetBookingFreight bookingFreight; - @Resource private DeliveryFeeCoverageService feeCoverage; @Resource private DeliveryExecutionGuard deliveryExecution; - @Resource private DispatchCargoSourceQuery cargoSources; - - @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(), - cargoSources.sources(dispatch, items)); - } - - @Transactional - public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, - DeliveryRequests.Transfer request) { - DispatchOrder dispatch = dispatchOrders.lockGlobalByDispatchNo(trim(dispatchNo)); - if (dispatch == null) throw notFound("Dispatch order not found"); - return transferResolved(dispatch, operatorId, request); - } - - @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()); - if (dispatch == null) throw notFound("Dispatch order not found"); - return transferResolved(dispatch, operatorId, request); - } - - /** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */ - 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 replayTransfer(replay, requestHash); - } - OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); - 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"); - if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { - throw conflict("All dispatch pallets must be received before transfer to delivery"); - } - if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required"); - var freight = externalFreight(dispatch, items); - if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash())) - && !Objects.equals(freight.snapshotHash(), request.externalFreightHash())) - throw conflict("外接实收或约定运费已变化,请重新打开转派送核对"); - var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet()); - var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none() - : feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalInboundCodes); - validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices()); - if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes()); - - LocalDateTime now = LocalDateTime.now(); - OutboundOrder outbound = new OutboundOrder(); - outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId())); - outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); - outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); - 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(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); - } catch (DuplicateKeyException e) { - // The unique keys are the final concurrent guard. A client retry must observe - // 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 replayTransfer(concurrent, requestHash); - } - concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); - if (concurrent != null) { - return replayTransfer(concurrent, requestHash); - } - throw conflict("Dispatch was concurrently transferred to delivery"); - } - - for (DispatchItem source : items) { - OutboundPalletItem item = new OutboundPalletItem(); - item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); - item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo()); - item.setDestinationWarehouseId(source.getDestinationWarehouseId()); - item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); - item.setDestinationWarehouseName(source.getDestinationWarehouseName()); - item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); - item.setGrossWeightKg(source.getGrossWeightKg()); item.setVolumeWeightKg(source.getVolumeWeightKg()); - item.setVolumeCbm(source.getVolumeCbm()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); - if (StringUtils.hasText(source.getCartonMarks())) { - item.setCartonMarks(source.getCartonMarks()); - } else if (source.getWarehousePalletId() != null) { - String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream() - .map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(",")); - item.setCartonMarks(StringUtils.hasText(marks) ? marks : null); - } - item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); - outboundPallets.insert(item); - } - - Map> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy( - this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList())); - List deliveries = new ArrayList<>(); - int groupIndex = 0; - for (Map.Entry> entry : destinationGroups.entrySet()) { - List groupItems = entry.getValue(); - DispatchItem destination = groupItems.get(0); - DeliveryOrder delivery = new DeliveryOrder(); - delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); - delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); - delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); - delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); - delivery.setDestinationWarehouseId(trim(destination.getDestinationWarehouseId())); - delivery.setDestinationWarehouseCode(firstText( - firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), - destination.getDestinationWarehouseId())); - delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); - delivery.setDestinationType(destination.getDestinationType()); - delivery.setDeliveryAddress(destination.getDeliveryAddress()); - delivery.setPostalCode(destination.getPostalCode()); - delivery.setContactName(destination.getContactName()); - delivery.setContactPhone(destination.getContactPhone()); - delivery.setDeliveryGroupKey(entry.getKey()); - LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); - delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); - delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); - delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); - delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); - delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); - delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); - delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); - 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(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 destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey( - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(), - item.getDeliveryAddress(), item.getPostalCode()))).findFirst() - .orElseThrow(() -> conflict("外接约定目的地未生成对应派送单")); - 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); - List feeInputs = request.fees() == null ? List.of() : request.fees(); - var ordinaryCargo = freight.requiresDeliveryFee() ? cargoSources.sources(dispatch, items).stream() - .filter(source -> !externalInboundCodes.contains(source.inboundCode())).toList() : List.of(); - for (DeliveryRequests.FeeItem input : feeInputs) { - 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 -> ordinaryCargo.stream().anyMatch(source -> Objects.equals(source.warehousePalletId(), - item.getWarehousePalletId().toString()) && Objects.equals(source.destinationKey(), FleetDispatchGateway.destinationKey( - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))).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 ("RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && !externalInboundCodes.isEmpty()) { - var ordinaryOrders = cargoSources.ordinaryOrderIds(dispatch, externalInboundCodes); - firstOrder = ordinaryOrders.size() == 1 ? tailOrders.selectById(ordinaryOrders.iterator().next()) : null; - if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致"); - } - 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(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(), - input.periodStart(), input.periodEnd())); - } - return result(outbound, deliveries, false); - } - - /** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */ - 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(); - 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(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 (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)) - && !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 (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment"); - } - } + @Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; + @Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations; @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { @@ -1165,38 +890,44 @@ public class DeliveryTransferService { } } - @Transactional + /** 先取得真实整车父锁,再锁出库和派送;已扫码不得以取消出库伪造退库。 */ + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) { required(request == null ? null : request.reason(), "取消原因不能为空"); - OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo)); + OutboundOrder observed = cancellations.find(trim(outboundNo)); if (observed == null) throw notFound("Outbound order not found"); if ("DISCARD_OUTBOUND".equals(observed.getOutboundType())) throw conflict("请通过废弃单取消,废弃出库单随申请同步取消"); - // Match dispatch picking and relabel confirmation lock order before locking outbound. - DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null - : dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId()); - RelabelApplication application = "RELABEL_OUTBOUND".equals(observed.getOutboundType()) - ? relabelApplications.lockByOutbound(observed.getCustomerId(), observed.getOutboundNo()) : null; - OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo)); + // 服务端身份与父锁仅使用正式共同端口,不按客户去锁整车头部。 + var dispatch = observed.getDispatchOrderId() == null ? null + : execution.lockForArrangement(new com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor(operatorId, + com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode.PLATFORM,null),observed.getDispatchNo()); + RelabelApplication application = observed.getDispatchOrderId()==null && "RELABEL_OUTBOUND".equals(observed.getOutboundType()) + ? cancellations.lockRelabel(observed) : null; + OutboundOrder outbound = cancellations.lock(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 (dispatch == null || !Objects.equals(dispatch.dispatchId(), outbound.getDispatchOrderId()) + || dispatch.status()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE + || dispatch.stage()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING + || dispatch.counts()==null || dispatch.counts().pickedPackageCount()>0 || dispatch.counts().shippedPackageCount()>0) + throw conflict("排车已开始执行,不能直接取消出库"); + fleetBindings.requireUnbound(dispatch.dispatchId()); } if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) { throw conflict("Only an unexecuted outbound order can be cancelled"); } String customerId = outbound.getCustomerId(); - List outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId()); + List outboundItems = cancellations.items(outbound); if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> value(i.getPickedPackageCount()) > 0 || !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) { throw conflict("Outbound order cannot be cancelled after execution has started"); } - List existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + List existingDeliveries = cancellations.deliveries(outbound); if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus()))) throw conflict("Outbound order cannot be cancelled while delivery is in progress"); + if (outbound.getDispatchOrderId()!=null && "RELABEL_OUTBOUND".equals(outbound.getOutboundType())) + application=cancellations.lockRelabel(outbound); LocalDateTime now = LocalDateTime.now(); if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { if (application == null || !customerId.equals(application.getCustomerId()) @@ -1206,13 +937,13 @@ public class DeliveryTransferService { for (OutboundPalletItem item : outboundItems) { if (item.getWarehousePalletId() != null) continue; for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) { - ReturnInventoryCarton carton = returnCartons.lockMark(mark); + ReturnInventoryCarton carton = cancellations.lockCarton(mark); if (carton == null || !customerId.equals(carton.getCustomerId()) || !Objects.equals(application.getId(), carton.getRelabelApplicationId()) || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货换标箱预留状态异常,不能取消出库"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); - returnCartons.updateById(carton); + cancellations.save(carton); } } relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId, @@ -1220,12 +951,23 @@ public class DeliveryTransferService { } if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) { - ReturnInventoryCarton carton = returnCartons.lockMark(mark); + ReturnInventoryCarton carton = cancellations.lockCarton(mark); if (carton == null || !customerId.equals(carton.getCustomerId()) || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货箱预留状态异常,不能取消出库"); - carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); cancellations.save(carton); } } + // 已收齐子单后,先锁齐全部相关来源;费用锁之后不得再发现新的来源锁。 + var cancelTransfers=new LinkedHashMap(); + if("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) + for(var no:outboundItems.stream().map(OutboundPalletItem::getOrderRefs).filter(StringUtils::hasText).distinct().sorted().toList()) { + var transfer=cancellations.lockTransfer(customerId,no);if(transfer!=null)cancelTransfers.put(no,transfer); + } + var cancelSources=new TreeMap(); + for(var selected:cancellations.sources(customerId,outboundNo)) { + var current=cancellations.lockSource(customerId,selected.getId()); + if(current!=null && outboundNo.equals(current.getOutboundOrderNo()))cancelSources.put(current.getId(),current); + } for (DeliveryOrder delivery : existingDeliveries) { int voidRequests = 0; for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) { @@ -1237,61 +979,58 @@ public class DeliveryTransferService { voidRequests++; } } - recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND", + recordCancellationOperation(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", + recordCancellationOperation(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); + delivery.setUpdatedAt(now); cancellations.save(delivery); } - OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); - if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } - outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); - for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } + OutboundPdaTask task = cancellations.task(outbound); + if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); } + outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); cancellations.save(outbound); + for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); cancellations.save(item); } if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs) .filter(StringUtils::hasText).distinct().toList()) { - WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo); + WarehouseTransferOrder transfer = cancelTransfers.get(transferNo); if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue; String restored = "FBA".equals(transfer.getDestinationType()) || "PRIVATE".equals(transfer.getDestinationType()) ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; - transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); - LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + transfer.setStatus(restored); transfer.setUpdatedAt(now); cancellations.save(transfer); + LastlegOrder order = cancelSources.get(transfer.getLastlegOrderId()); if (order != null && customerId.equals(order.getCustomerId())) { order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); - tailOrders.updateById(order); + if(!cancellations.restore(order,outboundNo,restored,"WAREHOUSE_TRANSFER:"+restored,now)) + throw conflict("转仓来源关联已变化,请刷新核对"); } } return; } if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { 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()); + for (LastlegOrder order : cancelSources.values()) { 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")); + var previous = cancellations.previous(customerId,order.getId(),transferEvents); 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) + if (!cancellations.restore(order,outboundNo,previous.getFromStatus(),"OUTBOUND:CANCELLED;DELIVERY:CANCELLED",now)) 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); + 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); cancellations.history(history); } } } + /** 取消事实按完整原因分段写入,不借通用Service直调Mapper。 */ + private void recordCancellationOperation(String customerId,Long deliveryId,String operator,String node,String result,LocalDateTime now) { + var operation=new DeliveryOperation();operation.setCustomerId(customerId);operation.setDeliveryOrderId(deliveryId); + operation.setOperatorId(operator);operation.setOperatorName(operator);operation.setOperationNode(node);operation.setResult(result);operation.setCreatedAt(now); + cancellations.operation(operation); + } + public List pdaTasks(String warehouseId, String status) { String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream() @@ -1591,49 +1330,7 @@ public class DeliveryTransferService { } catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); } } - private void bindTailOrders(String customerId, List items, String outboundNo, String deliveryNo, - String remark, LocalDateTime now) { - Set orderIds = new LinkedHashSet<>(); - items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add); - List palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList(); - if (!palletIds.isEmpty()) { - List allocations = planAllocations.selectList( - new LambdaQueryWrapper().in(InboundPlanPalletAllocation::getPalletId, palletIds)); - List lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId) - .filter(Objects::nonNull).distinct().toList(); - if (!lineIds.isEmpty()) { - planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId) - .filter(Objects::nonNull).forEach(orderIds::add); - } - } - if (orderIds.isEmpty()) return; - for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) { - String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo); - order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now); - order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY"); - tailOrders.updateById(order); - LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); - history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND"); - history.setEventId("TRANSFER_DELIVERY:" + deliveryNo); - history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents"); - history.setCreatedAt(now); histories.insert(history); - } - } - - private List requireDeliveries(String customerId, Long outboundId) { - List values = deliveryOrders.lockByOutbound(customerId, outboundId); - if (values.isEmpty()) throw new IllegalStateException("Delivery documents are missing for outbound order"); - return values; - } - private DeliveryResponses.TransferResult result(OutboundOrder o, List deliveries, boolean replayed) { - return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), - o.getPalletCount(), o.getPackageCount(), deliveries.stream().map(d -> new DeliveryResponses.TransferDelivery( - d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), - d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); - } - /** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */ - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), @@ -1655,7 +1352,6 @@ public class DeliveryTransferService { 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); } private String deliveryGroupKey(DispatchItem item) { if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) { return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim(); @@ -1665,16 +1361,6 @@ public class DeliveryTransferService { if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); throw conflict("Dispatch pallet is missing its destination"); } - private String resolveLxCodes(String customerId, List items) { - List inboundCodes = items.stream().map(DispatchItem::getInboundCode) - .filter(StringUtils::hasText).map(String::trim).distinct().toList(); - if (inboundCodes.isEmpty()) return ""; - return tailOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) - .in(LastlegOrder::getInboundCode, inboundCodes)) - .stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText) - .map(String::trim).distinct().collect(java.util.stream.Collectors.joining(",")); - } private void updateOutboundPickingStatus(String customerId, OutboundOrder outbound) { List items = outboundPallets.findByOutbound(customerId, outbound.getId()); String nextStatus = !items.isEmpty() && items.stream().allMatch(item -> "PICKED".equals(item.getStatus())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java new file mode 100644 index 000000000..9d598d203 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java @@ -0,0 +1,179 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.client.FinanceFeeCoverageClient; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups.Group; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; + +/** 逐客户逐目的地确认报价与覆盖,费用只归属于该组真实派送。 */ +@Service +public class DispatchDeliveryFees { + private final FleetBookingFreight external; + private final FinanceFeeCoverageClient finance; + private final OverseasFeeSourceService fees; + private final DeliveryCargoGroups groups; + private final DispatchDeliveryRepository repository; + private final String internalToken; + private static final Set SERVICES=Set.of("DELIVERY_FEE","LOADING_FEE","APPOINTMENT_FEE"); + + /** 复用现有报价、费用发布和Finance只读契约,不引入另一套账务。 */ + public DispatchDeliveryFees(FleetBookingFreight external,FinanceFeeCoverageClient finance, + OverseasFeeSourceService fees,DeliveryCargoGroups groups,DispatchDeliveryRepository repository, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.external=external;this.finance=finance;this.fees=fees;this.groups=groups; + this.repository=repository;this.internalToken=internalToken; + } + + /** 一客户一次按全部真实来源校验外接实收,避免将一笔完整报价按目的地重复核价。 */ + public Map external(Context context) { + var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(CargoLine::customerId,TreeMap::new,Collectors.toList())); + var result=new LinkedHashMap(); + for (var entry:byCustomer.entrySet()) { + var cargo=entry.getValue().stream().map(c->new FleetDispatchGateway.SourceCargo(c.inboundCode(), + c.warehousePalletId()==null?null:c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key())).toList(); + result.put(entry.getKey(),external.preview(context.dispatchId(),context.dispatchNo(),entry.getKey(),cargo)); + } + return result; + } + + /** 原费候选按客户批量只读,响应越界或不可用直接失败,不能认作已覆盖。 */ + public List original(String customerId,List cargo) { + var ids=new TreeSet(); + for (var row:cargo) if ("FIRSTLEG".equals(row.sourceRegion())) { + try { long id=Long.parseLong(row.sourceInboundId());if(id<=0)throw new NumberFormatException();ids.add(id); } + catch (RuntimeException invalid) { throw conflict("原头程货物缺少真实来源关联"); } + } + if (ids.isEmpty()) return List.of(); + try { + var response=finance.sources(internalToken,new FinanceFeeCoverageClient.Query(Long.valueOf(customerId),List.copyOf(ids))); + if (response==null || !response.successful() || response.data()==null) throw unavailable(); + for (var fee:response.data()) if (fee==null || fee.financeFeeId()==null || fee.financeFeeId()<=0 + || fee.sourceFeeId()==null || !Objects.equals(fee.customerId(),Long.valueOf(customerId)) + || !ids.contains(fee.sourceInboundId()) || fee.sourceVersion()<1 || blank(fee.sourceHash()) + || 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 response.data(); + } catch (RuntimeException unavailable) { throw unavailable(); } + } + + /** 平台按真实组服务范围确认原费,返回不可变证据供客户出库保存。 */ + public DeliveryFeeCoverageModels.Evidence coverage(Group group,ArrangeGroup input,String operatorId, + FleetBookingFreight.Preview freight) { + var selected=input.feeCoverage()==null?List.of():input.feeCoverage(); + if (selected.isEmpty()) return null; + if (!Objects.equals(group.cargoHash(),input.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化"); + var candidates=original(group.customerId(),group.cargo()); + var references=new ArrayList(); var scopes=new HashSet(); + for (var selection:selected) { + if (selection==null || selection.services()==null || selection.services().isEmpty()) throw conflict("请选择明确包含的服务"); + var fee=candidates.stream().filter(f->Objects.equals(f.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.size()!=selection.services().size() || !SERVICES.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 cargo=group.cargo().stream().map(c->new DeliveryFeeCoverageModels.Cargo(c.dispatchItemId(),c.warehousePalletId(), + c.palletNo(),c.orderId(),c.sourceRegion(),c.sourceInboundId(),c.inboundCode(),c.packages(),c.inboundCode())).toList(); + return new DeliveryFeeCoverageModels.Evidence(group.cargoHash(),cargo,List.copyOf(references),operatorId,Instant.now()); + } + + /** 按每条真实来源判断某服务是否已含,外接约定只覆盖派送服务。 */ + private boolean covered(CargoLine cargo,String service,DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight) { + if ("DELIVERY_FEE".equals(service) && freight.quotes().stream().anyMatch(q->Objects.equals(q.inboundCode(),cargo.inboundCode()))) return true; + return evidence!=null && "FIRSTLEG".equals(cargo.sourceRegion()) && evidence.references().stream().anyMatch(r-> + Objects.equals(r.source().sourceInboundId().toString(),cargo.sourceInboundId()) && r.services().contains(service)); + } + + /** 所有收费资格在创建子单前核验,新增应收只能针对未覆盖来源。 */ + public void validate(Group group,ArrangeGroup input,DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight) { + if (input.plannedDeliveryAt()==null || blank(input.deliveryMethod()) || blank(input.pickupMethod()) || blank(input.loadingType())) + throw conflict("请补齐每个客户目的地的派送信息"); + if (!Objects.equals(input.groupCargoHash(),group.cargoHash())) throw conflict("派送货物分组已变化"); + if (!Objects.equals(input.externalFreightHash(),freight.snapshotHash())) throw conflict("外接实收或约定运费已变化"); + var seen=new HashSet(); + for (var fee:input.fees()==null?List.of():input.fees()) { + if (fee==null || blank(fee.feeType()) || !SERVICES.contains(fee.feeType()) || blank(fee.direction()) || !Set.of("RECEIVABLE","PAYABLE").contains(fee.direction()) + || blank(fee.currency()) || !Set.of("CNY","USD").contains(fee.currency()) || blank(fee.feeName()) || fee.amount()==null || fee.amount().signum()<=0) + throw conflict("派送费用类型、方向、币种或金额不合法"); + if (!seen.add(fee.direction()+":"+fee.feeType())) throw conflict("同组同方向同费用不得重复登记"); + if ("RECEIVABLE".equals(fee.direction()) && group.cargo().stream().allMatch(c->covered(c,fee.feeType(),evidence,freight))) + throw conflict("本组该服务已全部覆盖,不得重复登记应收"); + } + var required=new HashSet(); required.add("DELIVERY_FEE"); + if (Set.of("FLOOR","MIXED").contains(input.loadingType())) required.add("LOADING_FEE"); + if ("BUY_APPOINTMENT".equals(input.appointmentAccount())) { + if (blank(input.sellerName())) throw conflict("买约须填写卖家名称");required.add("APPOINTMENT_FEE"); + } + for (var code:required) if (group.cargo().stream().anyMatch(c->!covered(c,code,evidence,freight)) && !seen.contains("RECEIVABLE:"+code)) + throw conflict("本组尚有未覆盖的服务费用,请核对" ); + } + + /** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */ + public void requireOriginalVoided(String customerId,FleetBookingFreight.Preview freight) { + fees.requireNoActiveDeliveryReceivables(customerId,freight.priorInboundCodes()); + } + + /** 费用与子单同事务保存,只向真实客户目的地发布一次正数来源。 */ + public void publish(Context context,Group group,ArrangeGroup input,DeliveryOrder delivery, + DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight,String operatorId,String arrangementKey) { + var now=LocalDateTime.now(); + for (var quote:freight.quotes()) if (Objects.equals(quote.destinationKey(),group.destination().key()) + && group.cargo().stream().anyMatch(c->Objects.equals(c.inboundCode(),quote.inboundCode()))) { + var operation=new DeliveryOperation();operation.setCustomerId(group.customerId());operation.setDeliveryOrderId(delivery.getId()); + operation.setOperatorId(operatorId);operation.setOperationNode("CONFIRM_EXTERNAL_FREIGHT"); + operation.setResult("外接预订 "+quote.bookingNo()+";实收确认版本 "+quote.recordVersion()+";约定运费 "+quote.currency()+" "+quote.amount().toPlainString()); + operation.setCreatedAt(now);repository.operation(operation); + if (quote.amount().signum()==0) continue; + fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),quote.bookingNo(),"FREIGHT")), + save(group,delivery,null,"DELIVERY_FEE","外接约定运费", "RECEIVABLE",quote.currency(),quote.amount(), + quote.quantity(),quote.quantityUnit(),quote.unitPrice(),null,quote.inboundCode())); + } + for (var fee:input.fees()==null?List.of():input.fees()) { + var charged=group.cargo().stream().filter(c->!"RECEIVABLE".equals(fee.direction())||!covered(c,fee.feeType(),evidence,freight)).toList(); + var orders=charged.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList(); + var inbound=charged.stream().map(CargoLine::inboundCode).filter(Objects::nonNull).distinct().toList(); + fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),fee.direction(),fee.feeType())), + save(group,delivery,orders.size()==1?orders.get(0):null,fee.feeType(),fee.feeName(),fee.direction(),fee.currency(), + fee.amount(),fee.chargeableQuantity(),fee.quantityUnit(),fee.unitPrice(),fee,inbound.size()==1?inbound.get(0):null)); + } + } + + /** 多来源不借第一票挂账,供应商应付与客户覆盖独立。 */ + private OverseasFeeSourceDtos.SaveRequest save(Group group,DeliveryOrder delivery,Long orderId,String code,String name, + String direction,String currency,BigDecimal amount,BigDecimal quantity,String unit,BigDecimal unitPrice,FeeItem input,String inboundCode) { + return new OverseasFeeSourceDtos.SaveRequest(group.customerId(),group.customerName(),"OVERSEAS_DELIVERY_FEE","DELIVERY_ORDER", + delivery.getId(),delivery.getDeliveryNo(),orderId,null,inboundCode,null,null,null,Long.valueOf(delivery.getOverseasWarehouseId()), + code,name,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(), + input==null?null:input.settlementMode(),input==null||input.paidAt()==null?null:LocalDateTime.ofInstant(input.paidAt(),ZoneId.systemDefault()), + input==null?null:input.paymentMethod(),input==null?null:input.paymentReferenceNo(),input==null?null:input.paymentVoucherFileIds(), + LocalDate.now(),currency,amount,quantity,unit,unitPrice,input==null?null:input.periodStart(),input==null?null:input.periodEnd()); + } + + /** 只有真实业务标识可用于费用归属。 */ + private static boolean blank(String value) { return value==null||value.isBlank(); } + /** 财务只读失败不降级为已覆盖或零费。 */ + private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"原费用来源暂不可用,请稍后核对"); } + /** 校验失败由外层事务回滚所有客户单据。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java new file mode 100644 index 000000000..46f2689c0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java @@ -0,0 +1,354 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups.Group; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */ +@Service +public class DispatchDeliveryService implements DispatchDeliveryPort { + private final DispatchExecutionPort execution; + private final DispatchDeliveryRepository repository; + private final DeliveryCargoGroups grouping; + private final DispatchDeliveryFees fees; + private final NormalDeliveryCompletionPolicy completion; + private final LastlegOrderNoGenerator numbers; + private final ObjectMapper json; + + /** 消费主流程实际公共骨架,禁止注入对方Controller或具体Service。 */ + public DispatchDeliveryService(@org.springframework.context.annotation.Lazy DispatchExecutionPort execution,DispatchDeliveryRepository repository, + DeliveryCargoGroups grouping,DispatchDeliveryFees fees,NormalDeliveryCompletionPolicy completion, + LastlegOrderNoGenerator numbers,ObjectMapper json) { + this.execution=execution;this.repository=repository;this.grouping=grouping;this.fees=fees; + this.completion=completion;this.numbers=numbers;this.json=json; + } + + /** 组合GET的入口上下文和本地派送数据加入同一个只读快照。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ArrangementPreview preview(Actor actor,String dispatchNo) { + return preview(execution.preview(actor,dispatchNo)); + } + + /** 外部原费只读带版本证据,不能声称参与Lastleg数据库同刻快照。 */ + @Override + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ArrangementPreview preview(Context context) { + var saved=repository.arrangement(context.dispatchId(),false); + var result=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); + var gate=arrangementGate(context); + if (context.cargo()==null || context.cargo().isEmpty() || !context.pickingReconciled()) + return new ArrangementPreview(context.dispatchNo(),context.recordVersion(),context.status(),context.stage(), + context.cargoHash(),context.loadingCompletionHash(),gate,List.of(),result); + var groups=grouping.groups(context); var external=fees.external(context); + var original=new HashMap>(); + for (var customer:groups.stream().map(Group::customerId).distinct().toList()) + original.put(customer,fees.original(customer,context.cargo().stream().filter(c->customer.equals(c.customerId())).toList())); + var previews=new ArrayList(); + for (var group:groups) { + var freight=external.get(group.customerId()); + var quotes=freight.quotes().stream().filter(q->Objects.equals(q.destinationKey(),group.destination().key()) + && group.cargo().stream().anyMatch(c->Objects.equals(c.inboundCode(),q.inboundCode()))) + .map(q->new ExternalQuote(group.customerId(),group.key(),q.bookingNo(),q.inboundCode(),q.currency(),q.amount(),freight.snapshotHash())).toList(); + var candidates=original.get(group.customerId()).stream().filter(f->group.cargo().stream().anyMatch(c-> + Objects.equals(c.sourceInboundId(),f.sourceInboundId().toString()))) + .map(f->new FeeCandidate(f.financeFeeId(),group.customerId(),f.currency(),f.amount(),f.sourceVersion(),f.sourceHash(), + f.status(),List.of("DELIVERY_FEE","LOADING_FEE","APPOINTMENT_FEE"),group.cargo().stream() + .filter(c->Objects.equals(c.sourceInboundId(),f.sourceInboundId().toString())).map(CargoLine::sourceKey).distinct().toList())).toList(); + var required=group.cargo().stream().anyMatch(c->freight.quotes().stream().noneMatch(q->Objects.equals(q.inboundCode(),c.inboundCode()))) + ?List.of("DELIVERY_FEE"):List.of(); + previews.add(new PreviewGroup(group.key(),group.customerId(),group.customerName(),group.destination(),group.cargoHash(),group.cargo(), + freight.snapshotHash(),quotes,group.cargoHash(),candidates,required,List.of())); + } + return new ArrangementPreview(context.dispatchNo(),context.recordVersion(),context.status(),context.stage(),context.cargoHash(), + context.loadingCompletionHash(),gate,List.copyOf(previews),result); + } + + /** 先共同父锁和严格命令恢复,再按客户与目的地原子创建全车单据。 */ + @Override + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult arrange(Actor actor,String dispatchNo,ArrangeDelivery request) { + if (actor==null || actor.mode()!=ActorMode.PLATFORM) throw new ResponseStatusException(HttpStatus.FORBIDDEN,"整车派送由平台安排"); + if (request==null || request.recordVersion()<0 || blank(request.idempotencyKey()) || request.idempotencyKey().length()>255 + || blank(request.cargoHash()) || blank(request.loadingCompletionHash()) || request.groups()==null || request.groups().isEmpty() + || request.groups().size()>200) throw conflict("安排派送请求不完整"); + var canonical=canonical(request); + var context=execution.lockForArrangement(actor,dispatchNo); + var identity=new CommandIdentity(Action.ARRANGE_DELIVERY,request.idempotencyKey(),request.recordVersion(), + grouping.hash(List.of(actor,Action.ARRANGE_DELIVERY,context.dispatchNo(),canonical))); + var replay=execution.replay(context,actor,identity); + if (replay.isPresent()) return replay.get(); + String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),canonical.cargoHash(),canonical.loadingCompletionHash(),canonical.groups())); + var saved=repository.arrangement(context.dispatchId(),true); + if (saved!=null) { + if (context.recordVersion()!=request.recordVersion() || !Objects.equals(saved.getBusinessHash(),businessHash)) + throw conflict("本车已安排派送,当前版本或业务内容不同"); + var current=departureFacts(context,true); + // 发车后的原安排也可作为既有业务事实读取,但取消/缺项绝不能补建为成功。 + if (context.status()!=Status.ACTIVE || current.outbounds().isEmpty() + || current.outbounds().stream().anyMatch(o->"CANCELLED".equals(o.status())) + || current.deliveries().stream().anyMatch(d->"CANCELLED".equals(d.status())) + || !completeDocuments(context,saved,repository.outbounds(context.dispatchId(),false),repository.deliveries(context.dispatchId(),false))) + throw conflict("原安排单据不完整或已取消,请核对原单据"); + return execution.recordArrangement(context,actor,identity,fact(saved),true); + } + execution.requireFreshArrangement(context,request.recordVersion()); + if (!Objects.equals(context.cargoHash(),request.cargoHash()) || !Objects.equals(context.loadingCompletionHash(),request.loadingCompletionHash())) + throw conflict("装车或实际货物已经变化,请重新核对"); + var groups=grouping.groups(context); var inputs=new HashMap(); + for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复"); + if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地"); + // 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。 + var existingOutbounds=repository.outbounds(context.dispatchId(),true); + var existingDeliveries=repository.deliveries(context.dispatchId(),true); + if (!existingOutbounds.isEmpty() || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对"); + var external=fees.external(context);var coverages=new HashMap(); + for (var group:groups) { + var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId())); + fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage); + } + var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap(); + for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { + var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); + var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer); + order.setOutboundNo(numbers.next("OUTBOUND",context.warehouseId(),order.getId())); + order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); + order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes(""); + order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum())); + order.setPalletCount((int)cargo.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count()); + order.setGrossWeightKg(metric(cargo,CargoLine::grossWeightKg));order.setVolumeCbm(metric(cargo,CargoLine::volumeCbm)); + order.setIdempotencyKey(grouping.hash(List.of(key,customer,"OUTBOUND")));order.setRequestHash(businessHash); + order.setCreatedBy(actor.operatorId());order.setCreatedAt(local(now));order.setUpdatedAt(local(now)); + var covered=groups.stream().filter(g->g.customerId().equals(customer)).map(g->coverages.get(g.key())).filter(Objects::nonNull).toList(); + if (!covered.isEmpty()) order.setFeeCoverageJson(write(mergeCoverage(covered,actor.operatorId()))); + repository.create(order);outbounds.put(customer,order); + } + var deliveries=new LinkedHashMap(); + for (var group:groups) { + var input=inputs.get(group.key());var outbound=outbounds.get(group.customerId()); + var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now); + repository.create(delivery);deliveries.put(group.key(),delivery); + for (var row:group.cargo()) { + var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId()); + item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId()); + item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId()); + item.setWarehousePalletId(row.warehousePalletId());item.setPalletNo(row.palletNo());item.setInboundCode(row.inboundCode()); + item.setDestinationWarehouseId(group.destination().warehouseId());item.setDestinationWarehouseCode(group.destination().warehouseCode()); + item.setDestinationWarehouseName(group.destination().warehouseName());item.setPackageCount(Math.toIntExact(row.packages())); + item.setPickedPackageCount(Math.toIntExact(row.packages()));item.setStatus("PICKED"); + item.setGrossWeightKg(row.grossWeightKg());item.setVolumeCbm(row.volumeCbm());item.setCartonMarks(row.cartonMark()); + item.setOrderRefs(row.orderNo());item.setCreatedAt(local(now));repository.create(item); + } + } + // 所有出库/派送均已插入且持有写锁,此后才申请本次全部来源锁。 + bindSources(context,groups,outbounds,deliveries,now); + for (var customer:external.keySet()) fees.requireOriginalVoided(customer,external.get(customer)); + for (var group:groups) fees.publish(context,group,inputs.get(group.key()),deliveries.get(group.key()),coverages.get(group.key()), + external.get(group.customerId()),actor.operatorId(),key); + var result=new ArrangementResult(context.dispatchNo(),context.recordVersion()+1,key,context.cargoHash(),now, + outboundRefs(outbounds.values(),groups),deliveryRefs(deliveries.values(),groups)); + var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId()); + arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash); + arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash()); + arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId()); + arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement); + return execution.recordArrangement(context,actor,identity,fact(arrangement),false); + } + + /** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public DepartureFacts departureFacts(Context context,boolean lock) { + var arrangement=repository.arrangement(context.dispatchId(),lock); + var outbounds=repository.outbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock); + var groups=context.cargo()==null||context.cargo().isEmpty()?List.of():grouping.groups(context); + String reason=arrangement==null?"请先安排本车全部客户派送": + !Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash()) + ?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排": + outbounds.stream().anyMatch(o->!"WAIT_OUTBOUND".equals(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus()) + || d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null; + return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(), + arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups), + context.cargo(),new Gate(reason==null,reason==null?null:"DELIVERY_NOT_READY",reason)); + } + + /** 只更新已锁单据,库存扣减及订单归属转换不在此重复执行。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) { + if (departedAt==null) throw conflict("缺少实际发车时间"); + var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason()); + var outbounds=repository.outbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false); + for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); } + for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt)); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); } + var groups=grouping.groups(context); + return new DepartureResult(outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),outbounds.stream().mapToLong(o->o.getPackageCount()).sum()); + } + + /** 签收汇总仅普通读取全部真实子单,不反向锁来源或再次发布费用。 */ + @Override + @Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED) + public NormalDeliveryFacts normalDeliveryFacts(Context context) { + var saved=repository.arrangement(context.dispatchId(),false); + var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); + var outbounds=repository.outbounds(context.dispatchId(),false); + var actual=new ArrayList(); + for (var delivery:repository.deliveries(context.dispatchId(),false)) { + var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId()) + && Objects.equals(o.getCustomerId(),delivery.getCustomerId()) && Objects.equals(o.getOutboundNo(),delivery.getOutboundNo())).findFirst().orElse(null); + var destination=destination(delivery);String groupKey=grouping.groupKey(delivery.getCustomerId(),destination); + // 使用安排时原组身份,避免FBA领域键与单据CODE键的表示差异。 + if (expected!=null) groupKey=expected.deliveries().stream().filter(d->d.deliveryNo().equals(delivery.getDeliveryNo())) + .map(DeliveryReference::groupKey).findFirst().orElse(groupKey); + long returned=repository.returns(delivery).stream().mapToLong(DeliveryReturnReceiptFact::packages).sum(); + actual.add(new NormalDeliveryFact(delivery.getCustomerId(),groupKey,delivery.getOutboundNo(),delivery.getDeliveryNo(), + Objects.requireNonNullElse(delivery.getVersion(),0L),Objects.requireNonNullElse(delivery.getPackageCount(),0), + Objects.requireNonNullElse(delivery.getSignedPackages(),0),returned,repository.activeReturns(delivery),delivery.getStatus(), + DeliveryExecutionGuard.matches(delivery,outbound),delivery.getSignedAt()==null?null:instant(delivery.getSignedAt()))); + } + var result=completion.evaluate(context,expected,actual,Instant.now()); + if (saved!=null && !completeDocuments(context,saved,outbounds,repository.deliveries(context.dispatchId(),false))) { + var reasons=new ArrayList<>(result.blockReasons());reasons.add("本车出库及派送集合与原安排不一致"); + return new NormalDeliveryFacts(result.dispatchNo(),result.factsHash(),false,null,result.expectedDeliveryNos(),result.deliveries(),List.copyOf(reasons)); + } + return result; + } + + /** 集合、客户、父引用和每组原始箱数全部相等,不仅比较全车总数。 */ + private boolean completeDocuments(Context context,DispatchDeliveryArrangement arrangement,List outbounds,List deliveries) { + var expected=read(arrangement.getResultJson(),ArrangementResult.class); + if (outbounds.size()!=expected.outbounds().size() || deliveries.size()!=expected.deliveries().size()) return false; + for (var ref:expected.outbounds()) if (outbounds.stream().filter(o->Objects.equals(o.getCustomerId(),ref.customerId()) + && Objects.equals(o.getOutboundNo(),ref.outboundNo()) && Objects.equals(o.getDispatchOrderId(),context.dispatchId()) + && Objects.equals(o.getOverseasWarehouseId(),context.warehouseId()) && Objects.equals((long)Objects.requireNonNullElse(o.getPackageCount(),0),ref.packageCount())).count()!=1) return false; + for (var ref:expected.deliveries()) if (deliveries.stream().filter(d->Objects.equals(d.getCustomerId(),ref.customerId()) + && Objects.equals(d.getDeliveryNo(),ref.deliveryNo()) && Objects.equals(d.getOutboundNo(),ref.outboundNo()) + && Objects.equals(d.getDispatchOrderId(),context.dispatchId()) && Objects.equals(d.getOverseasWarehouseId(),context.warehouseId()) + && Objects.equals(d.getDeliveryGroupKey(),grouping.documentGroupKey(ref.destination())) + && Objects.equals((long)Objects.requireNonNullElse(d.getPackageCount(),0),ref.packageCount()) + && outbounds.stream().anyMatch(o->Objects.equals(o.getId(),d.getOutboundOrderId()) && Objects.equals(o.getCustomerId(),d.getCustomerId()))).count()!=1) return false; + return expected.cargoHash().equals(context.cargoHash()); + } + + /** 按原实物来源数字ID一次锁齐全部来源,随后绑定状态和历史。 */ + private void bindSources(Context context,List groups,Map outbounds,Map deliveries,Instant now) { + var sources=new TreeMap>(); + for (var row:context.cargo()) if(row.orderId()!=null)sources.computeIfAbsent(row.orderId(),ignored->new ArrayList<>()).add(row); + var locked=new TreeMap(); + for (var entry:sources.entrySet()) { + var customer=entry.getValue().get(0).customerId(); + if(entry.getValue().stream().anyMatch(c->!customer.equals(c.customerId())))throw conflict("同一来源订单出现不同客户"); + var source=repository.lockSource(customer,entry.getKey()); + if(source==null||!Objects.equals(source.getCustomerId(),customer)||Boolean.TRUE.equals(source.getDetained()))throw conflict("派送来源订单不存在、暂扣或归属错误"); + locked.put(entry.getKey(),source); + } + for (var entry:sources.entrySet()) { + var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId()); + var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey()))) + .map(g->deliveries.get(g.key())).toList(); + repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now)); + for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId()); + history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo()); + history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);} + } + } + + /** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */ + private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) { + var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName()); + d.setDeliveryNo(numbers.next("DELIVERY",context.warehouseId(),d.getId()));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo()); + d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName()); + var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type()); + d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName()); + d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone()); + d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt())); + d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType()); + d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark()); + if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate()); + if(context.resources().driver()!=null){d.setDriverName(context.resources().driver().name());d.setDriverPhone(context.resources().driver().phone());}} + d.setPackageCount(Math.toIntExact(group.packages()));d.setPalletCount(Math.toIntExact(group.palletCount())); + d.setTotalWeightKg(metric(group.cargo(),CargoLine::grossWeightKg));d.setTotalVolumeCbm(metric(group.cargo(),CargoLine::volumeCbm)); + d.setIdempotencyKey(grouping.hash(List.of(key,group.key())));d.setCreatedBy(operator);d.setCreatedAt(local(now));d.setUpdatedAt(local(now));return d; + } + + /** 输出全部真实客户出库,不能留单个outboundNo别名。 */ + private List outboundRefs(Collection rows,List groups) { + return rows.stream().map(o->new OutboundReference(o.getCustomerId(),groups.stream().filter(g->g.customerId().equals(o.getCustomerId())) + .map(Group::customerName).filter(Objects::nonNull).findFirst().orElse(null),o.getOutboundNo(),o.getStatus(),Objects.requireNonNullElse(o.getPackageCount(),0),Objects.requireNonNullElse(o.getPalletCount(),0))).toList(); + } + /** 子单组身份由客户和目的地共同确定。 */ + private List deliveryRefs(Collection rows,List groups) { + return rows.stream().map(d->{var group=groups.stream().filter(g->g.customerId().equals(d.getCustomerId()) + && grouping.documentGroupKey(g.destination()).equals(d.getDeliveryGroupKey())).findFirst().orElse(null); + var dest=group==null?destination(d):group.destination();return new DeliveryReference(group==null?grouping.groupKey(d.getCustomerId(),dest):group.key(), + d.getCustomerId(),d.getCustomerName(),d.getOutboundNo(),d.getDeliveryNo(),dest,d.getStatus(),Objects.requireNonNullElse(d.getPackageCount(),0),Objects.requireNonNullElse(d.getPalletCount(),0));}).toList(); + } + /** 读取客户资料时只保留本客户来源与证据,原费用本身不复制为新来源。 */ + private DeliveryFeeCoverageModels.Evidence mergeCoverage(List rows,String operator) { + return new DeliveryFeeCoverageModels.Evidence(grouping.hash(rows.stream().map(DeliveryFeeCoverageModels.Evidence::cargoHash).toList()), + rows.stream().flatMap(e->e.cargo().stream()).toList(),rows.stream().flatMap(e->e.references().stream()).distinct().toList(),operator,Instant.now()); + } + /** 仅依据主流程阶段和真实核平显示资格,写入还需共同端口复验。 */ + private Gate arrangementGate(Context c) { + boolean allowed=c.status()==Status.ACTIVE&&c.stage()==Stage.AFTER_LOADING&&c.pickingReconciled()&&c.loadingCompletedAt()!=null; + return new Gate(allowed,allowed?null:"ARRANGEMENT_NOT_READY",allowed?null:"请先完成真实拣货核平及装车确认"); + } + /** 严格命令和业务摘要共用规范化集合,但原命令不移除版本和主体。 */ + private ArrangeDelivery canonical(ArrangeDelivery request) { + var inputs=new ArrayList(); + for(var g:request.groups()) { if(g==null||blank(g.groupKey()))throw conflict("派送分组身份不能为空"); + if(g.fees()!=null && (g.fees().size()>20||g.fees().stream().anyMatch(Objects::isNull)) + || g.feeCoverage()!=null && (g.feeCoverage().size()>2000||g.feeCoverage().stream().anyMatch(s->s==null||s.services()==null||s.services().stream().anyMatch(Objects::isNull)))) + throw conflict("派送费用或覆盖范围不合法"); + var selections=g.feeCoverage()==null?List.of():g.feeCoverage().stream() + .map(s->new CoverageSelection(s.financeFeeId(),s.sourceVersion(),s.sourceHash(),s.services().stream().sorted().toList())) + .sorted(Comparator.comparing(CoverageSelection::financeFeeId,Comparator.nullsFirst(Long::compareTo))).toList(); + var feeInputs=g.fees()==null?List.of():g.fees().stream().sorted(Comparator.comparing(FeeItem::direction,Comparator.nullsFirst(String::compareTo)) + .thenComparing(FeeItem::feeType,Comparator.nullsFirst(String::compareTo))).toList(); + inputs.add(new ArrangeGroup(g.groupKey(),g.groupCargoHash(),g.plannedDeliveryAt(),g.deliveryMethod(),g.waybillNo(),g.carrierName(),g.pickupMethod(), + g.loadingType(),g.vehicleType(),g.vehicleSize(),g.appointmentAccount(),g.sellerName(),g.remark(),g.externalFreightHash(),g.feeCoverageCargoHash(),selections,feeInputs));} + inputs.sort(Comparator.comparing(ArrangeGroup::groupKey)); + return new ArrangeDelivery(request.recordVersion(),request.idempotencyKey(),request.cargoHash(),request.loadingCompletionHash(),List.copyOf(inputs)); + } + /** 将持久安排还原为共同端口的完整不可变事实。 */ + private ArrangementFact fact(DispatchDeliveryArrangement saved) { return new ArrangementFact(saved.getArrangementKey(),saved.getBusinessHash(),saved.getCargoHash(), + saved.getLoadingCompletionHash(),read(saved.getResultJson(),ArrangementResult.class)); } + /** 始终使用原目的地业务数据,不用内部ID回退。 */ + private Destination destination(DeliveryOrder d) { return new Destination(d.getDeliveryGroupKey(),d.getDestinationType(),d.getDestinationWarehouseId(), + d.getDestinationWarehouseCode(),d.getDestinationWarehouseName(),d.getDeliveryAddress(),d.getPostalCode(),d.getContactName(),d.getContactPhone()); } + /** 未知物理量保持未知,不能补零或拿计费重量替代。 */ + private BigDecimal metric(List cargo,Function value) { return cargo.stream().anyMatch(c->value.apply(c)==null)?null: + cargo.stream().map(value).reduce(BigDecimal.ZERO,BigDecimal::add); } + /** 序列化不可变安排,不接受无法保存的半成品。 */ + 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 LocalDateTime local(Instant value) { return LocalDateTime.ofInstant(value,ZoneId.systemDefault()); } + /** 按既有服务端时区读取实际事件时间。 */ + private static Instant instant(LocalDateTime value) { return value.atZone(ZoneId.systemDefault()).toInstant(); } + /** 必要业务标识不允许空白。 */ + private static boolean blank(String value) { return value==null||value.isBlank(); } + /** 任一客户失败由外层事务回滚全部单据和费用。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java new file mode 100644 index 000000000..8f27477dd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; + +/** 只从认证主体构造平台操作身份,不接收请求中的客户/操作者参数。 */ +public final class DispatchActorFactory { + /** 工具类不可实例化。 */ + private DispatchActorFactory() { } + + /** 验证平台技术范围,客户账号不能读取或安排整车。 */ + public static Actor platform(AuthenticatedUser user) { + if(user==null || user.tenantId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"请先登录"); + if(!"PLATFORM".equals(user.appCode()) || user.tenantId()!=0L) throw new BizException(ResultCode.FORBIDDEN,"仅平台可访问整车作业"); + String operator=user.userId()==null?user.username():user.userId().toString(); + if(operator==null || operator.isBlank()) throw new BizException(ResultCode.UNAUTHORIZED,"操作身份无效"); + return new Actor(operator,ActorMode.PLATFORM,null); + } + /** PDA仓和操作者仅由既有仓作业身份校验器取出。 */ + public static Actor pda(AuthenticatedUser user) { + var identity=com.baiyue.lastleg.pda.security.PdaWarehouseIdentity.require(user); + return new Actor(identity.operatorKey(),ActorMode.PDA,Long.toString(identity.overseasWarehouseId())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java new file mode 100644 index 000000000..ad2c82703 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -0,0 +1,144 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.CommandResult; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 真整车建单/加货入口,接收共同DTO且不保留客户子排车协议。 */ +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches") +public class DispatchAggregateController { + private final DispatchAggregateService service; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure; + + /** 注入本域应用服务。 */ + public DispatchAggregateController(DispatchAggregateService service) { this.service=service; } + + /** 空单与先选货建单使用同一个正式入口。 */ + @PostMapping + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestBody DispatchCommands.Create request) { + return ApiResponse.success(service.create(DispatchActorFactory.platform(user),request)); + } + + /** 主单原版本和来源摘要共同核对后加货。 */ + @PostMapping("/{dispatchNo}/items") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse add(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @RequestBody DispatchCommands.AddCargo request) { + return ApiResponse.success(service.addCargo(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 版本化移货只接受真实明细身份。 */ + @PostMapping("/{dispatchNo}/items/{itemId}/remove") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse remove(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable long itemId,@RequestBody DispatchCommands.ReasonCommand request) { + return ApiResponse.success(service.removeCargo(DispatchActorFactory.platform(user),dispatchNo,itemId,request)); + } + /** 取消与主单版本及原命令键一起核对。 */ + @PostMapping("/{dispatchNo}/cancel") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReasonCommand request) { + return ApiResponse.success(service.cancel(DispatchActorFactory.platform(user),dispatchNo,request)); + } + + /** 移货弹框读取真实明细及当前公共版本。 */ + @GetMapping("/{dispatchNo}/items") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 读取实际起点和卸货顺序。 */ + @GetMapping("/{dispatchNo}/route") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse route(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.route(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 调整既有目的地顺序,服务端保留真实名称地址和外接目的地。 */ + @PutMapping("/{dispatchNo}/route") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse route(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.RouteCommand request) { + return ApiResponse.success(service.reorderRoute(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 实载弹框只读取本车实际拣货和测量证据。 */ + @GetMapping("/{dispatchNo}/loading") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 显式确认装车,不复用最后扫码或实际发车操作。 */ + @PostMapping("/{dispatchNo}/loading-complete") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')") + public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { + return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */ + @PostMapping("/{dispatchNo}/depart") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) { + return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 本车证据先上传,主单引用随后走版本化编辑。 */ + @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) { + return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file)); + } + /** 文件元数据不会暴露存储路径。 */ + @GetMapping("/{dispatchNo}/evidence/{kind}/{fileId}") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@PathVariable long fileId) { + return ApiResponse.success(service.evidenceMetadata(DispatchActorFactory.platform(user),dispatchNo,kind,fileId)); + } + /** 每次内容读取重新核对本车范围,禁止匿名文件URL。 */ + @GetMapping("/{dispatchNo}/evidence/{kind}/{fileId}/content") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public org.springframework.http.ResponseEntity evidenceContent(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@PathVariable long fileId) { + var actor=DispatchActorFactory.platform(user);var metadata=service.evidenceMetadata(actor,dispatchNo,kind,fileId); + return org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.parseMediaType(metadata.contentType())) + .cacheControl(org.springframework.http.CacheControl.noStore()).header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(metadata.fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .body(service.evidenceContent(actor,dispatchNo,kind,fileId)); + } + + /** 提交本轮两级审核。 */ + @PostMapping("/{dispatchNo}/submit-review") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.SubmitReview request) { + return ApiResponse.success(service.submitReview(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 调度审核先于理货审核。 */ + @PostMapping("/{dispatchNo}/dispatch-review") + @PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')") + public ApiResponse dispatchReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReviewCommand request) { + return ApiResponse.success(service.review(DispatchActorFactory.platform(user),dispatchNo,false,request)); + } + /** 理货审核通过后才允许真实扫码。 */ + @PostMapping("/{dispatchNo}/loading-review") + @PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')") + public ApiResponse loadingReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReviewCommand request) { + return ApiResponse.success(service.review(DispatchActorFactory.platform(user),dispatchNo,true,request)); + } + /** 零扫码退回保留原整车编号。 */ + @PostMapping("/{dispatchNo}/return-to-review") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse returnToReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReturnToReview request) { + return ApiResponse.success(service.returnToReview(DispatchActorFactory.platform(user),dispatchNo,request)); + } + + /** 原动作权限在服务层按持久动作再次检查,列表权限不能恢复他人命令。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + @PreAuthorize("isAuthenticated()") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(service.command(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index fe78e7aca..2d05acddd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -2,70 +2,33 @@ package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import com.baiyue.lastleg.dispatch.service.DispatchService; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; -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.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/platform/v1/last-leg/dispatches") @Tag(name = "Tail-leg vehicle dispatch") public class DispatchController { - @Resource private DispatchService service; + @Resource private com.baiyue.lastleg.dispatch.service.DispatchAggregateService aggregate; @PutMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") @Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses") - public ApiResponse update( + public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody DispatchRequests.Update request) { - requirePlatform(user); - return ApiResponse.success(service.update(dispatchNo, request)); + @Valid @RequestBody com.baiyue.lastleg.dispatch.port.DispatchCommands.Update request) { + return ApiResponse.success(aggregate.update(DispatchActorFactory.platform(user),dispatchNo,request)); } - @DeleteMapping("/{dispatchNo}/items/{itemId}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") - @Operation(summary = "Remove one unpicked pallet before an outbound order is generated") - public ApiResponse removeItem( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @PathVariable Long itemId) { - requirePlatform(user); - return ApiResponse.success(service.removeItem(dispatchNo, itemId)); - } - - @DeleteMapping("/{dispatchNo}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')") - @Operation(summary = "Cancel a pending dispatch and release its pallet locks") - public ApiResponse cancel( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo) { - requirePlatform(user); - service.cancel(dispatchNo); - return ApiResponse.success(null); - } - - private void requirePlatform(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - } - if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); - } - } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index feac81de7..536336a12 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -7,6 +7,8 @@ import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.service.DispatchService; import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.dispatch.port.DispatchViews; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,27 +36,27 @@ import java.nio.charset.StandardCharsets; @RequestMapping("/api/platform/v1/last-leg/dispatches") @Tag(name = "Tail-leg vehicle dispatch query") public class DispatchQueryController { - @Resource private DispatchService service; + @Resource private DispatchAggregateService aggregateService; @Resource private DispatchBolPrintService printService; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @Operation(summary = "Page dispatch orders") - public ApiResponse> page( + public ApiResponse page( @AuthenticationPrincipal AuthenticatedUser user, - @Valid DispatchRequests.Query query) { + @Valid DispatchViews.Query query) { requirePlatform(user); - return ApiResponse.success(service.platformPage(query)); + return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query)); } @GetMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @Operation(summary = "Get dispatch details and all pallet destinations") - public ApiResponse detail( + public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { requirePlatform(user); - return ApiResponse.success(service.platformDetail(dispatchNo)); + return ApiResponse.success(aggregateService.detail(DispatchActorFactory.platform(user),dispatchNo)); } @GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index b6239d6a4..17af0bea5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -2,154 +2,70 @@ package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningPort; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.security.OverseasPermissions; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -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.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - +import org.springframework.web.bind.annotation.*; import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; +/** 草稿仅走冻结的服务器版本化端口,候选继续由主流程提供真实来源。 */ @RestController -@ConditionalOnProperty(name = "lastleg.smart-dispatch-api.enabled", havingValue = "true") +@ConditionalOnProperty(name="lastleg.smart-dispatch-api.enabled",havingValue="true") @RequestMapping("/api/platform/v1/last-leg/dispatches") -@Tag(name = "Tail-leg smart dispatch") public class SmartDispatchController { - @Resource private DispatchService service; + @Resource private DispatchPlanningPort planning; + @Resource private DispatchAggregateService aggregate; + /** 读取实际候选,不在草稿域复制来源筛选逻辑。 */ @GetMapping("/candidates") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") - @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) - public ApiResponse> candidates( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) List planIds, - @RequestParam(required = false) String orderNo, - @RequestParam(required = false) String fbaWarehouseCode, - @RequestParam(required = false) String sourceStage, - @RequestParam(required = false) String draftToken) { - requirePlatform(user); - if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) { - return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken)); - } - return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, - fbaWarehouseCode, sourceStage, draftToken)); + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") + public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId, + @RequestParam(required=false)List planIds,@RequestParam(required=false)String orderNo, + @RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) { + return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken))); } - + /** 只列出当前拥有者未失效草稿。 */ @GetMapping("/drafts") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse> drafts(@AuthenticationPrincipal AuthenticatedUser user) { - requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user))); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") + public ApiResponse> drafts(@AuthenticationPrincipal AuthenticatedUser user){return ApiResponse.success(planning.drafts(DispatchActorFactory.platform(user)));} + /** 以持久命令键创建服务器token及零版本。 */ + @PostMapping("/drafts") + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse createDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody CreateDraft request){return ApiResponse.success(planning.createDraft(DispatchActorFactory.platform(user),request));} + /** 版本化布局保存只移动既有来源。 */ @PostMapping("/drafts/snapshot") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse saveDraft(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftSnapshot request) { - requirePlatform(user); service.saveDraftSnapshot(operatorId(user), request); return ApiResponse.success(null); - } - - @PostMapping("/pre-arrange") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH) - public ApiResponse preArrange( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.PreArrange request) { - requirePlatform(user); - String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank() - ? service.resolveCustomerScopeForSources(request.planPalletIds(), request.returnReceiptIds()) - : service.resolveCustomerScopeForRelabel(request.relabelNo()); - String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); - return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse saveDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSnapshot request){return ApiResponse.success(planning.saveSnapshot(DispatchActorFactory.platform(user),request));} + /** 一次转换全部容器,一容器一整车,不按客户拆头。 */ @PostMapping("/drafts/pre-arrange") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Atomically create one dispatch per loading container in an owned draft", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH) - public ApiResponse> preArrangeDraft( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.PreArrangeDraft request) { - requirePlatform(user); - return ApiResponse.success(service.preArrangeDraft(operatorId(user), request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse preArrangeDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody ConvertDraft request){return ApiResponse.success(planning.convert(DispatchActorFactory.platform(user),request));} + /** 取得本草稿容器的真实来源排他占用。 */ @PostMapping("/draft-locks/lock") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft") - public ApiResponse lockDraftPallet( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftPallet request) { - requirePlatform(user); - String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); - service.lockDraftPallet(customerId, operatorId(user), request); - return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse lockDraftPallet(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSelection request){return ApiResponse.success(planning.select(DispatchActorFactory.platform(user),request));} + /** 新建容器返回同一草稿的新版本。 */ @PostMapping("/drafts/containers") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse createContainer(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftContainer request) { - requirePlatform(user); return ApiResponse.success(service.createDraftContainer(operatorId(user), request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse createContainer(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody CreateContainer request){return ApiResponse.success(planning.createContainer(DispatchActorFactory.platform(user),request));} + /** DELETE同样带公共版本、原因及命令键,且只允许空容器。 */ @DeleteMapping("/drafts/{draftToken}/containers/{containerId}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse deleteContainer(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String draftToken, @PathVariable Long containerId) { - requirePlatform(user); service.deleteDraftContainer(operatorId(user), draftToken, containerId); return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse deleteContainer(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String draftToken,@PathVariable Long containerId, + @Valid @RequestBody DispatchCommands.ReasonCommand request){return ApiResponse.success(planning.removeContainer(DispatchActorFactory.platform(user),draftToken,containerId,request));} + /** 解锁只作用于原草稿原容器及原来源身份。 */ @PostMapping("/draft-locks/unlock") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Unlock one pallet removed from the current intelligent dispatch draft") - public ApiResponse unlockDraftPallet( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftPallet request) { - requirePlatform(user); - String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); - service.unlockDraftPallet(customerId, operatorId(user), request); - return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse unlockDraftPallet(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSelection request){return ApiResponse.success(planning.unselect(DispatchActorFactory.platform(user),request));} + /** 释放本拥有者原版本草稿,不释放其他作业占用。 */ @PostMapping("/draft-locks/release") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Release every pallet lock held by the current intelligent dispatch draft") - public ApiResponse releaseDraft( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftRelease request) { - requirePlatform(user); - service.releaseDraft(operatorId(user), request.draftToken()); - return ApiResponse.success(null); - } - - private String operatorId(AuthenticatedUser user) { - return user.userId() == null ? user.username() : String.valueOf(user.userId()); - } - - private void requirePlatform(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - } - if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); - } - } + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse releaseDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftRelease request){return ApiResponse.success(planning.release(DispatchActorFactory.platform(user),request));} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java new file mode 100644 index 000000000..0fc306e52 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.dispatch.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 真整车主单,不带客户头部;货物与来源各自保留真实客户。 */ +@Data +public class DispatchAggregate { + private Long id; + private String dispatchNo; + private Long tripId; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String status; + private String stage; + private Long recordVersion; + private Long cargoRevision; + private String cargoHash; + private String reviewState; + private Long reviewRound; + private String reviewBasisHash; + private String dispatchReviewJson; + private String loadingReviewJson; + private String resourcesJson; + private String vehiclePlate; + private String route; + private LocalDateTime plannedDepartureAt; + private LocalDateTime estimatedArrivalAt; + private LocalDateTime loadingCompletedAt; + private String loadingCompletionHash; + private String loadingConfirmationJson; + private String arrangementKey; + private String arrangementBusinessHash; + private LocalDateTime departedAt; + private LocalDateTime deliveryCompletedAt; + private Integer itemCount; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private String remark; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java new file mode 100644 index 000000000..6e412e3da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dispatch.entity; + +import java.time.LocalDateTime; + +/** 全局请求键的不可变成功记录,客户归属不能作为键分片。 */ +public record DispatchCommandRecord(Long id, Long dispatchId, String dispatchNo, String action, + String operatorId, String idempotencyKey, long recordVersion, + String commandHash, String resultJson, LocalDateTime createdAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java index bf4ce5818..ffb2daa22 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java @@ -13,8 +13,10 @@ public class DispatchDraft { @TableId(type = IdType.INPUT) private Long id; private String draftToken; - private String customerId; private String operatorId; + private String warehouseId; + private Long recordVersion; + private String sourcesJson; private String planIdsJson; private String containersJson; private String status; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java new file mode 100644 index 000000000..effc6ae6b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 草稿命令不可变身份及原始公开结果,与主单命令账本分域持久化。 */ +@Data +public class DispatchDraftCommand { + private Long id; + private String idempotencyKey; + private String operatorId; + private String action; + private String targetToken; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java index 74599d6bc..e346b2067 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java @@ -15,6 +15,7 @@ public class DispatchDraftContainer { private String draftToken; private Integer sequenceNo; private Integer capacity; + private String sourceKeysJson; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java index cb8d25f94..69c6ba311 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -16,6 +16,10 @@ public class DispatchItem { private Long id; private Long dispatchOrderId; private String customerId; + private String customerName; + private String sourceKey; + private String sourceVersion; + private String sourceSnapshotJson; private Long planId; private Long planPalletId; private Long returnReceiptId; 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 index 32e2e5b53..a5fd6f89b 100644 --- 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 @@ -1,275 +1,151 @@ 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.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities; -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 com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.fleet.exception.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.*; +import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; -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; - +/** Fleet附加能力读取完整整车,所有客户归属均来自真实明细及客户单据。 */ @Component -@Transactional(propagation = Propagation.MANDATORY) +@Transactional(propagation=Propagation.MANDATORY) public class DispatchFleetAdapter implements FleetDispatchGateway { - @jakarta.annotation.Resource - private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution; - @jakarta.annotation.Resource - private com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery cargoSources; - private final DispatchOrderMapper orders; - private final DispatchPdaTaskMapper tasks; + private final DispatchAggregateRepository aggregates; private final OutboundOrderMapper outbounds; private final DeliveryOrderMapper deliveries; - private final DeliveryOperationMapper deliveryOperations; - private final DispatchItemMapper items; - private final DispatchPdaPickMapper picks; - private final DispatchPdaWorkflowService workflow; - @jakarta.annotation.Resource @org.springframework.context.annotation.Lazy - private com.baiyue.lastleg.dispatch.service.DispatchService dispatchService; - - 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; + private final DeliveryOperationMapper operations; + private final DispatchCargoSourceRepository sources; + private final DispatchAggregateService workflow; + private final ObjectMapper json; + public DispatchFleetAdapter(DispatchAggregateRepository aggregates,OutboundOrderMapper outbounds, + DeliveryOrderMapper deliveries,DeliveryOperationMapper operations,DispatchCargoSourceRepository sources, + @Lazy DispatchAggregateService workflow,ObjectMapper json) { + this.aggregates=aggregates;this.outbounds=outbounds;this.deliveries=deliveries;this.operations=operations; + this.sources=sources;this.workflow=workflow;this.json=json; } - - @Override - public void removeItem(String dispatchNo,long itemId) { dispatchService.removeItem(dispatchNo,itemId); } - - @Override - public Snapshot lockForPlanning(String dispatchNo) { - return lockBeforeDeparture(dispatchNo, false); + /** 批量只读主单当前审核,绑定表没有审核副本。 */ + @Override public Map workflows(List numbers) { + if(numbers.size()>1000)throw conflict("整车审核批量读取超过上限"); + Map result=new HashMap<>(); + for(var row:aggregates.findMany(numbers))result.put(row.getDispatchNo(),new Workflow(row.getStatus(),row.getStage(),row.getRecordVersion(),review(row.getDispatchReviewJson()),review(row.getLoadingReviewJson()))); + return Map.copyOf(result); } - - @Override - public Snapshot lockForAssignment(String dispatchNo) { - return lockBeforeDeparture(dispatchNo, true); + private FleetTripModels.ReviewState review(String stored) { + try {var value=json.readValue(stored,DispatchContracts.Review.class);return new FleetTripModels.ReviewState(value.status().name(),value.reason(),value.reviewerId(),value.reviewedAt(),null);} + catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("整车审核事实读取失败",error);} } - - 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()); + /** 根锁已由上游trip/资源取得,禁止在已有扫码后以旧计划命令调整。 */ + @Override public Snapshot lockForPlanning(String number) { + var row=required(number,true); + if(!"ACTIVE".equals(row.getStatus()) || !"BEFORE_LOADING".equals(row.getStage()) || aggregates.realPicked(row.getId())!=0) + throw conflict("仅本车装车前且未扫码时可安排货物"); + return new Snapshot(row.getId(),row.getDispatchNo(),row.getOverseasWarehouseId(),row.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 List customers(String number) { + var row=required(number,false);return aggregates.cargo(row.getId()).stream().map(i->i.getCustomerId()).distinct().sorted(Comparator.comparingLong(Long::parseLong)).toList(); + } + /** 逐客户读取全部来源组成,不用主单或整托展示的一个客户替代。 */ + @Override public List sourceCargo(String number,String customer) { + var row=required(number,false);var items=aggregates.cargo(row.getId()).stream().filter(i->customer.equals(i.getCustomerId())).toList(); + var facts=sources.sources(customer,row.getId());var result=new ArrayList(); + for(var item:items) { + String destination=destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()); + if(item.getReturnReceiptId()!=null || Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(),""))) { + result.add(new SourceCargo(item.getInboundCode(),item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),item.getPackageCount(),destination));continue; + } + var selected=facts.stream().filter(f->Objects.equals(f.dispatchItemId(),item.getId())).toList(); + if(selected.isEmpty() || selected.stream().mapToLong(f->f.packages()).sum()!=item.getPackageCount())throw conflict("本客户实际来源与排车箱数未核平"); + for(var fact:selected) { + if(fact.inboundCode()==null || fact.inboundCode().isBlank() || fact.packages()<=0 || !Objects.equals(fact.warehousePalletId(),item.getWarehousePalletId()))throw conflict("本客户来源缺少真实入仓码或实托"); + result.add(new SourceCargo(fact.inboundCode(),fact.warehousePalletId().toString(),fact.palletNo(),Math.toIntExact(fact.packages()),destination)); + } + } + return List.copyOf(result); + } + @Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) { + var context=workflow.fleetContext(number,lock);var row=required(number,false); + var cargo=aggregates.cargo(row.getId()).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(), + destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList(); + String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null; + var vehicle=context.resources().vehicle();var driver=context.resources().driver(); + return new FleetExecutionModels.Manifest(number,row.getOverseasWarehouseId(),vehicle==null?null:vehicle.plate(),driver==null?null:driver.name(),driver==null?null:driver.phone(),cargo,reason); + } + /** 先收齐所有客户出库锁,再锁所有派送;客户和仓归属从每行复验。 */ + private Facts facts(String number,boolean lock) { + var row=required(number,lock); + var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList(); + var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList(); + var expected=aggregates.cargo(row.getId()).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount()))); + var byCustomer=new HashMap(); + for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致"); + if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物"); + var counts=new HashMap();var numbers=new HashSet(); + for(var delivery:deliveryRows) { + var outbound=byCustomer.get(delivery.getCustomerId()); + if(outbound==null || !numbers.add(delivery.getDeliveryNo()) || !Objects.equals(delivery.getDispatchOrderId(),row.getId()) || !Objects.equals(delivery.getOutboundOrderId(),outbound.getId()) || !Objects.equals(delivery.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || delivery.getPackageCount()==null || delivery.getPackageCount()<=0 || delivery.getVersion()==null)throw conflict("客户派送归属或数量与本车出库不一致"); + counts.merge(delivery.getCustomerId(),delivery.getPackageCount().longValue(),Math::addExact); + } + if(!counts.equals(expected))throw conflict("本车全部客户派送数量未核平"); + return new Facts(row,deliveryRows); + } + private record Facts(DispatchAggregate root,List deliveries) { } + @Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) { + var row=required(number,lock); + if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成"); + var facts=facts(number,lock);var outcomes=new ArrayList(); + for(var delivery:facts.deliveries()) { + var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts); + Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null; + outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining())); + } + String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null; + return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason); + } + @Override public FleetTransitEvidence transitEvidence(String number,boolean lock) { + var facts=facts(number,lock);var row=facts.root(); + if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null || row.getShippedPackageCount()<=0)throw conflict("交接必须具有本车真实发运依据"); + var result=new ArrayList(); + for(var delivery:facts.deliveries()) { + if(!Set.of("DELIVERING","REJECTED","DELIVERED","RETURN_PENDING","RETURNED").contains(Objects.toString(delivery.getStatus(),"")))throw conflict("本车客户派送状态与实际发运不一致"); + var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts); + Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null; + if(("DELIVERED".equals(delivery.getStatus()) || "RETURNED".equals(delivery.getStatus())) && (terminal==null || quantities.remaining()!=0))throw conflict("派送终态缺少完整真实签收或退回证据"); + String key=destination(delivery.getDestinationType(),delivery.getDestinationWarehouseCode(),null,delivery.getDeliveryAddress(),delivery.getPostalCode());if(key==null)throw conflict("派送缺少真实卸货目的地"); + var cargo=new FleetHandoverPolicy.Delivery(delivery.getCustomerId(),number,delivery.getDeliveryNo(),key,delivery.getPackageCount(),terminal,originallySigned(delivery)?List.of():receipts.stream().map(r->{if(r.dispatchItemId()==null || r.palletId()==null)throw conflict("实收退回缺少原明细和实托");return new FleetHandoverPolicy.ReturnedCargo(r.dispatchItemId().toString(),r.palletId().toString(),Math.toIntExact(r.packages()),instant(r.receivedAt()));}).toList()); + result.add(new FleetTransitEvidence.Delivery(cargo,delivery.getStatus(),delivery.getVersion(),new FleetTransitEvidence.Transport(delivery.getVehiclePlate(),delivery.getDriverName(),delivery.getDriverPhone()))); + } + return new FleetTransitEvidence(number,row.getOverseasWarehouseId(),row.getShippedPackageCount(),List.copyOf(result)); + } + @Override public void handover(FleetTransitEvidence evidence,Assignment assignment) { + var current=transitEvidence(evidence.dispatchNo(),true);if(!current.equals(evidence))throw conflict("本车交接事实已变化,请重新核对"); + for(var fact:evidence.deliveries()) { + if(fact.cargo().terminalAt()!=null)continue; + var row=deliveries.lockByNo(fact.cargo().customerId(),fact.cargo().deliveryNo()); + if(row==null || !Objects.equals(row.getVersion(),fact.recordVersion()) || row.getSignedAt()!=null)throw conflict("本客户派送已变化"); + if(deliveries.update(null,new LambdaUpdateWrapper().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.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 = "实物托盘或目的地未确认"; - if (reason == null) reason = deliveryExecution.departureBlockReason(outbound, lock); - 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 cargoSources.sources(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId())); - } - @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()); - if (outbound != null && (rows.stream().mapToLong(row -> Objects.requireNonNullElse(row.getPackageCount(), 0)).sum() - != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount()))) - return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), java.util.List.of(), "派送单数量未覆盖本次全部实发货物"); - var outcomes = rows.stream().map(row -> { - var quantities = quantities(row, deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId())); - return 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, - quantities.signed(), quantities.returned(), quantities.remaining()); - }).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 || row.remainingPackages() != 0 - || row.signedPackages() + row.returnedPackages() != row.packages() - || !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. - @Override - public com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(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()); - if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus()) - || dispatch.getShippedPackageCount() == null || dispatch.getShippedPackageCount() <= 0 - || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) - throw conflict("交接必须具有已完成出库的真实发运依据"); - var rows = lock ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) - : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId()); - var facts = new java.util.ArrayList(); - long packages = 0; - for (var row : rows) { - if (!Objects.equals(row.getCustomerId(), dispatch.getCustomerId()) - || !Objects.equals(row.getDispatchOrderId(), dispatch.getId()) - || !Objects.equals(row.getOutboundOrderId(), outbound.getId()) - || !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId()) - || row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null - || row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERING", "REJECTED", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus())) - throw conflict("派送货物归属或执行状态与原始发运不一致"); - Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) - ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null; - if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null) - throw conflict("派送终态缺少真实签收或退回完成证据"); - var receipts = deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId()); - var quantities = quantities(row, receipts); - if (terminal != null && quantities.remaining() != 0) throw conflict("派送终态数量未覆盖原实发货物"); - String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null, - row.getDeliveryAddress(), row.getPostalCode()); - if (key == null) throw conflict("派送单缺少真实卸货目的地"); - var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(), - dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal, - originallySigned(row) ? java.util.List.of() : receipts.stream().map(receipt -> { - if (receipt.dispatchItemId() == null || receipt.palletId() == null) throw conflict("实际退回缺少原发运实托明细归属"); - return new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.ReturnedCargo( - receipt.dispatchItemId().toString(), receipt.palletId().toString(), Math.toIntExact(receipt.packages()), instant(receipt.receivedAt())); - }).toList()); - facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(), - new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone()))); - packages = Math.addExact(packages, row.getPackageCount()); - } - if (facts.isEmpty() || packages != dispatch.getShippedPackageCount()) - throw conflict("派送明细未核平本次实发货物"); - return new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence(dispatchNo, dispatch.getCustomerId(), - dispatch.getOverseasWarehouseId(), dispatch.getShippedPackageCount(), java.util.List.copyOf(facts)); - } - - @Override - public void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment) { - var current = transitEvidence(evidence.dispatchNo(), true); - if (!current.equals(evidence)) throw conflict("交接货物已变化,请重新核对"); - for (var fact : evidence.deliveries()) { - if (fact.cargo().terminalAt() != null) continue; - var row = deliveries.lockByNo(evidence.customerId(), fact.cargo().deliveryNo()); - if (row == null || !Objects.equals(row.getVersion(), fact.recordVersion()) || row.getSignedAt() != null) - throw conflict("派送货物已变化,请重新核对交接"); - if (deliveries.update(null, new LambdaUpdateWrapper() - .eq(DeliveryOrder::getId, row.getId()).eq(DeliveryOrder::getCustomerId, evidence.customerId()) - .eq(DeliveryOrder::getVersion, fact.recordVersion()).eq(DeliveryOrder::getStatus, fact.status()) - .isNull(DeliveryOrder::getSignedAt) - .set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName()) - .set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, fact.recordVersion() + 1) - .set(DeliveryOrder::getUpdatedAt, LocalDateTime.now())) != 1) - throw conflict("派送交接保存失败,请刷新核对"); - } - } - - private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); } - private static DeliveryCargoQuantities quantities(DeliveryOrder row, java.util.List receipts) { - try { return DeliveryCargoQuantities.from(row, receipts); } - catch (IllegalArgumentException error) { throw conflict(error.getMessage()); } - } - 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); } + private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;} + private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);} + /** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */ + private static Instant instant(LocalDateTime value){return value==null?null:value.atZone(ZoneId.systemDefault()).toInstant();} + private static String first(String a,String b){return a!=null&&!a.isBlank()?a:b==null||b.isBlank()?null:b;} + private static boolean originallySigned(DeliveryOrder row){return row.getSignedAt()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(row.getStatus());} + private static DeliveryCargoQuantities quantities(DeliveryOrder row,List receipts){try{return DeliveryCargoQuantities.from(row,receipts);}catch(IllegalArgumentException error){throw conflict(error.getMessage());}} + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java index 61dcbe5ed..0d504905e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java @@ -1,58 +1,52 @@ package com.baiyue.lastleg.dispatch.fleet; - -import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.dispatch.service.DispatchService; -import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; -import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.trip.*; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.*; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.server.ResponseStatusException; import org.springframework.http.HttpStatus; import java.util.*; - +/** 外接预订绑定真实客户来源,整车身份由当前车次唯一关联解析。 */ @Component @Transactional(propagation=Propagation.MANDATORY) public class FleetDispatchPlanningAdapter implements FleetDispatchPlanningGateway { - private final FleetDispatchPlanningMapper mapper; - private final DispatchService dispatches; - private final FleetDispatchGateway cargo; - public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,@Lazy DispatchService dispatches,FleetDispatchGateway cargo) { - this.mapper=mapper;this.dispatches=dispatches;this.cargo=cargo; + private final FleetDispatchPlanningMapper mapper;private final DispatchSourceRepository sources; + private final DispatchService rules;private final FleetDispatchGateway cargo;private final DispatchAggregateService workflow; + public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,DispatchSourceRepository sources,@Lazy DispatchService rules, + FleetDispatchGateway cargo,@Lazy DispatchAggregateService workflow){this.mapper=mapper;this.sources=sources;this.rules=rules;this.cargo=cargo;this.workflow=workflow;} + @Override public String resolveIncoming(String tripNo,String customer,FleetBookingIncomingGateway.Evidence receipt,String operator,String key) { + if(receipt==null || receipt.inboundCode()==null || !"READY_FOR_DISPATCH".equals(receipt.status()) || receipt.pallets().isEmpty())throw conflict("请先完成真实收货和实收核对"); + String number=mapper.dispatchForTrip(tripNo);if(number==null)throw conflict("本车缺少唯一整车主单"); + var authentication=SecurityContextHolder.getContext().getAuthentication(); + var actor=DispatchActorFactory.platform(authentication!=null && authentication.getPrincipal() instanceof AuthenticatedUser user?user:null); + if(!Objects.equals(actor.operatorId(),operator))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"外接安排操作者不一致"); + var context=workflow.lockForArrangement(actor,number);var selected=new HashSet<>(mapper.selectedPlans(context.dispatchId(),customer)); + var additions=new ArrayList(); + for(var id:mapper.receivedPlanPallets(customer,receipt.inboundCode())) { + if(selected.contains(id))continue;var candidates=sources.selectedPlan(customer,id); + if(candidates.size()!=1)throw conflict("外接实收来源已变化,请重新核对");var candidate=candidates.get(0); + if(!Objects.equals(candidate.overseasWarehouseId(),context.warehouseId()))throw conflict("外接来源起运仓不一致"); + additions.add(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,id,null,null,null,rules.wholeSourceVersion(candidate,"PLAN:"+id,false))); + } + if(!additions.isEmpty())workflow.addCargo(actor,number,new DispatchCommands.AddCargo(context.recordVersion(),key,additions,List.of())); + validateIncomingDispatch(number,customer,receipt);return number; } - public PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size) { - var result=mapper.candidates(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page,size),tripNo,warehouseId,keyword); - return new PageResult<>(result.getRecords(),result.getCurrent(),result.getSize(),result.getTotal(),result.getPages()); - } - public String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator) { - if(receipt==null||receipt.inboundCode()==null||!"READY_FOR_DISPATCH".equals(receipt.status())||receipt.pallets().isEmpty()) - throw conflict("请先完成真实收货和实收核对"); - var existing=mapper.sourceDispatches(customerId,receipt.inboundCode()); - if(existing.size()>1)throw conflict("本次来货已有多张排车安排,请调度核查:"+String.join("、",existing)); - String number; - if(existing.isEmpty()) { - var ids=mapper.receivedPlanPallets(customerId,receipt.inboundCode()); - if(ids.isEmpty())throw conflict("实收来源没有可用于排车的真实托盘"); - number=dispatches.preArrange(customerId,operator,new DispatchRequests.PreArrange(ids,null,null,null,null)).dispatchNo(); - } else number=existing.get(0); - validateIncomingDispatch(number,customerId,receipt); - return number; - } - public void validateIncomingDispatch(String number,String customerId,FleetBookingIncomingGateway.Evidence receipt) { - var snapshot=cargo.lockForPlanning(number); - if(!customerId.equals(snapshot.customerId())||!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("排车客户或起运仓与来货不一致"); - var expected=new TreeMap(); - for(var p:receipt.pallets())expected.merge(p.warehousePalletId(),p.packages(),Math::addExact); + @Override public void validateIncomingDispatch(String number,String customer,FleetBookingIncomingGateway.Evidence receipt) { + var snapshot=cargo.lockForPlanning(number);if(!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("整车起运仓与外接实收不一致"); + var expected=new TreeMap();for(var pallet:receipt.pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact); var selected=new TreeMap(); - for(var item:cargo.sourceCargo(number)) { - if(!Objects.equals(receipt.inboundCode(),item.inboundCode())||!Objects.equals(receipt.destinationKey(),item.destinationKey())||item.warehousePalletId()==null) - throw conflict("候选排车包含其他来源或目的地,请调度核查 "+number); + for(var item:cargo.sourceCargo(number,customer))if(Objects.equals(receipt.inboundCode(),item.inboundCode())) { + if(item.warehousePalletId()==null || !Objects.equals(receipt.destinationKey(),item.destinationKey()))throw conflict("本客户外接实收托盘或目的地不一致"); selected.merge(item.warehousePalletId(),item.packages(),Math::addExact); } - if(!expected.equals(selected))throw conflict("候选排车未完整覆盖本票全部实收货物,请调度核查 "+number); + if(!expected.equals(selected))throw conflict("本车未完整覆盖本客户本票外接实收来源"); } - private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} + private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java index 60f01d757..28f587908 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java @@ -1,28 +1,13 @@ package com.baiyue.lastleg.dispatch.fleet; - -import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; import org.apache.ibatis.annotations.*; import java.util.List; - +/** 外接实收仅加入本车真实主单,不生成客户子排车。 */ @Mapper public interface FleetDispatchPlanningMapper { - @Select({""}) - IPage candidates(IPage page,@Param("tripNo") String tripNo,@Param("warehouseId") String warehouseId,@Param("keyword") String keyword); - - @Select("SELECT DISTINCT d.dispatch_no FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id " + - "WHERE d.customer_id=#{customerId} AND i.inbound_code=#{inboundCode} AND d.status!='CANCELLED' ORDER BY d.dispatch_no") - List sourceDispatches(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode); - + @Select("SELECT dispatch_no FROM ll_fleet_trip_dispatch WHERE trip_no=#{tripNo}") + String dispatchForTrip(@Param("tripNo") String tripNo); + @Select("SELECT DISTINCT plan_pallet_id FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id IS NOT NULL") + List selectedPlans(@Param("dispatch") long dispatch,@Param("customer") String customer); @Select("SELECT DISTINCT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "WHERE ip.customer_id=#{customerId} AND l.inbound_code=#{inboundCode} AND p.warehouse_pallet_id IS NOT NULL ORDER BY p.id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java new file mode 100644 index 000000000..bedd86d03 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dispatch.gateway; + +import org.springframework.web.multipart.MultipartFile; + +/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */ +public interface DispatchEvidenceGateway { + enum Kind { VEHICLE, DRIVER } + record File(Long fileId,String fileName,String contentType,long sizeBytes) { } + /** 上传只写本车、本类证据。 */ + File upload(long dispatchId,Kind kind,String operator,MultipartFile file); + /** 审核前逐文件复验平台、本车与类型复合归属。 */ + File metadata(long dispatchId,Kind kind,long fileId); + /** 下载仍通过本车读权限入口。 */ + byte[] download(long dispatchId,Kind kind,long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java new file mode 100644 index 000000000..9f2516f8e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java @@ -0,0 +1,96 @@ +package com.baiyue.lastleg.dispatch.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** 本域只调用File内部通用业务文件入口,不读取File表或返回存储地址。 */ +@Component +public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { + private static final long MAX=10L*1024*1024; + private static final Set TYPES=Set.of("application/pdf","image/jpeg","image/png"); + private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}"; + private final RestClient client; + private final String token; + /** 复用既有内部客户端超时与身份配置,不新增环境地址。 */ + public HttpDispatchEvidenceGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}")String url,@Value("${baiyue.security.internal-service-token:}")String token) { + client=factory.build(builder,url,properties.getCustomerVisitFile());this.token=token; + } + /** 类型由枚举生成,禁止请求指定任意File业务域。 */ + private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; } + /** 本车证据上传完成后仍以File返回元数据核对复合归属。 */ + @Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) { + if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid(); + var body=new MultipartBodyBuilder(); + try { body.part("file",new ByteArrayResource(file.getBytes()) { + /** 保留用户文件名供审阅,不把路径当业务事实。 */ + @Override public String getFilename(){return file.getOriginalFilename();} + }).contentType(MediaType.parseMediaType(file.getContentType())); }catch(java.io.IOException error){throw invalid();} + var result=client.post().uri(PATH,type(kind),dispatch).header("X-Internal-Service-Token",token).header("X-Operator-Id",operator) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().onStatus(HttpStatusCode::isError,(q,r)->{throw unavailable(r.getStatusCode());}).body(Envelope.class); + return verified(result,dispatch,kind,null); + } + /** 不接受其他车、其他证据类型或失效文件。 */ + @Override public File metadata(long dispatch,Kind kind,long id) { + var result=client.get().uri(PATH+"/{file}",type(kind),dispatch,id).header("X-Internal-Service-Token",token).retrieve() + .onStatus(HttpStatusCode::isError,(q,r)->{throw unavailable(r.getStatusCode());}).body(Envelope.class); + return verified(result,dispatch,kind,id); + } + /** 先核对元数据再读取有界内容。 */ + @Override public byte[] download(long dispatch,Kind kind,long id) { + var expected=metadata(dispatch,kind,id); + return client.get().uri(PATH+"/{file}/content",type(kind),dispatch,id).header("X-Internal-Service-Token",token).exchange((request,response)->{ + if(response.getStatusCode().isError())throw unavailable(response.getStatusCode()); + // 只读取上限加一,防止损坏上游响应绕过已核实的10MiB元数据上限。 + var bytes=response.getBody().readNBytes((int)MAX+1); + return verifiedContent(expected,response.getHeaders().getContentType(),response.getHeaders().getContentLength(),bytes); + }); + } + /** HTTP200业务错误JSON不能伪装成PDF或图片;MIME、长度及签名必须与元数据一致。 */ + static byte[] verifiedContent(File expected,MediaType contentType,long contentLength,byte[] bytes) { + if(bytes==null || bytes.length==0 || bytes.length>MAX)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + if(contentType!=null && (MediaType.APPLICATION_JSON.isCompatibleWith(contentType) || contentType.getSubtype().endsWith("+json"))) { + String code=null;try{code=new com.fasterxml.jackson.databind.ObjectMapper().readTree(bytes).path("code").asText(null);}catch(java.io.IOException ignored){/* 无法识别的上游错误响应按故障处理。 */} + throw businessFailure(code); + } + if(bytes.length!=expected.sizeBytes() || contentLength>=0 && contentLength!=bytes.length + || contentType==null || !MediaType.parseMediaType(expected.contentType()).isCompatibleWith(contentType))throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + boolean signature=switch(expected.contentType()) { + case "application/pdf" -> bytes.length>=5 && new String(bytes,0,5,java.nio.charset.StandardCharsets.US_ASCII).equals("%PDF-"); + case "image/png" -> bytes.length>=4 && (bytes[0]&255)==0x89 && bytes[1]==0x50 && bytes[2]==0x4e && bytes[3]==0x47; + case "image/jpeg" -> bytes.length>=2 && (bytes[0]&255)==0xff && (bytes[1]&255)==0xd8; + default -> false; + }; + if(!signature)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);return bytes; + } + /** File只能证明本车文件事实,审核决策仍属于本域。 */ + static File verified(Envelope envelope,long dispatch,Kind kind,Long id) { + if(envelope==null)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + if(!"200".equals(envelope.code()))throw businessFailure(envelope.code()); + if(envelope.data()==null)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + var value=envelope.data(); + if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId()) + || value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank() + || !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid(); + return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes()); + } + /** 无效证据不能作为已核实资料。 */ + private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} + /** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */ + private static ResponseStatusException businessFailure(String code){return code!=null && code.startsWith("A04") + ?new ResponseStatusException("A0404".equals(code)?HttpStatus.NOT_FOUND:HttpStatus.BAD_REQUEST,"证据文件不可用或归属核验失败") + :unavailable(HttpStatus.SERVICE_UNAVAILABLE);} + /** 上游不可用与业务文件不合格分别保留HTTP语义。 */ + private static ResponseStatusException unavailable(HttpStatusCode status){return new ResponseStatusException(status.is4xxClientError()?HttpStatus.BAD_REQUEST:HttpStatus.SERVICE_UNAVAILABLE,"证据文件不可用或归属核验失败");} + record Envelope(String code,Metadata data) { } + record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java new file mode 100644 index 000000000..d4c0197ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 真整车主单SQL,不按单客户头部筛选或创建。 */ +@Mapper +public interface DispatchAggregateMapper { + /** Fleet附加能力批量读取当前整车审核事实,不读取绑定表旧审核。 */ + @Select("") + java.util.List findMany(@Param("numbers") java.util.List numbers); + String BASE_FILTER = """ + AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%') + AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%') + AND d.overseas_warehouse_id=#{q.warehouseId} + AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId}) + AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo}) + """; + String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END"; + String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END"; + String CURRENT_FILTER = """ + AND d.status=#{q.status} + AND d.stage=#{q.stage} + AND ( + """ + REVIEW_DISPATCH + ")=#{q.reviewStatus}" + + " AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}"; + + record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId, + String fbaAppointmentNo, String status, String stage, String section, String reviewStatus, + long offset, int limit) { } + record Summary(String status, String stage, String reviewState, long quantity) { } + + /** 首次创建整车所有权及初始版本,不写customer_id。 */ + @Insert(""" + INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code, + overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round, + dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at, + estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count, + remark,created_by,created_at,updated_at) + VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, + #{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash}, + #{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson}, + #{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount}, + #{r.packageCount},#{r.pickedPackageCount},#{r.shippedPackageCount},#{r.remark},#{r.createdBy},#{r.createdAt},#{r.updatedAt}) + """) + int insert(@Param("r") DispatchAggregate row); + + /** 平台按业务号读取真实整车,不借客户ID作为头部归属。 */ + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{number}") + DispatchAggregate find(@Param("number") String number); + + /** 已取得Fleet父锁及资源锁后再锁主单。 */ + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{number} FOR UPDATE") + DispatchAggregate lock(@Param("number") String number); + + /** SQL完成当前条件下的整车分页,每车只返回一行。 */ + @Select("") + List page(@Param("q") Search query); + + /** 分页总数复用完全相同筛选,不能用当前页长度替代。 */ + @Select("") + long count(@Param("q") Search query); + + /** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */ + @Select("") + List summaries(@Param("q") Search query); + + /** 同一主单版本原子刷新货物摘要、计数并使原审核失效。 */ + @Update(""" + UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items}, + pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT', + review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending}, + record_version=record_version+1,updated_at=#{at} + WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING' + AND picked_package_count=0 AND shipped_package_count=0 + """) + int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash, + @Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages, + @Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at); + + /** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */ + @Update(""" + UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion}, + review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash}, + dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson}, + cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, + estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, + loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, + arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, + delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount}, + shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt} + WHERE id=#{r.id} AND record_version=#{version} + """) + int saveState(@Param("r") DispatchAggregate row,@Param("version") long originalVersion); + + /** 动作及完整当时主单快照追加保存,不覆盖旧审核轮次或原请求历史。 */ + @Insert("INSERT INTO ll_dispatch_history(id,dispatch_id,record_version,action,operator_id,reason,snapshot_json,occurred_at) " + + "VALUES(#{id},#{dispatch},#{version},#{action},#{operator},#{reason},#{snapshot},#{at})") + int history(@Param("id") long id, @Param("dispatch") long dispatchId, @Param("version") long version, + @Param("action") String action, @Param("operator") String operator, @Param("reason") String reason, + @Param("snapshot") String snapshot, @Param("at") java.time.LocalDateTime at); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index 254dbdded..0b7affc87 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -1,15 +1,11 @@ package com.baiyue.lastleg.dispatch.mapper; - -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - +import com.baiyue.lastleg.dispatch.model.DispatchBolData; +import org.apache.ibatis.annotations.*; import java.util.List; - +/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ @Mapper public interface DispatchBolMapper { - @Select("SELECT " + + @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + "COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," + @@ -24,12 +20,11 @@ public interface DispatchBolMapper { "FROM ll_dispatch_item di " + "LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " + "LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " + - "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " + "LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " + "LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " + - "WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " + + "WHERE di.dispatch_order_id=#{dispatchOrderId} " + "GROUP BY di.id,l.id ORDER BY di.id,l.id") - List findByDispatchOrder(@Param("customerId") String customerId, - @Param("dispatchOrderId") Long dispatchOrderId); + List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); } 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 52151dd76..17265daa0 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 @@ -40,7 +40,7 @@ public interface DispatchCandidateMapper { "") List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); - @Select("") + "ORDER BY p.plan_id,p.sequence_no"; + @Select("") List available(@Param("customerId") String customerId, @Param("planIds") List planIds, @Param("orderNo") String orderNo, @@ -143,7 +143,13 @@ public interface DispatchCandidateMapper { @Param("sourceStage") String sourceStage, @Param("draftToken") String draftToken); - @Select("") + List wholeAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("planIds") List planIds,@Param("orderNo") String orderNo, + @Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken); + + String SELECTED_PLAN_SQL = "") + "ORDER BY p.id"; + + /** 先普通读确定真实来源,再按来源订单→库存顺序取得写锁。 */ + @Select(SELECTED_PLAN_SQL + "") + List readSelected(@Param("customerId") String customerId, + @Param("planPalletIds") List planPalletIds); + + /** 已有调用需要的明确锁定读取,投影与普通读保持同一事实。 */ + @Select(SELECTED_PLAN_SQL + " FOR UPDATE") List lockSelected(@Param("customerId") String customerId, @Param("planPalletIds") List planPalletIds); - @Select("") + "GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no"; + @Select("") List relabelAvailable(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); - @Select("") + List wholeRelabelAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("relabelNo") String relabelNo); + + String NATIVE_RELABEL_BODY = "SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," + "MIN(ri.source_warehouse_name) AS overseasWarehouseName,'RETURN_RELABEL' AS orderType,a.relabel_no AS orderNo,a.relabel_no AS orderRefs," + "GROUP_CONCAT(DISTINCT c.source_return_no ORDER BY c.source_return_no SEPARATOR ',') AS inboundCode," + "c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," + @@ -277,11 +295,17 @@ public interface DispatchCandidateMapper { "AND a.relabel_no=#{relabelNo} " + "AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') " + "AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " + - "AND d.customer_id=di.customer_id WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " + - "GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code") + "WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " + + "GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code"; + @Select("") List nativeReturnRelabelAvailable(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); + /** 原生退货换标保持原账本,仓过滤与行数限制在数据库执行。 */ + @Select("") + List wholeNativeRelabelAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("relabelNo") String relabelNo); + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'") int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java new file mode 100644 index 000000000..82dfe424f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 持久命令账本只新增和读取,不更新或覆盖原结果。 */ +@Mapper +public interface DispatchCommandMapper { + /** 全局读取请求键,操作者在服务层判断,不能按客户过滤后重复创建。 */ + @Select("SELECT id,dispatch_id AS dispatchId,dispatch_no AS dispatchNo,action,operator_id AS operatorId," + + "idempotency_key AS idempotencyKey,record_version AS recordVersion,command_hash AS commandHash," + + "result_json AS resultJson,created_at AS createdAt FROM ll_dispatch_command WHERE idempotency_key=#{key}") + DispatchCommandRecord find(@Param("key") String key); + + /** 唯一索引仲裁全局请求键并发,与主单变化在调用方同一事务。 */ + @Insert("INSERT INTO ll_dispatch_command(id,dispatch_id,dispatch_no,action,operator_id,idempotency_key," + + "record_version,command_hash,result_json,created_at) VALUES(#{row.id},#{row.dispatchId},#{row.dispatchNo}," + + "#{row.action},#{row.operatorId},#{row.idempotencyKey},#{row.recordVersion},#{row.commandHash}," + + "#{row.resultJson},#{row.createdAt})") + int insert(@Param("row") DispatchCommandRecord row); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java new file mode 100644 index 000000000..f3d633f7f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 复用正式来源/实托唯一占用,仅写本拥有者草稿记录,不修改正式排车占用。 */ +@Mapper +public interface DispatchDraftClaimMapper { + record Claim(Long id,String customerId,String sourceKey,Long palletId,Long containerId,LocalDateTime expiresAt) { } + + /** 来源已加锁后才读取本草稿活动占用;不跨token领取他人记录。 */ + @Select("SELECT id,customer_id AS customerId,source_pallet_key AS sourceKey,warehouse_pallet_id AS palletId,draft_container_id AS containerId,expires_at AS expiresAt " + + "FROM ll_dispatch_pallet_lock WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL " + + "AND status='ACTIVE' AND expires_at>#{at} ORDER BY id FOR UPDATE") + List owned(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at); + + /** 与正式claim共用两项唯一约束,跨客户同实托也不得重复占用。 */ + @Insert("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id,warehouse_pallet_id,active_warehouse_pallet_id," + + "return_receipt_id,dispatch_order_id,draft_container_id,draft_token,operator_id,status,locked_at,expires_at,created_at,updated_at) " + + "VALUES(#{id},#{customer},#{key},#{key},#{plan},#{pallet},#{pallet},#{receipt},NULL,#{container},#{token},#{owner},'ACTIVE',#{at},#{expires},#{at},#{at})") + int insert(@Param("id")long id,@Param("customer")String customer,@Param("key")String key,@Param("plan")Long plan, + @Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("container")long container,@Param("token")String token, + @Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires); + + /** 布局移动仅匹配同草稿原容器,不能迁移他人新取得的占用。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token} " + + "AND operator_id=#{owner} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}") + int move(@Param("id")long id,@Param("token")String token,@Param("owner")String owner,@Param("previous")long previous, + @Param("next")long next,@Param("at")LocalDateTime at); + + /** 按精确原身份释放,保留历史行并同时清空来源键和全局实托键。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} " + + "WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL AND status='ACTIVE' " + + "AND (#{key} IS NULL OR source_pallet_key=#{key}) AND (#{container} IS NULL OR draft_container_id=#{container})") + int release(@Param("token")String token,@Param("owner")String owner,@Param("key")String key,@Param("container")Long container,@Param("at")LocalDateTime at); + + /** 草稿写入续期只延长原草稿活动占用,不复活已被释放或转换的记录。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} AND operator_id=#{owner} " + + "AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}") + int renew(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java new file mode 100644 index 000000000..aaad872bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchDraftCommand; +import org.apache.ibatis.annotations.*; + +/** 唯一命令键串行化并保存全载荷结果;未完成记录绝不单独提交。 */ +@Mapper +public interface DispatchDraftCommandMapper { + /** 同键等待原事务,保留首次主体/动作/请求摘要。 */ + @Insert("INSERT INTO ll_dispatch_draft_command(id,idempotency_key,operator_id,action,target_token,request_hash,created_at) " + + "VALUES(#{id},#{idempotencyKey},#{operatorId},#{action},#{targetToken},#{requestHash},#{createdAt}) ON DUPLICATE KEY UPDATE id=id") + int reserve(DispatchDraftCommand command); + + /** 唯一键竞争后采用当前读恢复提交结果,避免旧快照漏见原命令。 */ + @Select("SELECT * FROM ll_dispatch_draft_command WHERE idempotency_key=#{key} FOR UPDATE") + DispatchDraftCommand lock(@Param("key")String key); + + /** 仅完成本事务首次预占的命令,失败必须使所有业务写入回滚。 */ + @Update("UPDATE ll_dispatch_draft_command SET result_json=#{json} WHERE id=#{id} AND result_json IS NULL") + int complete(@Param("id")long id,@Param("json")String json); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java index 43b428aaf..939bafbb9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java @@ -5,10 +5,27 @@ import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.Delete; +import java.time.LocalDateTime; import java.util.List; @Mapper public interface DispatchDraftContainerMapper extends BaseMapper { + /** 容器只在其草稿根锁内修改,普通读保持服务端顺序。 */ @Select("SELECT * FROM ll_dispatch_draft_container WHERE draft_token=#{draftToken} ORDER BY sequence_no,id") List findByToken(@Param("draftToken") String draftToken); + + /** 保存已由服务校验的布局,不从任意客户端JSON新增来源。 */ + @Update("UPDATE ll_dispatch_draft_container SET sequence_no=#{sequence},capacity=#{capacity},source_keys_json=#{keys},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token}") + int layout(@Param("id")long id,@Param("token")String token,@Param("sequence")int sequence,@Param("capacity")int capacity, + @Param("keys")String keys,@Param("at")LocalDateTime at); + + /** 在同一草稿根锁中暂存原序号,交换两容器时不短暂冲撞唯一序号。 */ + @Update("UPDATE ll_dispatch_draft_container SET sequence_no=-sequence_no WHERE draft_token=#{token} AND sequence_no>0") + int stageSequences(@Param("token")String token); + + /** 服务已校验为空的容器才可删除,不释放其他容器的来源。 */ + @Delete("DELETE FROM ll_dispatch_draft_container WHERE id=#{id} AND draft_token=#{token}") + int remove(@Param("id")long id,@Param("token")String token); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java index 210f8eca6..65099672e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java @@ -5,20 +5,34 @@ import com.baiyue.lastleg.dispatch.entity.DispatchDraft; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; import java.util.List; @Mapper public interface DispatchDraftMapper extends BaseMapper { - @Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " + - "AND (d.expires_at IS NULL OR d.expires_at>CURRENT_TIMESTAMP(3)) AND NOT EXISTS (" + - "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_outbound_order o ON o.customer_id=l.customer_id " + - "AND o.dispatch_order_id=l.dispatch_order_id WHERE l.draft_token=d.draft_token) AND NOT EXISTS (" + - "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order x ON x.id=l.dispatch_order_id " + - "AND x.customer_id=l.customer_id WHERE l.draft_token=d.draft_token AND x.status!='PENDING_DEPARTURE') " + - "ORDER BY d.updated_at DESC") - List findActiveByOperator(@Param("operatorId") String operatorId); + /** 草稿头只按拥有者隔离,不再依赖客户子排车或旧主单状态。 */ + default List findActiveByOperator(String operatorId) { + return findActiveAt(operatorId,LocalDateTime.now(java.time.ZoneOffset.UTC)); + } - @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE") + /** 显式传入UTC时间,数据库会话时区不能使有效草稿被提前视为过期。 */ + @Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " + + "AND d.expires_at>#{at} ORDER BY d.updated_at DESC,d.id") + List findActiveAt(@Param("operatorId")String operatorId,@Param("at")LocalDateTime at); + + /** 保留现有提供者的精确拥有者锁查询,过期资格由新服务统一核对。 */ + @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' FOR UPDATE") DispatchDraft lockActive(@Param("draftToken") String draftToken, @Param("operatorId") String operatorId); + + /** 锁定任何生命周期的原草稿,重放在服务层先于有效期校验。 */ + @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{token} AND operator_id=#{owner} FOR UPDATE") + DispatchDraft lockOwned(@Param("token") String token,@Param("owner") String owner); + + /** 唯一公共版本CAS;所有布局、状态和命令结果随同事务提交。 */ + @Update("UPDATE ll_dispatch_draft SET record_version=#{r.recordVersion},sources_json=#{r.sourcesJson}," + + "plan_ids_json=#{r.planIdsJson},status=#{r.status},expires_at=#{r.expiresAt},released_at=#{r.releasedAt},updated_at=#{r.updatedAt} " + + "WHERE id=#{r.id} AND operator_id=#{r.operatorId} AND record_version=#{version}") + int save(@Param("r") DispatchDraft row,@Param("version") long version); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java new file mode 100644 index 000000000..2e29ef14a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 本车真实扫码与不可变归属的有界批量读取,不用计划箱数补造执行。 */ +@Mapper +public interface DispatchExecutionReadMapper { + record RescanCandidate(String dispatchNo,String deliveryNo,long deliveryVersion) { } + record PickSource(Long pickId,Long attributionId,Long itemId,String customerId,Long orderId,String orderNo, + String sourceRegion,String sourceInboundId,String inboundCode,Long positionId,Long receiptId,Long returnReceiptId, + Long returnCartonId,String cartonMark,long packages,long productQuantity) { } + record ItemPicked(Long itemId,long packages) { } + record ActualMeasurement(Long palletId,String sourceNo,LocalDateTime observedAt,BigDecimal weightKg,BigDecimal volumeCbm) { } + /** 只取实际存在的订单与归属;缺失源头会使完整性核对失败。 */ + @Select(""" + SELECT p.id AS pickId,a.id AS attributionId,i.id AS itemId,a.customer_id AS customerId,a.order_id AS orderId, + o.order_no AS orderNo,o.source_region AS sourceRegion,o.source_inbound_id AS sourceInboundId, + COALESCE(r.inbound_code,rb.inbound_code,i.inbound_code) AS inboundCode,p.inventory_position_id AS positionId, + a.receipt_id AS receiptId,a.return_receipt_id AS returnReceiptId,a.return_carton_id AS returnCartonId, + c.carton_mark AS cartonMark,a.packages,COALESCE(a.product_quantity,0) AS productQuantity + FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id + JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=p.customer_id + JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id + LEFT JOIN ll_inbound_stock_receipt r ON r.id=a.receipt_id AND r.customer_id=a.customer_id + LEFT JOIN ll_return_stock_receipt rr ON rr.id=a.return_receipt_id AND rr.customer_id=a.customer_id + LEFT JOIN ll_inventory_balance rb ON rb.id=rr.inventory_balance_id AND rb.customer_id=rr.customer_id + LEFT JOIN ll_return_inventory_carton c ON c.id=a.return_carton_id AND c.customer_id=a.customer_id + WHERE i.dispatch_order_id=#{id} ORDER BY i.id,p.id,a.id + """) + List sources(@Param("id")long dispatchId); + /** 每个明细都必须独立核平,不能以整车合计抵消超拣与漏拣。 */ + @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{id} GROUP BY i.id ORDER BY i.id") + List itemPicked(@Param("id")long dispatchId); + /** 每条扫码的不可变归属箱数与商品数必须完整,缺归属不能因总数相等而放行。 */ + @Select(""" + SELECT COUNT(*) FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id + WHERE i.dispatch_order_id=#{id} AND (p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages + OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0)) + """) + long inconsistentPicks(@Param("id")long dispatchId); + /** 已完成实测只有当前整托组成未变时才可自动带入,计费重与订单分摊重不在此表。 */ + @Select(""" + SELECT DISTINCT p.warehouse_pallet_id AS palletId,t.transfer_no AS sourceNo,p.completed_at AS observedAt, + p.gross_weight_kg AS weightKg,p.volume_cbm AS volumeCbm + FROM ll_dispatch_item i JOIN ll_warehouse_transfer_pallet p ON p.warehouse_pallet_id=i.warehouse_pallet_id AND p.customer_id=i.customer_id + JOIN ll_warehouse_transfer_order t ON t.id=p.transfer_order_id AND t.customer_id=p.customer_id + WHERE i.dispatch_order_id=#{id} AND p.status='COMPLETED' AND p.carton_count=i.package_count AND p.completed_at IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger l WHERE l.customer_id=i.customer_id AND l.pallet_id=p.warehouse_pallet_id + AND l.created_at>=p.completed_at AND l.business_type IN('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND')) + ORDER BY p.warehouse_pallet_id,p.completed_at + """) + List actualMeasurements(@Param("id")long dispatchId); + + /** 有界修复性重扫只找当前真实签收且缺本版本信号的在途车,不锁任何子单。 */ + @Select(""" + SELECT d.dispatch_no AS dispatchNo,l.delivery_no AS deliveryNo,l.version AS deliveryVersion + FROM ll_dispatch_order d JOIN ll_delivery_order l ON l.dispatch_order_id=d.id + WHERE d.status='ACTIVE' AND d.stage='DELIVERING' AND l.signed_packages>0 AND l.signed_at IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM ll_delivery_facts_changed s WHERE s.dispatch_id=d.id AND s.delivery_id=l.id AND s.delivery_version=l.version) + ORDER BY d.updated_at,d.id,l.id LIMIT #{limit} + """) + List rescan(@Param("limit")int limit); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java index 8f94757bf..77a0990d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -11,6 +11,9 @@ import java.util.List; @Mapper public interface DispatchItemMapper extends BaseMapper { + /** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */ + @Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id") + List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id") List findByDispatchOrder(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java new file mode 100644 index 000000000..200b18f78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.OutboundReference; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */ +@Mapper +public interface DispatchReadMapper { + /** 即使计数漂移也不得把已有真实扫码当成零扫码。 */ + @Select("SELECT COALESCE(SUM(p.packages),0) FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{id}") + long realPicked(@Param("id") long dispatchId); + record DeliveryRow(String groupKey, String customerId, String customerName, String outboundNo, + String deliveryNo, String type, String warehouseId, String warehouseCode, String warehouseName, + String address, String postalCode, String contactName, String contactPhone, String status, + long packageCount, long palletCount) { } + record BatchRow(Long dispatchItemId,Long planPalletId,String batchKey, String batchNo, String customerId, String sourceKey, String destinationKey, + int packageCount, String confirmationNo, String status, String warehouseCode, LocalDateTime startUtc, Long recordVersion) { } + + /** 读取所有客户出库,不因客户筛选把整车单据缩减成一张。 */ + @Select(""" + SELECT o.customer_id AS customerId,c.customerName,o.outbound_no AS outboundNo,o.status, + o.package_count AS packageCount,o.pallet_count AS palletCount + FROM ll_outbound_order o LEFT JOIN(SELECT dispatch_order_id,customer_id,MAX(customer_name) AS customerName + FROM ll_dispatch_item GROUP BY dispatch_order_id,customer_id)c + ON c.dispatch_order_id=o.dispatch_order_id AND c.customer_id=o.customer_id + WHERE o.dispatch_order_id=#{id} ORDER BY o.customer_id,o.id + """) + List outbounds(@Param("id") long dispatchId); + + /** 所有目的地单据一次读取,地址字段只来自真实派送记录。 */ + @Select(""" + SELECT delivery_group_key AS groupKey,customer_id AS customerId,customer_name AS customerName, + outbound_no AS outboundNo,delivery_no AS deliveryNo,destination_type AS type, + destination_warehouse_id AS warehouseId,destination_warehouse_code AS warehouseCode, + destination_warehouse_name AS warehouseName,delivery_address AS address,postal_code AS postalCode, + contact_name AS contactName,contact_phone AS contactPhone,status,package_count AS packageCount,pallet_count AS palletCount + FROM ll_delivery_order WHERE dispatch_order_id=#{id} ORDER BY customer_id,id + """) + List deliveries(@Param("id") long dispatchId); + + /** 关联按真实明细客户读取,UTC时间不从当地时间猜测。 */ + @Select(""" + SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,CASE WHEN p.id IS NULL THEN d.dispatch_no ELSE p.appointment_no END AS batchNo, + a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey, + a.package_count AS packageCount,p.confirmation_no AS confirmationNo,p.appointment_status AS status, + p.fba_warehouse_code AS warehouseCode,p.appointment_start_utc AS startUtc, + CASE WHEN a.batch_key LIKE 'FBA_APPOINTMENT:%' THEN p.version + WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.record_version ELSE NULL END AS recordVersion + FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id + JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id + LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) + WHERE d.id=#{id} ORDER BY a.batch_key,a.customer_id,a.source_key,a.destination_key,a.dispatch_item_id + """) + List batches(@Param("id") long dispatchId); + + /** 未接通跨客户批次写提供者前必须阻断静默丢批次,不能写无依据运输。 */ + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id + JOIN ll_inbound_fba_appointment_item f ON f.order_id=l.order_id AND f.customer_id=l.customer_id AND f.active=1 + WHERE a.pallet_id=#{pallet} AND l.customer_id=#{customer} + """) + long activeAppointments(@Param("customer") String customer, @Param("pallet") Long planPalletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java new file mode 100644 index 000000000..03293fbfd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 排车选货的来源身份、真实库存版本及排他资源锁。 */ +@Mapper +public interface DispatchSourceMapper { + record Stock(long id, long balanceId, Long palletId, String warehouseId, long availablePackages, long holdPackages, + long allocatedPackages, long pickedPackages, long version) { } + record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { } + record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { } + record Claim(Long id, Long dispatchId, String draftToken) { } + record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { } + + /** 换标单客户由服务端业务单号查得,不接受客户端指定。 */ + @Select("SELECT customer_id FROM ll_relabel_application WHERE relabel_no=#{number}") + String relabelOwner(@Param("number") String number); + + /** 无独立版本字段的申请以真实更新时间和客户确认轮次参加来源摘要。 */ + @Select("SELECT status,fulfillment_status AS fulfillmentStatus,updated_at AS updatedAt,customer_confirmed_at AS customerConfirmedAt " + + "FROM ll_relabel_application WHERE relabel_no=#{number}") + RelabelBasis relabelBasis(@Param("number") String number); + + /** 同托真实收货和计划组成共同定位来源订单,禁止按展示MIN订单猜归属。 */ + @Select(""" + SELECT DISTINCT x.order_id FROM ( + SELECT r.order_id FROM ll_inbound_stock_receipt r WHERE r.customer_id=#{customer} AND r.warehouse_pallet_id=#{pallet} + UNION SELECT r.order_id FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} + UNION SELECT l.order_id FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=#{customer} + WHERE a.pallet_id=#{planPallet} + ) x ORDER BY x.order_id + """) + List sourceOrders(@Param("customer") String customer, @Param("pallet") Long pallet, + @Param("planPallet") Long planPallet); + + /** 锁来源订单先于库存,并在同一锁内拒绝暂扣。 */ + @Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} AND detained=0 FOR UPDATE") + Long lockOrder(@Param("customer") String customer, @Param("id") Long id); + + /** 计划托盘尚无实物时也有可串行占用的真实来源根。 */ + @Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE") + Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id); + + /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ + @Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE") + Long lockReturn(@Param("customer") String customer, @Param("id") Long id); + + /** sourceVersion包含真实库存数量和版本;写时按仓位ID稳定加锁。 */ + @Select("") + List stock(@Param("customer") String customer, @Param("pallet") Long pallet, @Param("lock") boolean lock); + + /** 版本还包含逐入仓真实组成,后到货不得静默加入原选货。 */ + @Select("SELECT id,order_id AS orderId,inventory_position_id AS inventoryPositionId,received_packages AS packages,inbound_code AS inboundCode " + + "FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id=#{pallet} ORDER BY id") + List receipts(@Param("customer") String customer, @Param("pallet") Long pallet); + + /** 同托真实退回实收也参加来源摘要,不能被原计划展示订单覆盖。 */ + @Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id") + List returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet); + + /** 仅失效的草稿占用可让出唯一键,绝不释放活动正式单。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} " + + "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} " + + "AND ((customer_id=#{customer} AND source_pallet_key=#{key}) OR active_warehouse_pallet_id=#{pallet})") + int expire(@Param("customer") String customer, @Param("key") String key, @Param("pallet") Long pallet, + @Param("at") LocalDateTime at); + + /** 不同来源别名指向同一实托时只能归同一整车,客户不作为实托锁分片。 */ + @Select("SELECT id,dispatch_order_id AS dispatchId,draft_token AS draftToken FROM ll_dispatch_pallet_lock " + + "WHERE status='ACTIVE' AND ((customer_id=#{customer} AND active_key=#{key}) OR active_warehouse_pallet_id=#{pallet}) " + + "AND (expires_at IS NULL OR expires_at>#{at}) ORDER BY id FOR UPDATE") + List claims(@Param("customer") String customer, @Param("key") String key, + @Param("pallet") Long pallet, @Param("at") LocalDateTime at); + + /** 只释放本车本来源原占用,清空实托唯一键,不触碰后续他车占用。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND status='ACTIVE' AND ((#{plan} IS NOT NULL AND plan_pallet_id=#{plan}) OR (#{receipt} IS NOT NULL AND return_receipt_id=#{receipt}) OR (#{pallet} IS NOT NULL AND warehouse_pallet_id=#{pallet}))") + int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("receipt")Long receipt,@Param("pallet")Long pallet,@Param("at")LocalDateTime at); + + /** 原计划自然收货后取得同一实托唯一键,其他车/来源的占用不能被覆盖。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},active_warehouse_pallet_id=#{pallet},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL") + int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("at")LocalDateTime at); + + /** 唯一活动实托键与来源键共同阻止并发跨车重复排货。 */ + @Insert(""" + INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id, + warehouse_pallet_id,active_warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at) + VALUES(#{id},#{customer},#{key},#{key},#{planPallet},#{pallet},#{pallet},#{receipt},#{dispatch},'ACTIVE',#{operator},#{at},#{at},#{at}) + """) + int claim(@Param("id") long id, @Param("customer") String customer, @Param("key") String key, + @Param("planPallet") Long planPallet, @Param("pallet") Long pallet, @Param("receipt") Long receipt, + @Param("dispatch") long dispatch, @Param("operator") String operator, @Param("at") LocalDateTime at); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java index 74bb166d7..060d86376 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java @@ -23,17 +23,28 @@ public interface ReturnDispatchCandidateMapper { "JOIN ll_delivery_order d ON d.customer_id=r.customer_id AND d.id=e.delivery_order_id "; String ELIGIBLE = " AND o.detained=0 AND p.available_packages>0 AND r.received_packages>(SELECT COALESCE(SUM(a.packages),0) " + "FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) "; - @Select("") + "ORDER BY r.received_at,r.id"; + @Select("") List available(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("fba") String fba, @Param("draftToken") String draftToken); + /** 退回候选按真实库存仓过滤,保持多客户读取。 */ + @Select("") + List wholeAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("fba") String fba,@Param("draftToken") String draftToken); + + /** 只读解析选定退回来源,避免在来源订单前抢占库存锁。 */ + @Select("") + List readSelected(@Param("customerId") String customerId, @Param("ids") List ids); + @Select("") List lockSelected(@Param("customerId") String customerId, @Param("ids") List ids); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java new file mode 100644 index 000000000..52d2a0136 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.model; +import java.time.LocalDateTime; +import java.util.*; +/** 打印服务内部一致快照,不恢复旧排车JSON协议。 */ +public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName, + String vehiclePlate,LocalDateTime estimatedArrivalAt,List bolLines,Map appointmentNumbers) { + public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo, + String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId, + String poReferenceNo,int packageCount) { + public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);} + public String groupKey(){return customerId+"\u0000"+destinationKey();} + } + public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index 397f162e3..defc08736 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -2,12 +2,9 @@ package com.baiyue.lastleg.dispatch.pda.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; -import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; -import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; -import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.*; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; @@ -20,52 +17,56 @@ import java.util.List; @RequestMapping("/api/pda/v1/last-leg/dispatches") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class DispatchPdaController { - @Resource private DispatchPdaWorkflowService service; - @Resource private FleetTripExecutionService trips; + @Resource private DispatchPdaQueryService queries; + @Resource private DispatchPickingService picking; + @Resource private DispatchAggregateService workflow; + @Resource private DispatchDepartureService departure; + /** 批量读取当前绑定仓的整车作业。 */ @GetMapping - public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String status) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - String warehouse = String.valueOf(identity.overseasWarehouseId()); - var tasks = service.list(warehouse, status); - var gates = trips.gates(warehouse, tasks.stream().map(DispatchPdaResponses.Task::dispatchNo).toList()); - return ApiResponse.success(tasks.stream().map(task -> task.withGate(gates.get(task.dispatchNo()))).toList()); + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required=false)DispatchTypes.Stage stage) { + return ApiResponse.success(queries.list(DispatchActorFactory.pda(user),stage)); } + /** 使用排车业务号读取当前仓真实货物和扫码进度。 */ @GetMapping("/{dispatchNo}") - public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - String warehouse = String.valueOf(identity.overseasWarehouseId()); - return ApiResponse.success(service.task(warehouse, dispatchNo).withGate(trips.gate(warehouse, dispatchNo))); + return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo)); } + /** 共用公共版本和全局请求键执行一次真实库存扫码。 */ @PostMapping("/{dispatchNo}/items/{itemId}/pick") - public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, @PathVariable long itemId, - @Valid @RequestBody DispatchPdaRequests.Pick request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.pick(String.valueOf(identity.overseasWarehouseId()), dispatchNo, itemId, - identity.operatorKey(), identity.operatorName(), request)); + @Valid @RequestBody DispatchCommands.Pick request) { + return ApiResponse.success(picking.pick(DispatchActorFactory.pda(user),dispatchNo,itemId,request)); } - @PostMapping("/{dispatchNo}/complete") - public ApiResponse completePicking( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @Valid @RequestBody DispatchPdaRequests.CompletePicking request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.completePicking(String.valueOf(identity.overseasWarehouseId()), - dispatchNo, request)); + /** 与平台共用同一实载证据读取。 */ + @GetMapping("/{dispatchNo}/loading") + public ApiResponse loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo)); } + /** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */ + @PostMapping("/{dispatchNo}/loading-complete") + public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { + return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request)); + } + + /** 与平台共用实际发车服务,不执行第二次库存扣减。 */ @PostMapping("/{dispatchNo}/depart") - public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody FleetExecutionModels.Depart request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(trips.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo, - identity.operatorKey(), request)); + @Valid @RequestBody DispatchCommands.Depart request) { + return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request)); + } + + /** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(workflow.command(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey)); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java index 81ccee623..12b51e7b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java @@ -11,7 +11,6 @@ import java.time.LocalDateTime; @TableName("ll_dispatch_pda_task") public class DispatchPdaTask { @TableId(type = IdType.INPUT) private Long id; - private String customerId; private Long dispatchOrderId; private String taskNo; private String status; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java index 2b8c0f33c..27aa78364 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java @@ -7,6 +7,9 @@ import java.util.List; @Mapper public interface DispatchPdaPickMapper extends BaseMapper { + /** 按真实明细客户读取全车归属,不要求整车主单存在客户列。 */ + @Select("SELECT p.* FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{dispatch} AND p.customer_id=#{customer} ORDER BY p.id") + List wholeDispatch(@Param("customer")String customer,@Param("dispatch")long dispatch); @Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id") List listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId); @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java index 26b78cd40..3099c4da0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -18,12 +18,14 @@ public interface DispatchPdaPickOrderMapper extends BaseMapper deliveryPickedSkus(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java index c92c6232f..fad6512d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java @@ -8,11 +8,11 @@ import java.util.List; @Mapper public interface DispatchPdaTaskMapper extends BaseMapper { - @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") - DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + @Select("SELECT * FROM ll_dispatch_pda_task WHERE dispatch_order_id=#{dispatchOrderId} FOR UPDATE") + DispatchPdaTask lockByDispatch(@Param("dispatchOrderId") Long dispatchOrderId); - @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") - DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + @Select("SELECT * FROM ll_dispatch_pda_task WHERE dispatch_order_id=#{dispatchOrderId}") + DispatchPdaTask findByDispatch(@Param("dispatchOrderId") Long dispatchOrderId); @Select("SELECT * FROM ll_dispatch_pda_task WHERE warehouse_id=#{warehouseId} " + "AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java new file mode 100644 index 000000000..f9047428f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 绑定仓整车PDA批量读取,不读取单客户主单头或逐车查询详情。 */ +@Mapper +public interface DispatchWholePdaReadMapper { + record Picked(Long itemId,long packages) { } + record Invalid(Long dispatchId,long quantity) { } + record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { } + /** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */ + @Select("") + List headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage); + String ROOTS="#{id}"; + /** 一次读取选中整车所有真实客户货物。 */ + @Select("") + List items(@Param("ids")List ids); + /** 实际扫码按真实明细客户合计,不依赖客户任务头的假归属。 */ + @Select("") + List picked(@Param("ids")List ids); + /** 每条扫码的来源订单、箱数与商品数均须真实完整。 */ + @Select("") + List invalid(@Param("ids")List ids); + /** 仓位先按本批明细客户、实托及绑定仓一次读取。 */ + @Select("") + List positions(@Param("ids")List ids,@Param("warehouse")String warehouse); + /** 原生退货箱仍从原账本取真实箱号,精确选中集合由业务层再比对。 */ + @Select("") + List cartons(@Param("ids")List ids,@Param("warehouse")String warehouse); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index adc8c9907..dbf1f26f3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -1,405 +1,42 @@ package com.baiyue.lastleg.dispatch.pda.service; -import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.delivery.entity.OutboundOrder; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; -import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; -import com.baiyue.lastleg.dispatch.pda.entity.*; -import com.baiyue.lastleg.dispatch.pda.mapper.*; -import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; -import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; -import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; -import com.baiyue.lastleg.inventory.entity.InventoryPosition; -import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; -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.time.LocalDateTime; +import java.time.*; import java.util.*; import java.util.stream.Collectors; +/** 实际发车推进既有来源状态;扫码与装车只使用公共整车命令。 */ @Service public class DispatchPdaWorkflowService { - @Resource private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution; + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository wholePicks; + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceDepartureRepository wholeDeparture; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; - @Resource private FleetDispatchBindingGuard fleetBindings; - @Resource private DispatchOrderMapper orders; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private OutboundPalletItemMapper outboundPalletItems; - @Resource private DispatchItemMapper items; - @Resource private DispatchPdaTaskMapper tasks; - @Resource private DispatchPdaPickMapper picks; - @Resource private DispatchPdaOperationMapper operations; - @Resource private DispatchPalletLockMapper palletLocks; - @Resource private InventoryPositionMapper positions; - @Resource private InventoryCommandService inventory; - @Resource private RelabelApplicationService relabelApplications; - @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private DispatchPickOrderAttributionService pickAttribution; - @Resource private ObjectMapper objectMapper; - @Resource private com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnRelabelStock; - - @Transactional(readOnly = true) - public List list(String warehouseId, String status) { - String boundWarehouseId = warehouse(warehouseId); - List result = new ArrayList<>(); - for (DispatchPdaTask pdaTask : tasks.listByWarehouse(boundWarehouseId, trim(status))) { - DispatchOrder order = orders.selectById(pdaTask.getDispatchOrderId()); - if (order == null || !pdaTask.getCustomerId().equals(order.getCustomerId()) - || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { - throw conflict("Dispatch PDA task scope is inconsistent"); + @Resource private RelabelApplicationService relabelApplications; + /** 全部客户子单锁及来源订单锁已由调用方收齐,此处只推进原来源计数与原业务状态。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void departWholeSources(com.baiyue.lastleg.dispatch.port.DispatchContracts.Context context, + com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor actor, + com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.DepartureResult result,Instant departedAt) { + var at=LocalDateTime.ofInstant(departedAt,java.time.ZoneOffset.UTC); + var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::customerId,TreeMap::new,Collectors.toList())); + for(var group:byCustomer.entrySet()) { + String customer=group.getKey();var outbound=result.outbounds().stream().filter(o->customer.equals(o.customerId())).findFirst().orElseThrow(()->conflict("本客户实际出库单缺失")); + var actual=wholePicks.picks(customer,context.dispatchId()); + if(actual.isEmpty())throw conflict("本客户缺少真实扫码来源"); + pickAttribution.depart(customer,outbound.outboundNo(),context.dispatchNo(),actual,at); + for(var order:group.getValue().stream().filter(c->c.relabelNo()==null).map(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::orderId).distinct().sorted().toList())wholeDeparture.transferOrder(customer,order,at); + for(var no:group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::relabelNo).filter(Objects::nonNull).distinct().sorted().toList()) { + wholeDeparture.relabel(customer,no,at);relabelApplications.changeStatus(customer,no,actor.operatorId(),new RelabelRequests.ChangeStatus("COMPLETED","整车实际发车完成")); } - result.add(task(order.getCustomerId(), order, pdaTask, - items.findByDispatchOrder(order.getCustomerId(), order.getId()))); - } - return result; - } - - @Transactional - public DispatchPdaResponses.Task task(String warehouseId, String dispatchNo) { - DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = order.getCustomerId(); - items.bindReceivedPallets(customerId, order.getId()); - DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId()); - if (task == null) throw notFound("Dispatch PDA task not found"); - requireTaskWarehouse(task, warehouseId); - return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); - } - - @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) - public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, - String operatorId, String operatorName, - DispatchPdaRequests.Pick request) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - // Transfer and picking serialize on the same dispatch before taking task/inventory locks. - DispatchOrder order = orders.lockById(customerId, found.getId()); - if (order == null) throw notFound("Dispatch order not found"); - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - if (task == null) throw conflict("Dispatch PDA task is not available for picking"); - if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); - DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); - if (replay != null) { - DispatchPdaResponses.Picked result = read(replay, DispatchPdaResponses.Picked.class); - if (!task.getId().equals(replay.getTaskId()) || result.dispatchItemId() != itemId) { - throw conflict("Idempotency key was reused with a different dispatch pick request"); - } - return result; - } - requirePdaStatus(order); - requireVehicle(order); - fleetBindings.requirePickingApproved(order.getId()); - items.bindReceivedPallets(customerId, order.getId()); - if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { - throw conflict("Dispatch PDA task is not available for picking"); - } - bindOperator(task, operatorId, operatorName); - DispatchItem item = items.selectById(itemId); - if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) { - throw notFound("Dispatch pallet item not found"); - } - if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse"); - boolean nativeReturn = "RETURN_RELABEL".equals(item.getSourceStage()); - if (!nativeReturn && request.positionId() == null) throw conflict("普通入仓拣货必须选择库存仓位"); - if (nativeReturn && request.positionId() != null) throw conflict("退货箱拣货请扫描来源箱唛,不使用普通库存仓位"); - InventoryPosition position = nativeReturn ? null : positions.selectById(request.positionId()); - if (!nativeReturn && (position == null || !customerId.equals(position.getCustomerId()) - || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId()))) { - throw conflict("Inventory position does not belong to this dispatch pallet"); - } - long picked = picks.totalItem(customerId, task.getId(), itemId); - if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); - long pickId = IdWorker.getId(); - Long returnCartonId = null; - if (nativeReturn) { - pickAttribution.prepareNativeReturn(customerId, item.getOrderId()); - returnCartonId = returnRelabelStock.pick(item, request.cartonMark(), request.packages(), request.productQuantity(), - pickId, order.getDispatchNo()).getId(); - } else { - pickAttribution.preparePicking(customerId, request.positionId(), - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); - String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); - InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":ALLOCATE", request.remark()); - if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) { - inventory.release(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), item.getSourceBusinessNo(), order.getDispatchNo(), null, - keyBase + ":RELABEL_RELEASE", "换标排车拣货前释放冻结库存")); - } - inventory.allocate(customerId, operatorId, move); - inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":PICK", request.remark())); - // A successful PDA scan is the inventory deduction point. Departure only - // changes outbound/dispatch facts and must not deduct the same quantity again. - inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":SHIP", request.remark())); - } - DispatchPdaPick pick = new DispatchPdaPick(); - pick.setId(pickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setReturnCartonId(returnCartonId); - pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); - pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); - pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); - if (nativeReturn) pickAttribution.attributeNativeReturn(customerId, pick, item.getOrderId()); - else pickAttribution.attribute(customerId, pick, - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId()); - long total = picked + request.packages(); - order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); - task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); - if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) { - task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART"); - } - order.setUpdatedAt(LocalDateTime.now()); - orders.updateById(order); - tasks.updateById(task); - DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false); - save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result); - return result; - } - - @Transactional - public DispatchPdaResponses.PickingCompleted completePicking(String warehouseId, String dispatchNo, - DispatchPdaRequests.CompletePicking request) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - DispatchOrder order = orders.lockById(customerId, found.getId()); - if (order == null) throw notFound("Dispatch order not found"); - DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request); - if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay(); - requireVehicle(order); - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - fleetBindings.requirePickingApproved(order.getId()); - if (task != null && "COMPLETED".equals(task.getStatus())) { - List completedItems = items.findByDispatchOrder(customerId, order.getId()); - long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) - .mapToLong(Integer::longValue).sum(); - long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); - DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted( - order.getDispatchNo(), order.getStatus(), planned, picked, Math.max(0, planned - picked), false, false); - DispatchPdaOperation completed = operations.findCompleted(customerId, task.getId(), "COMPLETE_PICKING"); - if (completed != null) throw conflict("PDA picking was already completed with another idempotency key"); - save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); - return result; - } - if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { - throw conflict("Dispatch PDA task is not available for completion"); - } - List source = items.findByDispatchOrder(customerId, order.getId()); - long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) - .mapToLong(Integer::longValue).sum(); - long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); - if (planned <= 0 || picked != planned || !allPicked(customerId, task.getId(), source)) { - throw conflict("Every dispatch pallet and package must be fully picked before completion"); - } - LocalDateTime now = LocalDateTime.now(); - task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task); - order.setStatus("READY_TO_DEPART"); - order.setPartialCompletionReason(null); - order.setPickedPackageCount((int) picked); - order.setUpdatedAt(now); orders.updateById(order); - DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), - order.getStatus(), planned, picked, 0, false, false); - save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); - return result; - } - - /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ - @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) - public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId, - DispatchPdaRequests.Depart request, LocalDateTime departedAt) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - DispatchOrder order = orders.lockById(customerId, found.getId()); - if (order == null) throw notFound("Dispatch order not found"); - DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request); - if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay(); - fleetBindings.requireCoordinatedDeparture(order.getId()); - requireVehicle(order); - OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId()); - if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { - throw conflict("Dispatch must be transferred to a pending outbound order before departure"); - } - deliveryExecution.requireDeparture(outbound); - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - if (task == null || !"COMPLETED".equals(task.getStatus()) - || !"READY_TO_DEPART".equals(order.getStatus())) { - throw conflict("Dispatch must complete PDA picking before departure"); - } - requireTaskWarehouse(task, warehouseId); - - List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); - if (!allPicked(customerId, task.getId(), dispatchItems)) { - throw conflict("Every dispatch pallet must be fully picked before departure"); - } - List dispatchPicks = picks.listTask(customerId, task.getId()); - if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); - long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); - LocalDateTime now = Objects.requireNonNull(departedAt, "departedAt"); - pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); - if (outboundPalletItems != null) { - for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) { - boolean selected = dispatchItems.stream().anyMatch(item -> Objects.equals(item.getWarehousePalletId(), outboundItem.getWarehousePalletId()) - && Objects.equals(item.getInboundCode(), outboundItem.getInboundCode())); - if (selected) { - outboundItem.setPickedPackageCount(outboundItem.getPackageCount()); - outboundItem.setStatus("PICKED"); - outboundPalletItems.updateById(outboundItem); - } - } - } - for (DispatchItem item : dispatchItems) { - // A relabel dispatch may consume only part of a warehouse-transfer - // pallet. Do not close the whole transfer by its parent order; the - // remaining cartons still need destination binding/outbound. - if (item.getOrderId() != null && !"RELABEL_ORDER".equals(item.getSourceBusinessType())) { - warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); - } - if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) { - warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now); - } - } - dispatchItems.stream().filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) - .map(DispatchItem::getSourceBusinessNo).filter(Objects::nonNull).distinct().forEach(relabelNo -> - relabelApplications.changeStatus(customerId, relabelNo, operatorId, - new RelabelRequests.ChangeStatus("COMPLETED", "换标排车PDA发车完成"))); - outbound.setStatus("OUTBOUNDED"); - outbound.setUpdatedAt(now); - outboundOrders.updateById(outbound); - order.setShippedPackageCount((int) shipped); - order.setStatus("DEPARTED"); - order.setDepartAt(now); - order.setUpdatedAt(now); - orders.updateById(order); - releasePalletLocks(customerId, order.getId(), now); - Map sourcePackages = new TreeMap<>(); - for (var item : dispatchItems) if (item.getOrderId() != null) - sourcePackages.merge(item.getOrderId(), item.getPackageCount(), Math::addExact); - fleetPool.departed(customerId, order.getDispatchNo(), sourcePackages, operatorId, now); - DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(), - order.getStatus(), shipped, false); - save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result); - return result; - } - - private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List source) { - List values = new ArrayList<>(); - for (DispatchItem item : source) { - if ("RETURN_RELABEL".equals(item.getSourceStage())) { - values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), - item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), - warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), - picks.totalItem(customerId, task.getId(), item.getId()), List.of(), returnRelabelStock.detail(item))); - continue; - } - List palletPositions = item.getWarehousePalletId() == null ? List.of() - : positions.listPallet(customerId, item.getWarehousePalletId()); - boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType()); - values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), - item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), - warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), - picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> - new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()) - + (relabel ? n(p.getHoldPackages()) : 0), - n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); - } - OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId()); - String departBlockReason = departBlockReason(order, task, source, outbound); - return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), - task.getWarehouseId(), "DEPARTED".equals(order.getStatus()) ? "DEPARTED" : task.getStatus(), - departBlockReason == null, departBlockReason, values, null, null, 0, null, false, "请先关联车次并通过调度审核"); - } - - private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List source, - OutboundOrder outbound) { - if ("DEPARTED".equals(order.getStatus())) return "该排车已发车,不可重复提交。"; - if (trim(order.getVehiclePlate()) == null) return "请先在平台维护车辆信息。"; - if (!"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus()) - || !allPicked(order.getCustomerId(), task.getId(), source)) { - return "请先完成排车拣货并核平全部箱数。"; - } - if (outbound == null) return "请先在平台完成“转派送”,生成待出库单后才能发车。"; - if (!"WAIT_OUTBOUND".equals(outbound.getStatus())) return "关联出库单不是待出库状态,暂不可发车。"; - return null; - } - - private boolean allPicked(String customerId, Long taskId, List source) { - return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount()); - } - private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) { - List activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, customerId) - .eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); - for (DispatchPalletLock lock : activeLocks) { - lock.setStatus("RELEASED"); - lock.setActiveKey(null); - lock.setReleasedAt(now); - lock.setUpdatedAt(now); - palletLocks.updateById(lock); + Map packages=new TreeMap<>();for(var cargo:group.getValue())packages.merge(cargo.orderId(),Math.toIntExact(cargo.packages()),Math::addExact); + fleetPool.departed(customer,context.dispatchNo(),packages,actor.operatorId(),at); } } - private DispatchOrder requireWarehouseOrder(String warehouseId, String dispatchNo) { - String boundWarehouseId = warehouse(warehouseId); - DispatchOrder order = orders.findGlobalByDispatchNo(required(dispatchNo, "Dispatch number is required")); - if (order == null || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { - throw notFound("Dispatch order not found in the bound warehouse"); - } - return order; - } - private void requirePdaStatus(DispatchOrder order) { - if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus()) - || "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA"); - } - private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); } - private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); } - private static void requireTaskWarehouse(DispatchPdaTask task, String warehouseId) { - if (!warehouse(warehouseId).equals(trim(task.getWarehouseId()))) { - throw notFound("Dispatch PDA task not found in the bound warehouse"); - } - } - private static void bindOperator(DispatchPdaTask task, String operatorId, String operatorName) { - String normalizedOperator = required(operatorId, "PDA operator is required"); - if (trim(task.getOperatorId()) != null && !normalizedOperator.equals(trim(task.getOperatorId()))) { - throw conflict("Dispatch PDA task is already assigned to another operator"); - } - task.setOperatorId(normalizedOperator); - task.setOperatorName(trim(operatorName)); - } - private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; } - private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); } - private T read(DispatchPdaOperation op, Class type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } } - private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } } - private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } - private static long n(Long value) { return value == null ? 0 : value; } - private static long n(Integer value) { return value == null ? 0 : value; } - static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) { - String original = trim(originalFbaWarehouseCode); - String destination = trim(destinationWarehouseCode); - return original != null && destination != null && !original.equalsIgnoreCase(destination) - ? original + " -> " + destination : null; - } - private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } - private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } - private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + private static RuntimeException conflict(String message){return new com.baiyue.common.core.exception.BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,message);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java new file mode 100644 index 000000000..aae359617 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 各端唯一端点常量;骨架本身不表示Controller已经上线。 */ +public final class DispatchApi { + public static final String PLATFORM = "/api/platform/v1/last-leg/dispatches"; + public static final String PDA = "/api/pda/v1/last-leg/dispatches"; + public static final String CANDIDATES = "/candidates"; + public static final String ITEMS = "/{dispatchNo}/items"; + public static final String REMOVE_ITEM = "/{dispatchNo}/items/{itemId}/remove"; + public static final String PICK_ITEM = "/{dispatchNo}/items/{itemId}/pick"; + public static final String CANCEL = "/{dispatchNo}/cancel"; + public static final String LOADING = "/{dispatchNo}/loading"; + public static final String DRAFTS = "/drafts"; + public static final String DRAFT_SNAPSHOT = "/drafts/snapshot"; + public static final String DRAFT_CONTAINERS = "/drafts/containers"; + public static final String DELETE_DRAFT_CONTAINER = "/drafts/{draftToken}/containers/{containerId}"; + public static final String DRAFT_LOCK = "/draft-locks/lock"; + public static final String DRAFT_UNLOCK = "/draft-locks/unlock"; + public static final String DRAFT_RELEASE = "/draft-locks/release"; + public static final String CONVERT_DRAFT = "/drafts/pre-arrange"; + public static final String COMMAND = "/{dispatchNo}/commands/{idempotencyKey}"; + public static final String SUBMIT_REVIEW = "/{dispatchNo}/submit-review"; + public static final String DISPATCH_REVIEW = "/{dispatchNo}/dispatch-review"; + public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review"; + public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review"; + public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete"; + public static final String DEPART = "/{dispatchNo}/depart"; + public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement"; + public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery"; + + /** 端点容器不允许实例化。 */ + private DispatchApi() { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java new file mode 100644 index 000000000..0bff02a42 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 既有预约改量专用内部端口,不注册HTTP,不恢复旧客户排车或手工Fleet绑定。 */ +public interface DispatchAppointmentAmendmentPort { + /** 必须由已锁定原运输scope读取;主流程仍逐项核对DB真实归属及完整实物量。 */ + record PhysicalItem(Long itemId,String customerId,Long planPalletId,String sourceStage,Integer packages) { } + + /** MANDATORY:一次锁全范围trip→全部车辆→司机/资源→全部dispatch,禁止逐车交错。 */ + void lockExistingAppointmentDispatches(Actor actor,List dispatchNos); + + /** + * MANDATORY:调用者先核对完整scope并锁齐原权威批次、来源及业务行。 + * identity.recordVersion是本车当前公共版本;idempotencyKey是原父预约命令键。 + * 实现按父键、可信操作者及dispatchNo派生稳定子键,共用原整车命令账本,重试恢复原快照。 + * expected/next必须来自原权威事实;removePhysicalItems只能由服务端完整实物守卫决定。 + * 只允许ACTIVE、BEFORE_LOADING、零真实扫码、无下游执行;任何失败跟随父事务整体回滚。 + */ + CommandResult applyExistingAppointmentChange(Actor actor,String dispatchNo,DispatchCommands.ReasonCommand identity, + List touchedItems,List expectedAllocations, + List nextAllocations,boolean removePhysicalItems); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java new file mode 100644 index 000000000..9c71dfb6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 公开整车写请求;所有已有单写入只使用recordVersion。 */ +public final class DispatchCommands { + /** 请求容器不允许实例化。 */ + private DispatchCommands() { } + + public record Vehicle(ResourceMode mode, Long vehicleId, String plate, String carrierName, + Capacity capacity, List evidenceFileIds) { } + + public record Driver(ResourceMode mode, Long driverId, String name, String phone, + DriverEvidence evidence) { } + + public record CargoSelection(SourceType sourceType, Long planPalletId, Long returnReceiptId, + String relabelNo, Long warehousePalletId, String sourceVersion) { } + + /** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */ + public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver, + Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, + List cargo, List batchAllocations) { } + + /** 只关联权威既有预约,确认状态/时间必须解析提供者结果。 */ + public record AppointmentReference(String referenceNo, Long recordVersion) { + /** 拒绝缺引用或缺版本,不提供手工确认状态字段。 */ + public AppointmentReference { + if (referenceNo == null || referenceNo.isBlank()) throw new IllegalArgumentException("预约引用必填"); + recordVersion = requireVersion(recordVersion); + } + } + + public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, + Instant plannedDepartureAt, Instant estimatedArrivalAt, + AppointmentReference vehicleAppointment, String remark) { + /** 缺版本与有效版本零必须区分。 */ + public Update { recordVersion = requireVersion(recordVersion); } + } + + public record AddCargo(Long recordVersion, String idempotencyKey, List cargo, + List batchAllocations) { + /** 缺版本不得退化成零版本。 */ + public AddCargo { recordVersion = requireVersion(recordVersion); } + } + + public record ReasonCommand(Long recordVersion, String idempotencyKey, String reason) { + /** 移货和取消同样必须提交当前版本。 */ + public ReasonCommand { recordVersion = requireVersion(recordVersion); } + } + + public record SubmitReview(Long recordVersion, String idempotencyKey, String remark) { + /** 审核提交不接受隐式零版本。 */ + public SubmitReview { recordVersion = requireVersion(recordVersion); } + } + + public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision, + String reason) { + /** 两级审核都执行相同的必填版本约束。 */ + public ReviewCommand { recordVersion = requireVersion(recordVersion); } + } + + /** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */ + public record ReturnToReview(Long recordVersion, String idempotencyKey, String reason) { + /** 零扫码退回也不能覆盖并发新版本。 */ + public ReturnToReview { recordVersion = requireVersion(recordVersion); } + } + + public record Pick(Long recordVersion, String idempotencyKey, Long positionId, long packages, + long productQuantity, String cartonMark, String remark) { + /** 扫码缺版本必须失败,避免将旧请求解释为首版。 */ + public Pick { recordVersion = requireVersion(recordVersion); } + } + + /** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */ + public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash, + List measurements, String remark) { + /** 后台与PDA共用必填版本约束。 */ + public LoadingComplete { recordVersion = requireVersion(recordVersion); } + } + + public record Depart(Long recordVersion, String idempotencyKey, String remark) { + /** 实际发车不得接受缺省版本。 */ + public Depart { recordVersion = requireVersion(recordVersion); } + } + + /** 装箱类型保留JSON缺字段事实,同时允许显式提交零。 */ + static Long requireVersion(Long recordVersion) { + if (recordVersion == null || recordVersion < 0) throw new IllegalArgumentException("recordVersion必填且不得小于零"); + return recordVersion; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java new file mode 100644 index 000000000..eba03e4bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dispatch.port; + +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 主流程独占实现;阶段CAS与本信号确认在同一新事务提交。 */ +public interface DispatchCompletionPort { + /** 在签收提交后取得trip/dispatch锁,真实全签才推进,失败不确认信号。 */ + CompletionResult reconcile(Signal signal); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java new file mode 100644 index 000000000..b96d9e564 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 主流程与派送域之间的事实快照;不允许传递持久化实体。 */ +public final class DispatchContracts { + /** 契约容器不允许实例化。 */ + private DispatchContracts() { } + + /** 由可信认证上下文构造,不允许从请求体绑定操作身份或PDA仓库。 */ + public record Actor(String operatorId, ActorMode mode, String boundWarehouseId) { } + + /** 未允许时必须携带稳定错误码和可展示原因。 */ + public record Gate(boolean allowed, String blockCode, String blockReason) { } + + public record Counts(long itemCount, long palletCount, long packageCount, + long pickedPackageCount, long shippedPackageCount) { } + + public record Capacity(Integer palletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } + + /** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */ + public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, + List fileIds, Instant expectedTaskEndAt) { } + + public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate, + String carrierName, String vehicleStatus, Capacity capacity, + List evidenceFileIds) { } + + public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone, + DriverEvidence evidence) { } + + public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver) { } + + public record Destination(String key, String type, String warehouseId, String warehouseCode, + String warehouseName, String address, String postalCode, + String contactName, String contactPhone) { } + + public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt, + long reviewRound, String basisHash, String invalidatedReason) { } + + public record Reviews(Review dispatchReview, Review loadingReview) { } + + public record BatchAllocation(Long planPalletId, String batchKey, String customerId, + String sourceKey, String destinationKey, int packageCount, Long recordVersion) { + /** 本字段属于batchKey的权威记录,不是整车版本;必须显式提交,零合法。 */ + public BatchAllocation { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } + + public record BatchSource(String sourceKey, String destinationKey, int packageCount) { } + + public record BatchReference(String batchKey, String batchNo, String customerId, + List sources, Long recordVersion) { + /** FBA使用原预约版本,自动DISPATCH引用使用该真实主单版本。 */ + public BatchReference { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } + + /** 一行表示本次不可变真实拣货来源,展示入仓码不能替代此归属。 */ + public record CargoLine(Long dispatchItemId, String customerId, String customerName, + Long orderId, String orderNo, String sourceKey, String sourceStage, + String sourceRegion, String sourceInboundId, String inboundCode, + Long planPalletId, Long warehousePalletId, String palletNo, + Long pickId, Long attributionId, Long inventoryPositionId, + Long receiptId, Long returnReceiptId, Long returnCartonId, + String cartonMark, String relabelNo, Destination destination, + long packages, long productQuantity, BigDecimal grossWeightKg, + BigDecimal volumeCbm, List batchAllocations) { } + + /** 仅用于服务内协作;锁定上下文并不等于通过当前版本或阶段资格。 */ + public record Context(Long dispatchId, String dispatchNo, Long tripId, String tripNo, + String warehouseId, String warehouseCode, String warehouseName, + Status status, Stage stage, long recordVersion, long cargoRevision, + String cargoHash, String loadingCompletionHash, Instant loadingCompletedAt, + Instant departedAt, Resources resources, Counts counts, Reviews reviews, + boolean pickingReconciled, List cargo) { } + + /** 内部命令身份由服务端摘要器产生,公开请求不接受commandHash。 */ + public record CommandIdentity(Action action, String idempotencyKey, long recordVersion, + String commandHash) { } + + public record CommandResult(String dispatchNo, Action action, String idempotencyKey, + long recordVersion, Status status, Stage stage, Instant occurredAt, + Effect effect, Counts counts, DispatchResults.Payload result, + boolean replayed, boolean alreadyArranged) { } + + public record LoadingMeasurement(Long warehousePalletId, int palletSpaces, BigDecimal weightKg, + BigDecimal volumeCbm, int deliveryIndex) { } + + public record ConfirmedLoad(String manifestHash, List pallets, + String operatorId, Instant confirmedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java new file mode 100644 index 000000000..e5632484b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 派送安排、费用证据与正常签收的共享边界,均保留真实客户。 */ +public final class DispatchDeliveryModels { + /** 派送契约容器不允许实例化。 */ + private DispatchDeliveryModels() { } + + public record CoverageSelection(Long financeFeeId, int sourceVersion, String sourceHash, + List services) { } + + public record FeeItem(String feeType, String feeName, String direction, String currency, + BigDecimal amount, Long supplierId, String supplierName, String supplierCategory, + String settlementMode, Instant paidAt, String paymentMethod, String paymentReferenceNo, + List paymentVoucherFileIds, BigDecimal chargeableQuantity, String quantityUnit, + BigDecimal unitPrice, LocalDate periodStart, LocalDate periodEnd, String remark) { } + + public record ArrangeGroup(String groupKey, String groupCargoHash, Instant plannedDeliveryAt, + String deliveryMethod, String waybillNo, String carrierName, String pickupMethod, + String loadingType, String vehicleType, String vehicleSize, String appointmentAccount, + String sellerName, String remark, String externalFreightHash, String feeCoverageCargoHash, + List feeCoverage, List fees) { } + + public record ArrangeDelivery(Long recordVersion, String idempotencyKey, String cargoHash, + String loadingCompletionHash, List groups) { + /** 安排派送缺版本不得被反序列化为有效零。 */ + public ArrangeDelivery { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record OutboundReference(String customerId, String customerName, String outboundNo, + String status, long packageCount, long palletCount) { } + + public record DeliveryReference(String groupKey, String customerId, String customerName, + String outboundNo, String deliveryNo, Destination destination, + String status, long packageCount, long palletCount) { } + + public record ArrangementResult(String dispatchNo, long recordVersion, String arrangementKey, + String cargoHash, Instant arrangedAt, List outbounds, + List deliveries) implements DispatchResults.Payload { } + + public record ExternalQuote(String customerId, String groupKey, String bookingNo, String inboundCode, + String currency, BigDecimal amount, String sourceHash) { } + + public record FeeCandidate(Long financeFeeId, String customerId, String currency, BigDecimal amount, + int sourceVersion, String sourceHash, String status, List services, + List sourceKeys) { } + + public record PreviewGroup(String groupKey, String customerId, String customerName, Destination destination, + String groupCargoHash, List cargo, String externalFreightHash, + List externalQuotes, String feeCoverageCargoHash, + List feeSources, List requiredServices, + List blockReasons) { } + + public record ArrangementPreview(String dispatchNo, long recordVersion, Status status, Stage stage, + String cargoHash, String loadingCompletionHash, Gate arrange, + List groups, ArrangementResult arrangement) { } + + /** 只认本车全部客户单据完整覆盖,lock模式由调用方父事务提供。 */ + public record DepartureFacts(String dispatchNo, String arrangementKey, String cargoHash, + String loadingCompletionHash, String arrangementBusinessHash, + List outbounds, List deliveries, + List cargo, Gate depart) { } + + public record DepartureResult(List outbounds, List deliveries, + long shippedPackages) { } + + public record NormalDeliveryFact(String customerId, String groupKey, String outboundNo, + String deliveryNo, long deliveryVersion, long originalPackages, + long signedPackages, long returnedPackages, long activeReturnCount, + String status, boolean outboundCompleted, Instant signedAt) { } + + public record NormalDeliveryFacts(String dispatchNo, String factsHash, boolean allNormallySigned, + Instant completedAt, List expectedDeliveryNos, + List deliveries, List blockReasons) { } + + public record ArrangementFact(String arrangementKey, String arrangementBusinessHash, + String cargoHash, String loadingCompletionHash, ArrangementResult result) { } + + public record Signal(Long signalId, String dispatchNo, String deliveryNo, long deliveryVersion, + int attempts, Instant nextRetryAt) { } + + public record CompletionResult(String dispatchNo, Stage stage, long recordVersion, + boolean changed, boolean allNormallySigned, Instant completedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java new file mode 100644 index 000000000..11fea00de --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */ +public interface DispatchDeliveryPort { + /** 一次预览返回同快照下全部客户目的地及费用证据。 */ + ArrangementPreview preview(Context context); + + /** 平台整车安排;使用主流程端口获得父锁和共同命令结果。 */ + CommandResult arrange(Actor actor, String dispatchNo, ArrangeDelivery request); + + /** lock为真时先锁齐本车全部outbound/delivery,再允许来源/费用锁。 */ + DepartureFacts departureFacts(Context context, boolean lock); + + /** 加入发车父事务,仅更新真实客户单据/计数,不执行库存SHIP。 */ + DepartureResult markDeparted(Context context, Actor actor, Instant departedAt, String commandKey); + + /** 普通读取全部本趟签收事实,不锁历史来源,不调用跨趟来源证明。 */ + NormalDeliveryFacts normalDeliveryFacts(Context context); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java new file mode 100644 index 000000000..a1ed11086 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.Optional; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 主流程实现的整车权威端口;写方法必须加入调用者已有事务。 */ +public interface DispatchExecutionPort { + /** 只读返回当前真实货物,不授予写资格或取得子单锁。 */ + Context preview(Actor actor, String dispatchNo); + + /** 双端按当前权限/绑定仓读取真实实载及缺项,不补造PDA数量或预约。 */ + DispatchPlanningModels.LoadingPreview loading(Actor actor, String dispatchNo); + + /** 取得trip→必要资源→dispatch;原命令恢复优先,不提前拒绝旧版本。 */ + Context lockForArrangement(Actor actor, String dispatchNo); + + /** 当前权限/仓核验后查全局请求键;异主体或异摘要必须拒绝。 */ + Optional replay(Context context, Actor actor, CommandIdentity identity); + + /** 新命令复验当前版本、有效状态、装车后和完整真实拣货。 */ + void requireFreshArrangement(Context context, long recordVersion); + + /** 与客户单据/费用同事务记录安排引用及公共命令;既有安排不得再执行。 */ + CommandResult recordArrangement(Context context, Actor actor, CommandIdentity identity, + ArrangementFact fact, boolean alreadyArranged); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java new file mode 100644 index 000000000..647ff9c2d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 候选、草稿和实载读取种子,不生成日历预约或虚构实测。 */ +public final class DispatchPlanningModels { + /** 规划契约容器不允许实例化。 */ + private DispatchPlanningModels() { } + + public record CandidateQuery(String warehouseId, List planIds, String orderNo, + String destinationWarehouseCode, String sourceStage, String draftToken) { } + + /** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */ + public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion, + String customerId, String customerName, String warehouseId, String warehouseCode, + String warehouseName, String orderNo, String inboundCode, String planNo, + String palletNo, String containerNo, Destination destination, long availablePackages, + BigDecimal suggestedWeightKg, BigDecimal suggestedVolumeCbm, String measurementSource, + List appointmentBatches, List blockReasons) { } + + public record CandidateList(List candidates, Instant snapshotAt, boolean complete) { } + + public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity, + List sourceKeys) { } + + public record Draft(String draftToken, long recordVersion, String status, String warehouseId, + List containers, List sources, Instant expiresAt) { } + + public record CreateDraft(String idempotencyKey, String warehouseId) { } + + public record CreateContainer(Long recordVersion, String idempotencyKey, String draftToken, int capacity) { + /** 已建草稿也必须区分缺版本和首版零。 */ + public CreateContainer { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftSelection(Long recordVersion, String idempotencyKey, String draftToken, + Long draftContainerId, DispatchCommands.CargoSelection source) { + /** 锁定或移除来源时不能覆盖并发草稿修订。 */ + public DraftSelection { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftSnapshot(Long recordVersion, String idempotencyKey, String draftToken, + List planIds, List containers) { + /** 草稿布局同样要求显式版本。 */ + public DraftSnapshot { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftRelease(Long recordVersion, String idempotencyKey, String draftToken) { + /** 释放仅作用于原版本/原拥有者,不能覆盖新选择。 */ + public DraftRelease { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + /** 一个服务器草稿容器原子生成一张整车,不按客户再拆子排车。 */ + public record DraftVehicle(Long draftContainerId, DispatchCommands.Create dispatch) { } + + public record ConvertDraft(Long recordVersion, String idempotencyKey, String draftToken, + List groups) { + /** 正式转换按已核对草稿版本执行,不重复生成车单。 */ + public ConvertDraft { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record ConvertedVehicle(Long draftContainerId, CommandResult dispatch) { } + + public record ConvertedDraft(String draftToken, List vehicles, boolean replayed) { } + + /** 非实测来源只能作为建议展示,不能填入实际测量值。 */ + public record MeasurementEvidence(String field, String sourceType, String sourceBusinessNo, + Instant observedAt, BigDecimal value, boolean actual) { } + + public record LoadingPallet(Long warehousePalletId, String palletNo, long scheduledPackages, + long pickedPackages, Integer palletSpaces, BigDecimal weightKg, + BigDecimal volumeCbm, Integer deliveryIndex, List missingFields, + List evidence) { } + + public record LoadingPreview(String dispatchNo, long recordVersion, DispatchTypes.Status status, + DispatchTypes.Stage stage, String manifestHash, boolean pickingReconciled, + Resources resources, List pallets, ConfirmedLoad confirmedLoad, + Gate loadingComplete, List missingFields) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java new file mode 100644 index 000000000..382abe968 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; + +/** 主流程独占实现候选与草稿,不按客户拆成多个排车头。 */ +public interface DispatchPlanningPort { + /** 有界批量解析真实来源、客户和事实摘要,不能跨仓或静默截断。 */ + CandidateList candidates(Actor actor, CandidateQuery query); + + /** 只查询当前操作者拥有的未失效草稿。 */ + List drafts(Actor actor); + + /** 持久幂等创建草稿,返回服务端token及合法零版本。 */ + Draft createDraft(Actor actor, CreateDraft request); + + /** 在草稿版本和拥有者锁内新增装车容器。 */ + Draft createContainer(Actor actor, CreateContainer request); + + /** 保存布局并按服务端来源占用重核,不信任布局JSON赋予货物资格。 */ + Draft saveSnapshot(Actor actor, DraftSnapshot request); + + /** 比对sourceVersion后取得真实来源/实托排他占用。 */ + Draft select(Actor actor, DraftSelection request); + + /** 仅解除本草稿本容器的原来源,不释放他人新占用。 */ + Draft unselect(Actor actor, DraftSelection request); + + /** 以当前草稿版本移除容器并归还其未执行来源。 */ + Draft removeContainer(Actor actor, String draftToken, Long containerId, DispatchCommands.ReasonCommand request); + + /** 幂等释放当前操作者的原草稿。 */ + Draft release(Actor actor, DraftRelease request); + + /** 一个事务转换全部组,一组一整车,任意客户失败全批回滚。 */ + ConvertedDraft convert(Actor actor, ConvertDraft request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java new file mode 100644 index 000000000..b78185820 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +/** 不可变命令结果的数据类型,恢复不得改成当前详情。 */ +public final class DispatchResults { + /** 结果容器不允许实例化。 */ + private DispatchResults() { } + + public sealed interface Payload permits Saved, Picked, Loaded, Departed, + DispatchDeliveryModels.ArrangementResult { } + + public record Saved(String dispatchNo, List changedItemIds) implements Payload { } + + public record Picked(Long dispatchItemId, Long positionId, Long returnCartonId, long packages, + long pickedPackages, boolean pickingReconciled) implements Payload { } + + public record Loaded(String loadingCompletionHash, Instant loadingCompletedAt, + DispatchContracts.ConfirmedLoad confirmedLoad) implements Payload { } + + public record Departed(Instant departedAt, long shippedPackages, + List outbounds, + List deliveries) implements Payload { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java new file mode 100644 index 000000000..3b335d48d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 派送域实现持久提醒数据访问;调度、退避和重扫由主流程负责。 */ +public interface DispatchSignalPort { + /** 有界普通读到期信号,不持信号或派送锁进入父事务。 */ + List due(Instant now, int limit); + + /** MANDATORY:与主阶段CAS同一汇总事务确认,失败全部回滚。 */ + void acknowledge(Signal signal, Instant acknowledgedAt); + + /** REQUIRES_NEW:仅在父汇总回滚后独立更新失败次数及退避时间。 */ + void failed(Signal signal, Instant nextRetryAt, String errorCode); + + /** 在原签收事务中持久去重写入,不回调父流程或自动推进阶段。 */ + void enqueue(String dispatchNo, String deliveryNo, long deliveryVersion, Instant occurredAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java new file mode 100644 index 000000000..69a90951b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 整车作业各端共用的唯一枚举,不复用客户子单或车次状态。 */ +public final class DispatchTypes { + /** 枚举容器不允许实例化。 */ + private DispatchTypes() { } + + public enum Status { ACTIVE, CANCELLED } + public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED } + public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED } + public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED } + public enum Decision { APPROVED, REJECTED } + public enum ResourceMode { MASTER, EXTERNAL } + public enum ActorMode { PLATFORM, PDA } + public enum SourceType { INBOUND_PLAN, RETURN_RECEIPT, RELABEL } + public enum BeforeLoadingSection { VEHICLE_INFO, DISPATCH_REVIEW, LOADING_REVIEW } + public enum StatusFilter { ACTIVE, CANCELLED, ALL } + public enum Effect { APPLIED, EXISTING } + public enum Action { + CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW, + LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java new file mode 100644 index 000000000..058b41cb2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 列表及四页签展示对象,不将当前页统计冒充全量统计。 */ +public final class DispatchViews { + /** 展示容器不允许实例化。 */ + private DispatchViews() { } + + public record Query(long page, long size, String dispatchNo, String vehiclePlate, + String warehouseId, String customerId, String fbaAppointmentNo, + StatusFilter status, Stage stage, BeforeLoadingSection beforeLoadingSection, + ReviewStatus reviewStatus) { } + + public record ReviewCounts(long notSubmitted, long pending, long approved, long rejected) { } + + public record BeforeLoadingCounts(long vehicleInfoTotal, ReviewCounts dispatchReview, + ReviewCounts loadingReview) { } + + public record Statistics(long matchedTotal, long activeTotal, long cancelledTotal, + Map stageCounts, BeforeLoadingCounts beforeLoadingReviewCounts) { } + + public record Page(List records, long total, long page, long size, long pages, + Statistics statistics, Instant snapshotAt) { } + + public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion, + ReviewState reviewState, Reviews reviews, Resources resources, + String warehouseId, String warehouseCode, String warehouseName, + Instant plannedDepartureAt, Instant departedAt, Counts counts, + boolean pickingReconciled, boolean arranged, + Map actions) { } + + public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver) { } + + public record FbaAppointment(String batchKey, String batchNo, String appointmentNo, + String destinationKey, String destinationWarehouseCode, + String status, Instant confirmedAt, String blockReason) { } + + /** 仅引用已存在的车辆预约;本批不新增预约权威模型或创建接口。 */ + public record VehicleAppointment(String referenceNo, String status, Instant appointmentAt) { } + + public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt, + Instant estimatedArrivalAt, List fbaAppointments, + VehicleAppointment vehicleAppointment, List appointmentBatches) { } + + public record Documents(String dispatchNo, List outbounds, + List deliveries, + Instant createdAt, Instant updatedAt, String remark) { } + + public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode, + BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse, + BigDecimal volumeCbm, String containerNo, String sourceKey) { } + + /** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */ + public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion, + ReviewState reviewState, Reviews reviews, Counts counts, + boolean pickingReconciled, Map actions, + VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents, + List orderDetails) { } + + public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, + Stage stage, long recordVersion, Reviews reviews, Counts counts, + boolean pickingReconciled, List items, Map actions) { } + + public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo, + Destination destination, long scheduledPackages, long pickedPackages, + List positions, List returnCartons) { } + + public record Position(Long positionId, Long balanceId, long availablePackages, + long allocatedPackages, long pickedPackages) { } + + public record ReturnCarton(Long returnCartonId, String cartonMark, String status, + long productQuantity) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java new file mode 100644 index 000000000..d2f1b708b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 四详情对象之外的作业弹框读取,不以展示字段代替真实明细或卸货顺序。 */ +public final class DispatchWorkModels { + /** DTO容器不实例化。 */ + private DispatchWorkModels(){ } + public record CargoItem(Long dispatchItemId,String customerId,String customerName,String sourceKey,String sourceVersion, + SourceType sourceType,Long planPalletId,Long returnReceiptId,String relabelNo,Long warehousePalletId,String palletNo, + String orderNo,String inboundCode,Destination destination,long scheduledPackages,long pickedPackages,Gate remove) { } + public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List items) { } + public record Stop(int index,String name,String address,String destinationKey) { } + public record Route(String dispatchNo,long recordVersion,List stops,Gate edit) { } + /** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */ + public record RouteCommand(Long recordVersion,String idempotencyKey,List destinationKeys,String reason) { + /** 显式零版本合法,缺版本不能覆盖并发变更。 */ + public RouteCommand { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java new file mode 100644 index 000000000..222f415ee --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 整车读写持久化入口;业务流转与事务由服务负责。 */ +@Repository +public class DispatchAggregateRepository { + private final DispatchAggregateMapper mapper; + private final DispatchItemMapper items; + private final DispatchReadMapper reads; + + /** 只注入本域Mapper,服务不直接执行SQL。 */ + public DispatchAggregateRepository(DispatchAggregateMapper mapper, DispatchItemMapper items, DispatchReadMapper reads) { + this.mapper = mapper; this.items = items; this.reads=reads; + } + /** 新建真整车。 */ + public int insert(DispatchAggregate row) { return mapper.insert(row); } + /** 状态、审核、资源和实际执行事实以同一公共版本CAS保存。 */ + public int saveState(DispatchAggregate row,long originalVersion) { return mapper.saveState(row,originalVersion); } + /** 真实扫码流水优先于主单冗余计数。 */ + public long realPicked(long id) { return reads.realPicked(id); } + /** 读取业务号对应整车。 */ + public DispatchAggregate find(String number) { return mapper.find(number); } + /** 同一快照批量读整车,空集合不生成无效IN SQL。 */ + public List findMany(List numbers) { return numbers.isEmpty()?List.of():mapper.findMany(numbers); } + /** 在调用方已取得资源父锁后锁整车。 */ + public DispatchAggregate lock(String number) { return mapper.lock(number); } + /** 整车分页。 */ + public List page(DispatchAggregateMapper.Search query) { return mapper.page(query); } + /** 整车分页总数。 */ + public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); } + /** 全搜索基集的阶段和审核分布。 */ + public List summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); } + /** 按整车读取全部真实客户明细。 */ + public List cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); } + /** 保存已核对真实客户与仓库的来源快照。 */ + public int insertItem(DispatchItem item) { return items.insert(item); } + /** 未执行明细才可移除,资格和根版本由应用层保证。 */ + public int removeItem(long itemId) {return items.deleteById(itemId);} + /** 原子更新货量并作废旧审核依据。 */ + public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages, + String route, String pending, LocalDateTime at) { + return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at); + } + /** 追加不可变业务快照。 */ + public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) { + return mapper.history(id,row.getId(),row.getRecordVersion(),action,operator,reason,snapshot,at); + } + /** 同车全部客户出库摘要。 */ + public List outbounds(long id) { return reads.outbounds(id); } + /** 同车全部客户目的地派送摘要。 */ + public List deliveries(long id) { return reads.deliveries(id); } + /** 已有关联预约批次只读事实。 */ + public List batches(long id) { return reads.batches(id); } + /** 是否必须走受保护提供者核验批次分配。 */ + public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java new file mode 100644 index 000000000..dfab76006 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import com.baiyue.lastleg.dispatch.mapper.DispatchCommandMapper; +import org.springframework.stereotype.Repository; + +/** 命令账本的数据入口,不承担权限、状态或幂等业务决定。 */ +@Repository +public class DispatchCommandRepository { + private final DispatchCommandMapper mapper; + + /** 注入本服务显式注册的命令Mapper。 */ + public DispatchCommandRepository(DispatchCommandMapper mapper) { this.mapper = mapper; } + + /** 按全局请求键查询原不可变结果。 */ + public DispatchCommandRecord find(String key) { return mapper.find(key); } + + /** 原子新增成功命令,不能更新覆盖既有行。 */ + public int insert(DispatchCommandRecord row) { return mapper.insert(row); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java new file mode 100644 index 000000000..74b7fa05d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.mapper.*; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 草稿命令、根版本、布局和自身占用的数据访问;事务及资格由专用服务编排。 */ +@Repository +public class DispatchDraftRepository { + private final DispatchDraftMapper drafts; + private final DispatchDraftContainerMapper containers; + private final DispatchDraftCommandMapper commands; + private final DispatchDraftClaimMapper claims; + + /** 仅装配本有界包Mapper,来源资格仍取既有提供者。 */ + public DispatchDraftRepository(DispatchDraftMapper drafts,DispatchDraftContainerMapper containers, + DispatchDraftCommandMapper commands,DispatchDraftClaimMapper claims) { + this.drafts=drafts;this.containers=containers;this.commands=commands;this.claims=claims; + } + /** 读取当前拥有者的未失效草稿。 */ + public List active(String owner){return drafts.findActiveByOperator(owner);} + /** 在唯一命令之后取得拥有者根锁。 */ + public DispatchDraft lock(String token,String owner){return drafts.lockOwned(token,owner);} + /** 新建服务器生成身份的草稿。 */ + public void insert(DispatchDraft row){if(drafts.insert(row)!=1)throw new IllegalStateException("草稿未保存");} + /** 公共版本CAS失败必须回滚本次全部写入。 */ + public void save(DispatchDraft row,long version){if(drafts.save(row,version)!=1)throw new IllegalStateException("草稿版本竞争失败");} + /** 布局普通读始终在草稿根事务中使用。 */ + public List containers(String token){return containers.findByToken(token);} + /** 保存真实新容器。 */ + public void insert(DispatchDraftContainer row){if(containers.insert(row)!=1)throw new IllegalStateException("草稿容器未保存");} + /** 布局交换先腾出本草稿原序号,异常由外层事务整体回滚。 */ + public void stageSequences(String token){containers.stageSequences(token);} + /** 写回服务端校验后的完整布局。 */ + public void layout(DispatchDraftContainer row){if(containers.layout(row.getId(),row.getDraftToken(),row.getSequenceNo(),row.getCapacity(),row.getSourceKeysJson(),row.getUpdatedAt())!=1)throw new IllegalStateException("草稿容器归属已变化");} + /** 只删除已核对为空的本容器。 */ + public void remove(String token,long id){if(containers.remove(id,token)!=1)throw new IllegalStateException("草稿容器不存在");} + /** 全局草稿命令键竞争采用数据库唯一约束。 */ + public DispatchDraftCommand reserve(DispatchDraftCommand row){commands.reserve(row);return commands.lock(row.getIdempotencyKey());} + /** 原始结果随业务事务共同完成。 */ + public void complete(long id,String json){if(commands.complete(id,json)!=1)throw new IllegalStateException("草稿命令结果未保存");} + /** 来源提供者已取根锁后,锁本草稿活动占用。 */ + public List claims(DispatchDraft row,LocalDateTime now){return claims.owned(row.getDraftToken(),row.getOperatorId(),now);} + /** 占用来源及实托,两项唯一键共同保证排他。 */ + public void claim(long id,DispatchDraft row,long container,DispatchServiceSource source,LocalDateTime now){ + if(claims.insert(id,source.customer(),source.key(),source.plan(),source.pallet(),source.receipt(),container,row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt())!=1)throw new IllegalStateException("来源占用未保存"); + } + /** 数据访问输入只包含提供者已经解析的真实标识。 */ + public record DispatchServiceSource(String customer,String key,Long plan,Long pallet,Long receipt) { } + /** 移动只针对同草稿原容器记录,零行不能被当作成功。 */ + public void move(DispatchDraft row,DispatchDraftClaimMapper.Claim claim,long next,LocalDateTime now){if(claims.move(claim.id(),row.getDraftToken(),row.getOperatorId(),claim.containerId(),next,now)!=1)throw new IllegalStateException("原草稿占用已变化");} + /** 释放限定原token、owner及尚未转换的草稿记录。 */ + public int release(DispatchDraft row,String key,Long container,LocalDateTime now){return claims.release(row.getDraftToken(),row.getOperatorId(),key,container,now);} + /** 活动草稿修改时同步续期原占用。 */ + public int renew(DispatchDraft row,LocalDateTime now){return claims.renew(row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java new file mode 100644 index 000000000..461a34c01 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 真实执行读取只经本域Mapper,流程判断留给应用服务。 */ +@Repository +public class DispatchExecutionRepository { + private final DispatchExecutionReadMapper reads; + /** 装配批量查询。 */ + public DispatchExecutionRepository(DispatchExecutionReadMapper reads){this.reads=reads;} + /** 全车所有客户的真实扫码来源。 */ + public List sources(long id){return reads.sources(id);} + /** 每项实际已拣数量。 */ + public List itemPicked(long id){return reads.itemPicked(id);} + /** 不完整归属不能核平。 */ + public long inconsistentPicks(long id){return reads.inconsistentPicks(id);} + /** 可自动带入的实测证据。 */ + public List actualMeasurements(long id){return reads.actualMeasurements(id);} + /** 没有持久信号的已签收版本仍可通过真实单据重建通知。 */ + public List rescan(int limit){return reads.rescan(limit);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java new file mode 100644 index 000000000..a3e526e65 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 整车PDA批量读投影,空集合由应用层短路,避免无范围查询。 */ +@Repository +public class DispatchPdaReadRepository { + private final DispatchWholePdaReadMapper mapper; + /** 装配本域读Mapper。 */ + public DispatchPdaReadRepository(DispatchWholePdaReadMapper mapper){this.mapper=mapper;} + /** 绑定仓真实主单。 */ + public List headers(String warehouse,String number,String stage){return mapper.headers(warehouse,number,stage);} + /** 一次取齐当前根集合的所有客户货物。 */ + public List items(List ids){return mapper.items(ids);} + /** 实际扫码计数。 */ + public List picked(List ids){return mapper.picked(ids);} + /** 归属不完整不能核平。 */ + public List invalid(List ids){return mapper.invalid(ids);} + /** 本批真实客户库存仓位。 */ + public List positions(List ids,String warehouse){return mapper.positions(ids,warehouse);} + /** 本批原生退货箱。 */ + public List cartons(List ids,String warehouse){return mapper.cartons(ids,warehouse);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java new file mode 100644 index 000000000..f32b74661 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 每张排车唯一作业,扫码明细保留真实客户与来源归属。 */ +@Repository +public class DispatchPickingRepository { + private final DispatchPdaTaskMapper tasks;private final DispatchPdaPickMapper picks; + private final InventoryPositionMapper positions;private final DispatchItemMapper items; + /** 封装原作业Mapper,库存状态变化仍经库存命令服务。 */ + public DispatchPickingRepository(DispatchPdaTaskMapper tasks,DispatchPdaPickMapper picks,InventoryPositionMapper positions,DispatchItemMapper items){this.tasks=tasks;this.picks=picks;this.positions=positions;this.items=items;} + /** 根锁后锁本车唯一作业。 */ + public DispatchPdaTask task(long dispatch){return tasks.lockByDispatch(dispatch);} + /** 审核后首次实际扫码创建本车作业。 */ + public int createTask(DispatchPdaTask task){return tasks.insert(task);} + /** 保存本车作业进度。 */ + public int saveTask(DispatchPdaTask task){return tasks.updateById(task);} + /** 普通读确定实际仓位身份,来源准备后再锁库存复验。 */ + public InventoryPosition position(long id){return positions.selectById(id);} + /** 真实扫码明细在同一事务保存。 */ + public int pick(DispatchPdaPick pick){return picks.insert(pick);} + /** 本明细实际已扫码数量。 */ + public long itemPicked(String customer,long task,long item){return picks.totalItem(customer,task,item);} + /** 自然收货后保存已经核实的同一计划来源实托身份。 */ + public int materialize(DispatchItem item){return items.updateById(item);} + /** 按真实客户取本车扫码,不借主单客户头。 */ + public List picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java new file mode 100644 index 000000000..c41eeec1e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; + +/** 原来源业务状态同步的数据封装,不能把物理库存SHIP再执行一次。 */ +@Repository +public class DispatchSourceDepartureRepository { + private final WarehouseTransferOrderMapper transfers; + /** 保留原转仓完成条件,主流程不重写该提供者SQL。 */ + public DispatchSourceDepartureRepository(WarehouseTransferOrderMapper transfers){this.transfers=transfers;} + /** 普通实际来源同步其转仓履约。 */ + public void transferOrder(String customer,long order,LocalDateTime at){transfers.markOutboundByOrder(customer,order,at);} + /** 换标只在该原转仓剩余可用箱数为零时完成。 */ + public void relabel(String customer,String number,LocalDateTime at){transfers.markOutboundByRelabel(customer,number,at);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java new file mode 100644 index 000000000..161b9119f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */ +@Repository +public class DispatchSourceRepository { + private final DispatchCandidateMapper plans; + private final ReturnDispatchCandidateMapper returns; + private final DispatchSourceMapper source; + + /** 复用已有来源查询和本域锁Mapper。 */ + public DispatchSourceRepository(DispatchCandidateMapper plans, ReturnDispatchCandidateMapper returns, DispatchSourceMapper source) { + this.plans=plans; this.returns=returns; this.source=source; + } + /** 普通来源按仓和筛选在SQL取有界集合。 */ + public List availablePlans(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return plans.wholeAvailable(q.warehouseId(),null,q.planIds(),q.orderNo(),q.destinationWarehouseCode(),q.sourceStage(),q.draftToken()); + } + /** 退回来源保持其真实客户和库存仓。 */ + public List availableReturns(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return returns.wholeAvailable(q.warehouseId(),null,q.orderNo(),q.destinationWarehouseCode(),q.draftToken()); + } + /** 普通换标候选先限定真实仓,之后由实际测量提供者补齐证据。 */ + public List availableRelabel(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return plans.wholeRelabelAvailable(q.warehouseId(),null,q.orderNo(),null); + } + /** 来源托盘的真实客户。 */ + public List planOwners(Long id) { return plans.findCustomerScopesByPlanPalletIds(List.of(id)); } + /** 退回收据的真实客户。 */ + public List returnOwners(Long id) { return returns.customers(List.of(id)); } + /** 换标单业务客户。 */ + public String relabelOwner(String no) { return source.relabelOwner(no); } + /** 真实申请轮次依据。 */ + public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); } + /** 取得已有普通计划候选,保留原来源资格SQL。 */ + public List selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } + /** 取得已有真实退回候选。 */ + public List selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } + /** 同托真实来源订单组成。 */ + public List sourceOrders(String customer, Long pallet, Long planPallet) { return source.sourceOrders(customer,pallet,planPallet); } + /** 来源订单加锁并校验未暂扣。 */ + public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); } + /** 锁定真实计划托盘根。 */ + public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); } + /** 锁定实际退回收据根。 */ + public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); } + /** 真实库存数量及版本。 */ + public List stock(String customer, Long pallet, boolean lock) { return source.stock(customer,pallet,lock); } + /** 不可变收货来源组成。 */ + public List receipts(String customer, Long pallet) { return source.receipts(customer,pallet); } + /** 同一实托的退回实收不得从来源证明丢失。 */ + public List returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);} + /** 释放已到期草稿键。 */ + public int expire(String customer, String key, Long pallet, LocalDateTime at) { return source.expire(customer,key,pallet,at); } + /** 锁定当前活动资源占用。 */ + public List claims(String customer, String key, Long pallet, LocalDateTime at) { return source.claims(customer,key,pallet,at); } + /** 精确释放本车本来源,不释放他车重新取得的键。 */ + public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getPlanPalletId(),item.getReturnReceiptId(),item.getWarehousePalletId(),at);} + /** 原计划收货的物理身份与其活动键同事务补齐,不复制来源占用。 */ + public int materialize(long dispatch,String customer,long plan,long pallet,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,at);} + /** 保存正式源/实托占用。 */ + public int claim(long id, String customer, String key, Long plan, Long pallet, Long receipt, long dispatch, String operator, LocalDateTime at) { + return source.claim(id,customer,key,plan,pallet,receipt,dispatch,operator,at); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java new file mode 100644 index 000000000..d6ab99161 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -0,0 +1,824 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ +@Service +public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; + /** 仅用于显示的不可变来源字段,数量/归属仍取关系明细和真实拣货。 */ + public record SourceDisplay(String containerNo,String planNo) { } + private static final String PREFIX = "platform:overseas-warehouse:dispatch:"; + private final DispatchAggregateRepository repository; + private final DispatchSourceRepository sources; + private final DispatchService sourceRules; + private final FleetTripExecutionService fleet; + private final DispatchCommandLedger commands; + private final DispatchCommandHasher hashes; + private final ObjectMapper json; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads; + + /** 领域编排只访问Repository和提供者应用入口。 */ + public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources, + DispatchService sourceRules, FleetTripExecutionService fleet, DispatchCommandLedger commands, + DispatchCommandHasher hashes, ObjectMapper json) { + this.repository=repository; this.sources=sources; this.sourceRules=sourceRules; this.fleet=fleet; + this.commands=commands; this.hashes=hashes; this.json=json; + } + + /** 草稿转换统一预锁全批资源;正式create只重入已持身份锁,再逐组持久新根。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDraftCreationResources(Actor actor,List creates) { + requirePlatform(actor);if(creates==null || creates.isEmpty() || creates.size()>1000 || creates.stream().anyMatch(Objects::isNull))throw bad("请选择完整草稿建车分组"); + fleet.lockDispatchCreationResources(creates.stream().map(c->fleetVehicle(c.vehicle())).toList(),creates.stream().map(c->fleetDriver(c.driver())).toList()); + appointments.lockCreates(creates); + } + + /** 全范围先锁父级/资源,再按真实主单ID锁全部dispatch,不交错取下一车父锁。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public void lockExistingAppointmentDispatches(Actor actor,List numbers) { + requirePlatform(actor);fleet.lockExistingDispatchShells(numbers); + var observed=repository.findMany(numbers); + if(observed.size()!=numbers.size())throw conflict("原运输排车集合已变化,请重新读取完整范围"); + for(var row:observed.stream().sorted(Comparator.comparing(DispatchAggregate::getId)).toList()) { + var locked=required(actor,row.getDispatchNo(),true);var identity=fleet.dispatchIdentity(row.getDispatchNo()); + if(!Objects.equals(locked.getTripId(),row.getTripId()) || identity.tripId()!=locked.getTripId())throw conflict("原运输资源归属已变化"); + } + } + + /** 原预约父事务的唯一改量桥接,不调用旧Fleet解绑,不接收用户可选的移货开关。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public CommandResult applyExistingAppointmentChange(Actor actor,String number,DispatchCommands.ReasonCommand request, + List physical,List expected,List next,boolean remove) { + requirePlatform(actor);var row=required(actor,number,true); + if(request==null || trim(request.idempotencyKey())==null || trim(request.reason())==null)throw bad("原预约命令身份及变更原因必填"); + String key="APPOINTMENT:"+hashes.source(Arrays.asList(actor.operatorId(),request.idempotencyKey(),number)); + var identity=identity(actor,number,Action.EDIT,request.recordVersion(),key,Arrays.asList("EXISTING_APPOINTMENT",request,physical,expected,next,remove)); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + requireEditing(row,request.recordVersion()); + if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有实际扫码或有效下游单据,不能普通调整原预约"); + if(physical==null || physical.isEmpty() || physical.size()>500 || physical.stream().anyMatch(Objects::isNull))throw bad("原预约缺少完整真实实物范围"); + var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); + var ids=new HashSet();var touched=new ArrayList(); + for(var input:physical) { + var item=byId.get(input.itemId()); + if(item==null || !ids.add(input.itemId()) || !Objects.equals(input.customerId(),item.getCustomerId()) + || !Objects.equals(input.planPalletId(),item.getPlanPalletId()) || !Objects.equals(input.sourceStage(),item.getSourceStage()) + || !Objects.equals(input.packages(),item.getPackageCount()) || input.packages()==null || input.packages()<=0 + || !Objects.equals(item.getOverseasWarehouseId(),row.getOverseasWarehouseId()))throw conflict("原预约只能调整已核齐真实客户与完整数量的原实物"); + touched.add(item); + } + var current=repository.batches(row.getId()).stream().filter(b->ids.contains(b.dispatchItemId())).toList(); + var actual=current.stream().map(b->new BatchAllocation(b.planPalletId(),b.batchKey(),b.customerId(),b.sourceKey(),b.destinationKey(),b.packageCount(),b.recordVersion())).toList(); + validateAppointmentVersions(expected);validateAppointmentVersions(next); + if(!allocationTotals(actual).equals(allocationTotals(expected)))throw conflict("原实物预约分配集合或数量已变化,请重新读取完整范围"); + if(remove) { + if(next!=null && !next.isEmpty())throw bad("移出完整实物时不能保留其新分配"); + var relabels=touched.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet()); + if(items.stream().anyMatch(i->relabels.contains(i.getSourceBusinessNo()) && !ids.contains(i.getId())))throw conflict("换标必须按原完整申请调整,不能仅移出部分来源"); + appointments.remove(row.getId(),touched);sourceRules.releaseWholeRelabel(touched); + for(var item:touched) { + if(sources.release(row.getId(),item,now())<1 || repository.removeItem(item.getId())!=1)throw conflict("原实物占用或货物明细已变化"); + } + refreshCargo(row,items.stream().filter(i->!ids.contains(i.getId())).toList());row=repository.find(number); + history(row,actor,Action.EDIT,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); + commands.append(row.getId(),actor,identity,result);return result; + } + appointments.bind(row.getId(),touched,next);invalidate(row,"原预约分配已调整,请重新两级审核"); + return saveCommand(row,actor,identity,request.reason(),new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); + } + + /** 原分配比较业务归属和数量;父事务已锁批次并可能正推进权威版本。 */ + private record AllocationIdentity(Long planPalletId,String batchKey,String customerId,String sourceKey,String destinationKey) { } + private static Map allocationTotals(List allocations) { + var result=new HashMap(); + if(allocations!=null)for(var a:allocations) { + if(a==null || trim(a.batchKey())==null || trim(a.customerId())==null || trim(a.sourceKey())==null || trim(a.destinationKey())==null || a.packageCount()<=0)throw bad("原预约分配缺少真实来源或数量"); + result.merge(new AllocationIdentity(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey()),(long)a.packageCount(),Math::addExact); + } + return result; + } + private static void validateAppointmentVersions(List allocations) { + var versions=new HashMap(); + if(allocations!=null)for(var a:allocations) { + if(a==null || a.recordVersion()==null || a.recordVersion()<0)throw bad("原预约批次版本必填且不能为负"); + var old=versions.putIfAbsent(a.batchKey(),a.recordVersion());if(old!=null && !old.equals(a.recordVersion()))throw conflict("同一预约批次包含不同原版本"); + } + } + + /** 候选及来源摘要来自同一只读快照;公开选择可直接用于同仓多客户建单。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchPlanningModels.CandidateList candidates(Actor actor,DispatchPlanningModels.CandidateQuery query) { + requirePlatform(actor); + if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓"); + if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法"); + var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), + trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken())); + var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized); + var batchFacts=appointments.candidates(rows); + var result=new ArrayList(); + for(var row:rows) { + SourceType type="RELABEL".equals(row.sourceStage())?SourceType.RELABEL: + row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN; + String relabel=type==SourceType.RELABEL?row.orderRefs():null; + var preliminary=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),null); + String key=DispatchService.wholeSourceKey(preliminary),version=sourceRules.wholeSourceVersion(row,key,false); + var selection=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),version); + var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(), + row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(), + row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone()); + var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId())); + var references=new ArrayList();var reasons=new LinkedHashSet(); + if(fact!=null) { + reasons.addAll(fact.blockReasons()); + // 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。 + if(fact.occupied() && normalized.draftToken()==null)reasons.add("实物已被其他有效运输安排占用"); + for(var ref:fact.appointmentBatches()) { + if(!Objects.equals(ref.customerId(),row.customerId()))throw conflict("原预约候选客户与实物归属不一致"); + references.add(new BatchReference(ref.batchKey(),ref.batchNo(),ref.customerId(),ref.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),ref.recordVersion())); + } + } + result.add(new DispatchPlanningModels.Candidate(selection,key,version,row.customerId(),row.customerName(),row.overseasWarehouseId(), + row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(), + destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons))); + } + return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true); + } + + /** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchWorkModels.Items items(Actor actor,String number) { + requirePlatform(actor);var row=required(actor,number,false); + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var gate=actions(actor,row).get(Action.REMOVE_CARGO);var result=new ArrayList(); + for(var item:repository.cargo(row.getId())) { + boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType()); + var remove=relabel?new Gate(false,"RELABEL_WHOLE_SOURCE","换标按整单排车,请取消整单后重排"):gate; + if(picked.getOrDefault(item.getId(),0L)>0)remove=new Gate(false,"ALREADY_PICKED","本来源已经真实扫码"); + result.add(new DispatchWorkModels.CargoItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getSourceVersion(), + relabel?SourceType.RELABEL:item.getReturnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN,item.getPlanPalletId(),item.getReturnReceiptId(), + relabel?item.getSourceBusinessNo():null,item.getWarehousePalletId(),item.getPalletNo(),item.getOrderNo(),item.getInboundCode(),destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),remove)); + } + return new DispatchWorkModels.Items(number,row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),List.copyOf(result)); + } + + /** 路线返回真实资源记录中的顺序,索引可直接对应装载卸货站。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchWorkModels.Route route(Actor actor,String number) { + requirePlatform(actor);var row=required(actor,number,false);var actual=fleet.dispatchStops(number);var stops=new ArrayList(); + for(int index=0;index changed=addResolved(row,actor,request.cargo(),request.batchAllocations()); + row=repository.find(number); + history(row,actor,Action.CREATE,request.remark()); + var result=result(row,identity,new DispatchResults.Saved(number,changed)); + commands.append(id,actor,identity,result); + return result; + } + + /** 先trip→资源→dispatch,原命令恢复后才判断当前版本。 */ + @Transactional(isolation = Isolation.READ_COMMITTED) + public CommandResult addCargo(Actor actor, String number, DispatchCommands.AddCargo request) { + requirePlatform(actor); + var shell=fleet.lockDispatchShell(number); + var row=required(actor,number,true); + if (!Objects.equals(row.getTripId(),shell.tripId())) throw conflict("整车资源关系已变化"); + var identity=new CommandIdentity(Action.ADD_CARGO,request.idempotencyKey(),request.recordVersion(), + hashes.command(actor,Action.ADD_CARGO,number,request.recordVersion(),request)); + var replay=commands.replay(actor,number,identity); + if (replay.isPresent()) return replay.get(); + requireEditing(row,request.recordVersion()); + appointments.lock(repository.batches(row.getId()),request.batchAllocations()); + if (request.cargo()==null || request.cargo().isEmpty()) throw bad("请选择需要加入的真实货物"); + var changed=addResolved(row,actor,request.cargo(),request.batchAllocations()); + row=repository.find(number); history(row,actor,Action.ADD_CARGO,null); + var result=result(row,identity,new DispatchResults.Saved(number,changed)); + commands.append(row.getId(),actor,identity,result); + return result; + } + + /** 移除真实未执行明细,来源占用与货量更新一起提交,保留既有整单换标边界。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult removeCargo(Actor actor,String number,long itemId,DispatchCommands.ReasonCommand request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.REMOVE_CARGO,request.recordVersion(),request.idempotencyKey(),Arrays.asList(itemId,request)); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); + if(repository.realPicked(row.getId())!=0)throw conflict("本车已经真实扫码,不能移除货物"); + var all=repository.cargo(row.getId());var selected=all.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"排车货物明细不存在")); + if("RELABEL_ORDER".equals(selected.getSourceBusinessType()))throw conflict("换标按整单排车,请取消整单后重排"); + appointments.lock(repository.batches(row.getId()),List.of()); + appointments.remove(row.getId(),List.of(selected)); + if(sources.release(row.getId(),selected,now())<1)throw conflict("原来源占用已变化,未移除货物"); + if(repository.removeItem(itemId)!=1)throw conflict("货物明细已变化"); + refreshCargo(row,all.stream().filter(i->!Objects.equals(i.getId(),itemId)).toList());row=repository.find(number); + history(row,actor,Action.REMOVE_CARGO,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,List.of(itemId))); + commands.append(row.getId(),actor,identity,result);return result; + } + + /** 零扫码取消协调主单、原来源与资源,取消保留在正交status而不增加第六阶段。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult cancel(Actor actor,String number,DispatchCommands.ReasonCommand request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); + if(trim(request.reason())==null)throw bad("取消原因不能为空"); + if(repository.realPicked(row.getId())!=0 || !repository.outbounds(row.getId()).isEmpty())throw conflict("本车已有真实执行或关联出库,不能取消"); + var items=repository.cargo(row.getId());appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); + for(var item:items)if(sources.release(row.getId(),item,now())<1)throw conflict("原来源占用已变化,未取消排车"); + fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消"); + return saveCommand(row,actor,identity,request.reason()); + } + + /** 旧新资源身份一次锁齐,编辑关键依据后两级审核均失效,普通备注不篡改旧审核。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult update(Actor actor,String number,DispatchCommands.Update request) { + requirePlatform(actor); + fleet.lockDispatchShell(number,request.vehicle()==null?null:request.vehicle().vehicleId(),request.driver()==null?null:request.driver().driverId()); + var row=required(actor,number,true);var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + if(request.vehicleAppointment()!=null)throw conflict("车辆预约关联需按权威预约重新核验"); + String before=basis(row); + var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); + row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); + row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); + if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); + fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark()); + return saveCommand(row,actor,identity,request.remark()); + } + + /** 提交新轮次审核,不把已通过调度审核复用到更改后的货物或资源。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult submitReview(Actor actor,String number,DispatchCommands.SubmitReview request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); + verifyReviewResources(row); + if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); + long round=row.getReviewRound()+1;String basis=basis(row); + row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); + row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null))); + row.setLoadingReviewJson(write(pending(round))); + return saveCommand(row,actor,identity,request.remark()); + } + + /** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) { + var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW; + var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + if(request.decision()==null)throw bad("请选择审核结论"); + String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name(); + if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化"); + if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空"); + if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交"); + if(loading) { + var dispatch=reviews(row).dispatchReview(); + if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效"); + } + if(request.decision()==Decision.APPROVED)verifyReviewResources(row); + var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null); + if(loading) { + row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); + if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name()); + } else { + row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); + row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound()))); + } + return saveCommand(row,actor,identity,request.reason()); + } + + /** 只允许零真实扫码退回原单,保留编号并明确作废两级审核。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult returnToReview(Actor actor,String number,DispatchCommands.ReturnToReview request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.RETURN_TO_REVIEW,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + if(row.getRecordVersion().longValue()!=request.recordVersion() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()))throw conflict("仅当前版本装车中的有效单可退回重审"); + if(trim(request.reason())==null)throw bad("退回原因不能为空"); + if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前"); + row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason()); + return saveCommand(row,actor,identity,request.reason()); + } + + /** 派送域只读取本车真实已拣归属;缺失归属不会由计划补齐。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Context preview(Actor actor,String number) { return context(actor,required(actor,number,false)); } + + /** 原命令恢复前仅取根身份锁,不提前拒绝旧版本或已经发车的阶段。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public Context lockForArrangement(Actor actor,String number) { + required(actor,number,false);fleet.lockDispatchShell(number);return context(actor,required(actor,number,true)); + } + + /** 当前范围复验后只恢复原主体与严格摘要相同的命令。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public Optional replay(Context context,Actor actor,CommandIdentity identity) { + required(actor,context.dispatchNo(),false);return commands.replay(actor,context.dispatchNo(),identity); + } + + /** 新安排只能使用装车后的完整真实来源。 */ + @Override public void requireFreshArrangement(Context context,long version) { + if(context.recordVersion()!=version || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING + || !context.pickingReconciled() || context.loadingCompletionHash()==null || context.loadingCompletedAt()==null) + throw conflict("本车尚未完成真实拣货及装车,或当前版本已变化"); + } + + /** 子单及费用保存后,同一事务以主单CAS和全局账本确认原子结果。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public CommandResult recordArrangement(Context context,Actor actor,CommandIdentity identity,DispatchDeliveryModels.ArrangementFact fact,boolean existing) { + var row=required(actor,context.dispatchNo(),true); + if(row.getRecordVersion()!=identity.recordVersion() || !Objects.equals(row.getCargoHash(),fact.cargoHash()) + || !Objects.equals(row.getLoadingCompletionHash(),fact.loadingCompletionHash()) || fact.result()==null)throw conflict("本车安排依据已变化"); + if(existing) { + if(!Objects.equals(row.getArrangementKey(),fact.arrangementKey()) || !Objects.equals(row.getArrangementBusinessHash(),fact.arrangementBusinessHash()))throw conflict("原派送安排依据不一致"); + } else { + requireFreshArrangement(context,identity.recordVersion());if(row.getArrangementKey()!=null)throw conflict("本车已经安排派送"); + row.setArrangementKey(fact.arrangementKey());row.setArrangementBusinessHash(fact.arrangementBusinessHash()); + } + long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(now()); + if(repository.saveState(row,version)!=1)throw conflict("排车版本已变化");history(row,actor,Action.ARRANGE_DELIVERY,null); + var result=new CommandResult(row.getDispatchNo(),Action.ARRANGE_DELIVERY,identity.idempotencyKey(),row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()), + Instant.now().truncatedTo(ChronoUnit.MILLIS),existing?Effect.EXISTING:Effect.APPLIED,counts(row),fact.result(),false,existing); + commands.append(row.getId(),actor,identity,result);return result; + } + + /** 两端读取同一实载清单;已知实测自动带出,建议值不能填入实际字段。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchPlanningModels.LoadingPreview loading(Actor actor,String number) { + var row=required(actor,number,false);var context=context(actor,row);var items=repository.cargo(row.getId()); + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var measured=executionReads.actualMeasurements(row.getId()).stream().collect(Collectors.groupingBy(p->p.palletId())); + var stops=fleet.dispatchStops(number);var pallets=new ArrayList();var missing=new ArrayList(); + var groups=items.stream().filter(i->i.getWarehousePalletId()!=null).collect(Collectors.groupingBy(DispatchItem::getWarehousePalletId,TreeMap::new,Collectors.toList())); + for(var entry:groups.entrySet()) { + var group=entry.getValue();var first=group.get(0);String destination=destination(first).key(); + if(group.stream().anyMatch(i->!Objects.equals(destination,destination(i).key())))throw conflict("同一实托的目的地不一致,请先核对来源"); + Integer index=null;for(int n=1;n(); + var facts=measured.getOrDefault(entry.getKey(),List.of()); + if(facts.size()==1) { + var fact=facts.get(0);weight=fact.weightKg();volume=fact.volumeCbm(); + if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true)); + if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true)); + } + var absent=new ArrayList();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex"); + missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList()); + pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(), + group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof))); + } + if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源"); + String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets)); + var gate=actions(actor,row).get(Action.LOADING_COMPLETE); + if(!context.pickingReconciled() || groups.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))gate=new Gate(false,"PICKING_INCOMPLETE","本车真实拣货和逐项来源尚未核平"); + return new DispatchPlanningModels.LoadingPreview(number,row.getRecordVersion(),context.status(),context.stage(),hash,context.pickingReconciled(),context.resources(), + List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing)); + } + + /** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) { + required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true); + var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车"); + var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); + if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); + if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量"); + var supplied=new HashMap(); + for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复"); + for(var p:preview.pallets()) { + var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex() + || p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对"); + } + verifyReviewResources(row);var context=context(actor,row); + var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList(); + var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList(); + fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark()); + var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at); + row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name()); + return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed)); + } + + /** 当前明细、实际扫码、真实归属及批次组合为唯一整车上下文。 */ + /** Fleet附加能力只读执行事实;调用方已按trip→资源→主单顺序持父锁。 */ + @Transactional(propagation=Propagation.MANDATORY) + public Context fleetContext(String number,boolean lock) { + var row=lock?repository.lock(number):repository.find(number); + if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + return context(null,row); + } + private Context context(Actor actor,DispatchAggregate row) { + var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化"); + var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); + var batches=repository.batches(row.getId());var cargo=new ArrayList(); + var remaining=new LinkedHashMap();batches.forEach(b->remaining.put(b,(long)b.packageCount())); + for(var pick:executionReads.sources(row.getId())) { + var item=byId.get(pick.itemId());String source=pick.returnCartonId()!=null?"RETURN_CARTON:"+pick.returnCartonId():pick.returnReceiptId()!=null?"RETURN:"+pick.returnReceiptId():"ORDER:"+pick.orderId(); + var allocations=new ArrayList();long unassigned=pick.packages(); + for(var batch:batches)if(Objects.equals(batch.dispatchItemId(),item.getId()) && Objects.equals(batch.customerId(),item.getCustomerId()) && Objects.equals(batch.sourceKey(),source) && unassigned>0) { + long allocated=Math.min(unassigned,remaining.get(batch));if(allocated<=0)continue; + allocations.add(new BatchAllocation(batch.planPalletId(),batch.batchKey(),batch.customerId(),batch.sourceKey(),batch.destinationKey(),Math.toIntExact(allocated),batch.recordVersion())); + unassigned-=allocated;remaining.put(batch,remaining.get(batch)-allocated); + } + cargo.add(new CargoLine(item.getId(),pick.customerId(),item.getCustomerName(),pick.orderId(),pick.orderNo(),source,item.getSourceStage(),pick.sourceRegion(),pick.sourceInboundId(),pick.inboundCode(), + item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),pick.cartonMark(), + "RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations)); + } + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())) + && executionReads.inconsistentPicks(row.getId())==0 && cargo.stream().mapToLong(CargoLine::packages).sum()==n(row.getPackageCount()); + return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),identity.tripNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(), + Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),row.getLoadingCompletionHash(),instant(row.getLoadingCompletedAt()),instant(row.getDepartedAt()), + read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),complete,List.copyOf(cargo)); + } + + /** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */ + private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); } + /** 严格请求身份不接收前端摘要或操作者。 */ + private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));} + /** 每次状态变更、历史及不可变命令结果同事务保存。 */ + private CommandResult saveCommand(DispatchAggregate row,Actor actor,CommandIdentity identity,String reason) { + return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(row.getDispatchNo(),List.of())); + } + /** 公开结果类型按动作保存,不把Loaded等恢复成普通保存结果。 */ + private CommandResult saveCommand(DispatchAggregate row,Actor actor,CommandIdentity identity,String reason,DispatchResults.Payload payload) { + long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(now()); + if(repository.saveState(row,version)!=1)throw conflict("排车版本已变化,请重新核对"); + history(row,actor,identity.action(),reason);var result=result(row,identity,payload); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */ + private String basis(DispatchAggregate row) { + var facts=new TreeMap();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); + facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts); + } + /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ + private void invalidate(DispatchAggregate row,String reason) { + row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); + var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason); + row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid)); + } + /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ + void verifyReviewResources(DispatchAggregate row) { + var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); + if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话"); + var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积"); + var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间"); + if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期"); + if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds()); + if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds()); + var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()), + new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); + if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); + } + /** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */ + private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List ids) { + if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请补齐本车有效的车辆及司机证据"); + for(var id:ids)evidence.metadata(dispatch,kind,id); + } + + /** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.Page page(Actor actor, DispatchViews.Query query) { + requirePlatform(actor); + long page=query.page()==0 ? 1 : query.page(),size=query.size()==0 ? 20 : query.size(); + if(page<1 || size<1 || size>200 || page>1_000_000) throw bad("分页范围不合法"); + StatusFilter status=query.status()==null ? StatusFilter.ACTIVE : query.status(); + if(query.beforeLoadingSection()!=null && (status!=StatusFilter.ACTIVE || query.stage()!=Stage.BEFORE_LOADING)) throw bad("审核分区仅适用于装车前有效单据"); + if(query.reviewStatus()!=null && (query.beforeLoadingSection()==null || query.beforeLoadingSection()==BeforeLoadingSection.VEHICLE_INFO)) throw bad("请先选择对应审核分区"); + var filter=new DispatchAggregateMapper.Search(trim(query.dispatchNo()),trim(query.vehiclePlate()),trim(query.warehouseId()), + trim(query.customerId()),trim(query.fbaAppointmentNo()),status.name(),query.stage()==null?null:query.stage().name(), + query.beforeLoadingSection()==null?null:query.beforeLoadingSection().name(),query.reviewStatus()==null?null:query.reviewStatus().name(),(page-1)*size,(int)size); + var rows=repository.page(filter); long count=repository.count(filter); + return new DispatchViews.Page(rows.stream().map(r -> listRow(actor,r)).toList(),count,page,size,(count+size-1)/size, + statistics(repository.summaries(filter)),Instant.now()); + } + + /** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.Detail detail(Actor actor, String number) { + var row=required(actor,number,false); var cargo=repository.cargo(row.getId()); + var resources=read(row.getResourcesJson(),Resources.class); + var batches=repository.batches(row.getId()); + var references=new LinkedHashMap>(); + batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b)); + var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(), + group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList(); + var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap( + b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream() + .map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList(); + var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(), + d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), + d.status(),d.packageCount(),d.palletCount())).toList(); + var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); + return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), + new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,null,batchRefs), + documents,orderDetails(cargo)); + } + + /** 恢复动作结果前重新核验当前单据范围和原动作权限。 */ + @Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED) + public CommandResult command(Actor actor,String number,String key) { + required(actor,number,false); + var allowed=EnumSet.noneOf(Action.class); + for(var action:Action.values()) if(hasPermission(actor,action)) allowed.add(action); + return commands.readOriginal(actor,number,key,allowed); + } + + /** 全部客户来源和占用同事务,任一失败不留下部分整车。 */ + private List addResolved(DispatchAggregate row,Actor actor,List selections,List batchAllocations) { + if(selections==null || selections.isEmpty()) { + if(batchAllocations!=null && !batchAllocations.isEmpty())throw bad("没有真实货物时不能提交预约分配"); + return List.of(); + } + var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet()); + var physical=current.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toSet()); + var resolved=sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections); + var saved=new ArrayList(); + for(var source:resolved) { + if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入"); + var c=source.candidate(); + if(c.warehousePalletId()!=null && !physical.add(c.warehousePalletId()))throw conflict("同一实托不能作为不同来源重复计入本车,请核对真实来源组成"); + var at=now(); var claims=sources.claims(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at); + if(claims.stream().anyMatch(lock -> !Objects.equals(lock.dispatchId(),row.getId()))) throw conflict("所选托盘已被其他有效运输安排占用"); + if(claims.isEmpty()) { + sources.expire(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at); + try { if(sources.claim(IdWorker.getId(),c.customerId(),c.sourcePalletKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),row.getId(),actor.operatorId(),at)!=1) throw conflict("来源占用未保存"); } + catch(DuplicateKeyException error) { throw conflict("所选实托已被并发排车占用,请重新核对"); } + } + var item=item(row,source); if(repository.insertItem(item)!=1) throw conflict("排车明细未保存"); + saved.add(item); + } + sourceRules.bindWholeRelabel(resolved,saved); + appointments.bind(row.getId(),saved,batchAllocations); + var all=new ArrayList<>(current); all.addAll(saved); + refreshCargo(row,all);return saved.stream().map(DispatchItem::getId).toList(); + } + + /** 加货和移货共享同一货量、摘要、审核失效与Fleet容量刷新。 */ + private void refreshCargo(DispatchAggregate row,List all) { + int packages=0; + try { for(var item:all) packages=Math.addExact(packages,item.getPackageCount()); } + catch(ArithmeticException error) { throw bad("本车箱数超过可支持范围"); } + int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count(); + String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")); + var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList(); + if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物"); + var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key())) + .collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops); + } + + /** 冻结真实来源和客户,候选订单号只用于展示,不替代后续逐来源拣货归属。 */ + private DispatchItem item(DispatchAggregate row,DispatchService.WholeSource source) { + var c=source.candidate(); var i=new DispatchItem(); i.setId(IdWorker.getId()); i.setDispatchOrderId(row.getId()); + i.setCustomerId(c.customerId()); i.setCustomerName(c.customerName()); i.setSourceKey(source.sourceKey()); i.setSourceVersion(source.sourceVersion()); i.setSourceSnapshotJson(write(new SourceDisplay(c.containerNo(),c.planNo()))); + i.setPlanId(c.planId()); i.setPlanPalletId(c.planPalletId()); i.setReturnReceiptId(c.returnReceiptId()); i.setWarehousePalletId(c.warehousePalletId()); + i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setOrderRefs(c.orderRefs()); i.setInboundCode(c.inboundCode()); i.setPalletNo(c.palletNo()); + i.setOverseasWarehouseId(c.overseasWarehouseId()); i.setOverseasWarehouseName(c.overseasWarehouseName()); + i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode()); i.setDestinationWarehouseName(c.destinationWarehouseName()); + i.setFbaWarehouseCode(c.fbaWarehouseCode()); i.setDestinationType(c.destinationType()); i.setDeliveryAddress(c.deliveryAddress()); i.setPostalCode(c.postalCode()); i.setContactName(c.contactName()); i.setContactPhone(c.contactPhone()); + i.setPackageCount(c.packageCount()); i.setGrossWeightKg(c.grossWeightKg()); i.setVolumeWeightKg(c.volumeWeightKg()); i.setVolumeCbm(c.volumeCbm()); + i.setSourceStage(source.selection().sourceType()==SourceType.RELABEL ? c.returnCartonSource()?"RETURN_RELABEL":"RELABEL" : c.sourceStage()); + i.setCartonMarks(c.relabelCartonMarks()==null?c.cartonMarks():c.relabelCartonMarks()); + if(source.selection().relabelNo()!=null) { i.setSourceBusinessType("RELABEL_ORDER"); i.setSourceBusinessNo(source.selection().relabelNo()); } + i.setCreatedAt(now()); return i; + } + + + /** 读取后验证PDA绑定仓;平台整车读取不按客户头部裁切。 */ + private DispatchAggregate required(Actor actor,String number,boolean lock) { + if(actor==null || actor.operatorId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"请先登录"); + var row=lock?repository.lock(number):repository.find(number); + if(row==null) throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),row.getOverseasWarehouseId())) throw new BizException(ResultCode.FORBIDDEN,"排车单不属于当前绑定仓"); + return row; + } + + /** 普通变更不允许覆盖已执行的真实库存或旧版本。 */ + private static void requireEditing(DispatchAggregate row,long version) { + if(row.getRecordVersion()!=version) throw conflict("排车版本已变化,请重新核对"); + if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物"); + } + + /** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */ + Map actions(Actor actor,DispatchAggregate row) { + var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); + Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); + for(var action:Action.values()) { + boolean state=switch(action) { + case CREATE -> actor.mode()==ActorMode.PLATFORM; + case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING; + case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING; + case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0; + case PICK -> active && stage==Stage.LOADING && review==ReviewState.APPROVED; + case LOADING_COMPLETE -> active && stage==Stage.LOADING && reconciled(row); + case ARRANGE_DELIVERY -> active && stage==Stage.AFTER_LOADING; + case DEPART -> active && stage==Stage.AFTER_LOADING && row.getArrangementKey()!=null; + }; + boolean permitted=hasPermission(actor,action); + gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件")); + } + return Collections.unmodifiableMap(gates); + } + + /** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */ + private static boolean hasPermission(Actor actor,Action action) { + var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false; + if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action) + && auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority())); + if(action==Action.PICK) return false; + String permission=switch(action) { + case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel"; + case DISPATCH_REVIEW -> "platform:fleet:trip:dispatch-review"; case LOADING_REVIEW -> "platform:fleet:trip:loading-review"; + case LOADING_COMPLETE -> PREFIX+"loading-complete"; case DEPART -> PREFIX+"depart"; + case ARRANGE_DELIVERY -> PREFIX+"transfer-delivery"; default -> PREFIX+"edit"; + }; + return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority())); + } + + /** 统计按真整车去重并补齐全部零项。 */ + private static DispatchViews.Statistics statistics(List groups) { + var stages=new EnumMap(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L); + long active=0,cancelled=0,before=0; long[] dispatch=new long[4],loading=new long[4]; + for(var group:groups) { + if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; } + active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum); + if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState()); + dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity(); + loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); } + } + return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before, + new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3]))); + } + + /** 列表不逐行读取明细、附件或派送。 */ + private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) { + return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row)); + } + + /** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */ + private List orderDetails(List cargo) { + var result=new ArrayList(); + for(var item:cargo) { + var source=read(item.getSourceSnapshotJson(),SourceDisplay.class); + var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList(); + if(orders.isEmpty()) orders=Collections.singletonList(null); + for(var order:orders) result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),order,item.getInboundCode(),orders.size()==1?item.getGrossWeightKg():null, + orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey())); + } + return List.copyOf(result); + } + + /** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */ + private static Resources resources(FleetExecutionModels.DispatchShell shell) { + var v=shell.vehicle(); var d=shell.driver(); + return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(), + new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(), + new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt()))); + } + + /** 主档选择不接受请求覆盖其车牌和容量。 */ + private static FleetExecutionModels.DispatchVehicle fleetVehicle(DispatchCommands.Vehicle v) { + if(v==null) return null; if(v.mode()==null) throw bad("请选择车辆来源方式"); + return new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),v.capacity()==null?null:v.capacity().palletSpaces(), + v.capacity()==null?null:v.capacity().maxLoadKg(),v.capacity()==null?null:v.capacity().maxVolumeCbm(),v.evidenceFileIds()==null?List.of():List.copyOf(v.evidenceFileIds())); + } + + /** 司机证据是本趟输入,主档事实仍由Fleet提供者核验。 */ + private static FleetExecutionModels.DispatchDriver fleetDriver(DispatchCommands.Driver d) { + if(d==null) return null; if(d.mode()==null) throw bad("请选择司机来源方式"); var e=d.evidence(); + return new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e==null?null:e.licenseNo(),e==null?null:e.licenseExpiresOn(), + e==null||e.fileIds()==null?List.of():List.copyOf(e.fileIds()),e==null?null:e.expectedTaskEndAt()); + } + + /** 只允许平台发起整车写命令。 */ + private static void requirePlatform(Actor actor) { if(actor==null || actor.mode()!=ActorMode.PLATFORM || actor.operatorId()==null) throw new BizException(ResultCode.FORBIDDEN,"仅平台可安排整车作业"); } + /** 保存动作时不可变结果。 */ + private static CommandResult result(DispatchAggregate row,CommandIdentity identity,DispatchResults.Payload payload) { + return new CommandResult(row.getDispatchNo(),identity.action(),identity.idempotencyKey(),row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),Instant.now().truncatedTo(ChronoUnit.MILLIS),Effect.APPLIED,counts(row),payload,false,false); + } + /** 追加原时刻主单快照和操作证据。 */ + private void history(DispatchAggregate row,Actor actor,Action action,String reason) { + if(repository.history(IdWorker.getId(),row,action.name(),actor.operatorId(),reason,write(row),now())!=1) throw conflict("排车操作历史未保存"); + } + /** 当前审核记录,不以版本相等代替轮次有效性。 */ + Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); } + /** 未提交的审核不伪造待审核。 */ + private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); } + /** 整车真实统计。 */ + private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); } + /** 空单永远不能核平为装车完成。 */ + private static boolean reconciled(DispatchAggregate row) { return n(row.getPackageCount())>0 && Objects.equals(row.getPackageCount(),row.getPickedPackageCount()); } + /** 目的地业务标签仅来源于真实目的仓或实际地址。 */ + private String destinationLabel(DispatchItem i) { return "PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():trim(i.getDestinationWarehouseCode())==null?i.getFbaWarehouseCode():i.getDestinationWarehouseCode(); } + /** 目的地稳定业务键复用既有地址规范,不信任用户指定路线。 */ + Destination destination(DispatchItem i) { return new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),i.getDestinationType(),i.getDestinationWarehouseId(),i.getDestinationWarehouseCode(),i.getDestinationWarehouseName(),i.getDeliveryAddress(),i.getPostalCode(),i.getContactName(),i.getContactPhone()); } + /** 严格写入领域快照,失败回滚而不吞字段。 */ + private String write(Object value) { try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("排车快照保存失败",error);} } + /** 严格读取本版快照,不提供旧字段降级解析。 */ + private T read(String value,Class type) { try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("排车快照读取失败",error);} } + /** 数据库实际时间统一保存UTC毫秒。 */ + private static LocalDateTime now() { return utc(Instant.now().truncatedTo(ChronoUnit.MILLIS)); } + /** 可空计划不填假时间。 */ + private static LocalDateTime utc(Instant time) { return time==null?null:LocalDateTime.ofInstant(time,ZoneOffset.UTC); } + /** 读取UTC字段不使用操作者本地时区。 */ + private static Instant instant(LocalDateTime time) { return time==null?null:time.toInstant(ZoneOffset.UTC); } + /** 空统计只作零处理,不用于业务编号回退。 */ + private static int n(Integer value) { return value==null?0:value; } + /** 查询空字符串收敛为无筛选。 */ + private static String trim(String value) { return value==null||value.isBlank()?null:value.trim(); } + /** 明确参数错误。 */ + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST,message); } + /** 明确版本/状态/事实竞争失败。 */ + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java new file mode 100644 index 000000000..158c648d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.BatchAllocation; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.util.*; + +/** 预约提供者薄合同适配,客户归属只从已持久化真实明细取得。 */ +@Service +@Transactional(propagation=Propagation.MANDATORY) +public class DispatchAppointmentAllocations { + private final AppointmentWorkDispatchPort provider; + public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;} + + /** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */ + public Map candidates(List rows) { + var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList(); + if(identities.isEmpty())return Map.of(); + var result=provider.candidateBatches(identities); + if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"原预约候选批读范围不完整,请重新核对"); + return result; + } + + /** 当前保留分配与全部请求合并后一次预锁;同批混版本或缺版本不能绕过。 */ + public void lock(List retained,List requested) { + var versions=new TreeMap(); + if(retained!=null)for(var row:retained)if(row.batchKey()!=null && row.batchKey().startsWith("FBA_APPOINTMENT:"))add(versions,row.batchKey(),row.recordVersion()); + if(requested!=null)for(var row:requested) { + if(row==null || row.batchKey()==null || !row.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*"))throw bad("只能选择原权威FBA预约批次,客户仓引用由服务器生成"); + add(versions,row.batchKey(),row.recordVersion()); + } + provider.lockBatches(Collections.unmodifiableMap(versions)); + } + /** 草稿在来源锁之前锁齐所有车辆分组的全部预约,不能在第二组才新增批次锁。 */ + public void lockCreates(List requests) { + var allocations=new ArrayList(); + for(var request:requests)if(request.batchAllocations()!=null)allocations.addAll(request.batchAllocations()); + lock(List.of(),allocations); + } + /** Provider只增量替换touched;不将同车其他客户原分配删掉。 */ + public void bind(long dispatch,List touched,List requested) { + var items=touched.stream().map(i->new AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList(); + var allocations=requested==null?List.of():requested.stream().map(a-> + new AppointmentWorkDispatchPort.Allocation(a.planPalletId(),a.batchKey(),a.recordVersion(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount())).toList(); + provider.bindItems(dispatch,items,allocations); + } + /** 调用方已锁齐全车原批次且核完整实物,删除只作用于本车指定明细。 */ + public void remove(long dispatch,List items){for(var item:items)provider.removeItem(dispatch,item.getId());} + private static void add(Map versions,String key,Long version) { + if(version==null || version<0)throw bad("预约批次recordVersion必填且不能小于零"); + var existing=versions.putIfAbsent(key,version); + if(existing!=null && !existing.equals(version))throw new BizException(ResultCode.CONFLICT,"同一预约批次版本不一致,请重新核对原批次"); + } + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index 4ec0823cd..136dcf34a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -4,7 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; import feign.FeignException; import jakarta.annotation.Resource; import java.math.BigDecimal; @@ -27,13 +27,13 @@ public class DispatchBolPrintService { private static final int CONTINUATION_PAGE_ROWS = 26; private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297")); - @Resource private DispatchService dispatches; + @Resource private DispatchBolReadService dispatches; @Resource private PrintPdfClient print; @Resource private DomesticWarehouseDirectoryClient warehouses; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public GeneratedPdf generate(String dispatchNo, String requestId) { - DispatchResponse detail = dispatches.platformDetail(dispatchNo); + DispatchBolData detail = dispatches.read(dispatchNo); if (detail.bolLines() == null || detail.bolLines().isEmpty()) { throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细"); } @@ -54,16 +54,16 @@ public class DispatchBolPrintService { } } - private List documents(DispatchResponse detail) { + private List documents(DispatchBolData detail) { String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); - Map> groups = new LinkedHashMap<>(); - for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line); + Map> groups = new LinkedHashMap<>(); + for (DispatchBolData.Line line : detail.bolLines()) groups.computeIfAbsent(line.groupKey(), ignored -> new ArrayList<>()).add(line); List documents = new ArrayList<>(); - for (Map.Entry> group : groups.entrySet()) { - List lines = group.getValue(); + for (Map.Entry> group : groups.entrySet()) { + List lines = group.getValue(); int pages = pageCount(lines.size()); - int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum(); - String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); + int total = lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum(); + String destination = lines.stream().map(DispatchBolData.Line::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); int offset = 0; for (int page = 0; page < pages; page++) { int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS; @@ -72,8 +72,8 @@ public class DispatchBolPrintService { .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), - text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", - group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), + text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", + text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), total, page + 1, pages, rows)); offset = end; } @@ -128,9 +128,9 @@ public class DispatchBolPrintService { return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS; } - private String unique(List lines, boolean shipment) { + private String unique(List lines, boolean shipment) { LinkedHashSet values = new LinkedHashSet<>(); - for (DispatchResponse.BolLine line : lines) { + for (DispatchBolData.Line line : lines) { String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo(); if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java new file mode 100644 index 000000000..00c2c2d28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.service; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; +import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; +/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */ +@Service +public class DispatchBolReadService { + private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json; + public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;} + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchBolData read(String number) { + var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);} + var cargo=lines.findWholeDispatch(row.getId()); + if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实"); + var confirmations=new LinkedHashMap>(); + for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo()); + var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue()))); + return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(), + row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java new file mode 100644 index 000000000..29c7db77c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels; +import com.baiyue.lastleg.dispatch.port.DispatchResults; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +/** 严格按已持久化Action恢复结果,不依赖进程内类型缓存或默认多态。 */ +@Component +public final class DispatchCommandCodec { + private final ObjectMapper json; + + /** 保留正式时间/Long配置,并防止中间树将资金或实测小数转为double丢精度。 */ + public DispatchCommandCodec(ObjectMapper json) { + this.json = json.copy().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .setNodeFactory(JsonNodeFactory.withExactBigDecimals(true)); + } + + /** 原动作与结果类型必须匹配后才能写入不可变账本。 */ + public String encode(DispatchContracts.CommandResult result) { + if (result == null || result.action() == null || result.result() == null + || !payloadType(result.action()).isInstance(result.result())) { + throw new IllegalArgumentException("排车动作与命令结果类型不匹配"); + } + try { return json.writeValueAsString(result); } + catch (JsonProcessingException error) { throw new IllegalStateException("排车命令结果保存失败", error); } + } + + /** 从数据库result_json解码,禁止猜测历史类型或回退为空成功结果。 */ + public DispatchContracts.CommandResult decode(String stored) { + try { + JsonNode root = json.readTree(stored); + DispatchTypes.Action action = read(root, "action", DispatchTypes.Action.class); + Long version = read(root, "recordVersion", Long.class); + if (version < 0) throw new IllegalArgumentException("命令结果版本不得为负"); + JsonNode payload = required(root, "result"); + for (String field : mandatoryPayloadFields(action)) required(payload, field); + var result = new DispatchContracts.CommandResult( + read(root, "dispatchNo", String.class), action, + read(root, "idempotencyKey", String.class), version, + read(root, "status", DispatchTypes.Status.class), read(root, "stage", DispatchTypes.Stage.class), + read(root, "occurredAt", Instant.class), read(root, "effect", DispatchTypes.Effect.class), + read(root, "counts", DispatchContracts.Counts.class), + json.treeToValue(payload, payloadType(action)), + read(root, "replayed", Boolean.class), read(root, "alreadyArranged", Boolean.class)); + return result; + } catch (JsonProcessingException | IllegalArgumentException error) { + throw new IllegalStateException("排车命令账本结果损坏,不能恢复为成功", error); + } + } + + /** 原结果仅改变重放标记,原版本、阶段、单据与事实时间均保持。 */ + public DispatchContracts.CommandResult replay(String stored) { + var original = decode(stored); + return new DispatchContracts.CommandResult(original.dispatchNo(), original.action(), original.idempotencyKey(), + original.recordVersion(), original.status(), original.stage(), original.occurredAt(), original.effect(), + original.counts(), original.result(), true, original.alreadyArranged()); + } + + /** 封闭动作集合决定具体类型,不启用任意类名反序列化。 */ + private static Class payloadType(DispatchTypes.Action action) { + return switch (action) { + case PICK -> DispatchResults.Picked.class; + case LOADING_COMPLETE -> DispatchResults.Loaded.class; + case DEPART -> DispatchResults.Departed.class; + case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class; + case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, + DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class; + }; + } + + /** 防止将其他动作JSON缺字段默认为零或null后冒充有效结果。 */ + private static String[] mandatoryPayloadFields(DispatchTypes.Action action) { + return switch (action) { + case PICK -> new String[] { "dispatchItemId", "packages", "pickedPackages", "pickingReconciled" }; + case LOADING_COMPLETE -> new String[] { "loadingCompletionHash", "loadingCompletedAt", "confirmedLoad" }; + case DEPART -> new String[] { "departedAt", "shippedPackages", "outbounds", "deliveries" }; + case ARRANGE_DELIVERY -> new String[] { "dispatchNo", "recordVersion", "arrangementKey", "cargoHash", "arrangedAt", "outbounds", "deliveries" }; + default -> new String[] { "dispatchNo" }; + }; + } + + /** 账本缺少字段即失败,不将缺少布尔或数值解释为默认值。 */ + private static JsonNode required(JsonNode root, String field) { + if (root == null || !root.isObject() || !root.hasNonNull(field)) + throw new IllegalArgumentException("排车命令结果缺少字段:" + field); + return root.get(field); + } + + /** 使用正式ObjectMapper解析对应显式类型。 */ + private T read(JsonNode root, String field, Class type) throws JsonProcessingException { + return json.treeToValue(required(root, field), type); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java new file mode 100644 index 000000000..baa4ab651 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; + +/** 命令身份与派送业务分别取摘要,禁止换主体或换版本冒充原请求。 */ +@Component +public final class DispatchCommandHasher { + private static final Set UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services", + "batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds"); + private final ObjectMapper json; + + /** 复用已注册的时间、数值和公开字段序列化配置。 */ + public DispatchCommandHasher(ObjectMapper json) { this.json = json; } + + /** 严格摘要绑定可信主体、动作、业务号及原版本,渠道不改变命令身份。 */ + public String command(DispatchContracts.Actor actor, DispatchTypes.Action action, String dispatchNo, + Long recordVersion, Object request) { + var root = json.createObjectNode(); + root.put("operatorId", actor.operatorId()); + root.put("action", action.name()); + root.put("dispatchNo", dispatchNo); + if (recordVersion == null) root.putNull("recordVersion"); + else root.put("recordVersion", recordVersion); + JsonNode payload = json.valueToTree(request); + if (payload.isObject()) ((com.fasterxml.jackson.databind.node.ObjectNode) payload).remove("idempotencyKey"); + root.set("payload", payload); + return sha256(canonical(root, "")); + } + + /** 同车安排只按业务证据去重,不受操作者、请求键或纯资格版本影响。 */ + public String arrangement(String dispatchNo, DispatchDeliveryModels.ArrangeDelivery request, + DispatchContracts.Resources resources) { + var root = json.createObjectNode(); + root.put("dispatchNo", dispatchNo); + var payload = (com.fasterxml.jackson.databind.node.ObjectNode) json.valueToTree(request); + payload.remove(List.of("idempotencyKey", "recordVersion")); + root.set("payload", payload); + root.set("resources", json.valueToTree(resources)); + return sha256(canonical(root, "")); + } + + /** 来源版本复用同一规范化算法,调用方必须提供完整权威事实。 */ + public String source(Object authoritativeFacts) { return sha256(canonical(json.valueToTree(authoritativeFacts), "")); } + + /** 无序集合按规范内容排序;路线/站点等有序数组必须保留次序。 */ + private String canonical(JsonNode node, String field) { + if (node == null || node.isNull()) return "null"; + if (node.isObject()) { + var values = new TreeMap(); + node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()))); + var result = new ArrayList(); + values.forEach((key, value) -> result.add(quoted(key) + ":" + value)); + return "{" + String.join(",", result) + "}"; + } + if (node.isArray()) { + var values = new ArrayList(); + node.forEach(value -> values.add(canonical(value, ""))); + if (UNORDERED.contains(field)) values.sort(String::compareTo); + return "[" + String.join(",", values) + "]"; + } + if (node.isNumber()) return node.decimalValue().stripTrailingZeros().toPlainString(); + return node.toString(); + } + + /** 使用Jackson转义字段名,避免自制引号破坏规范JSON。 */ + private String quoted(String value) { return json.getNodeFactory().textNode(value).toString(); } + + /** 标准SHA256输出固定小写十六进制,不使用进程相关hashCode。 */ + private static String sha256(String value) { + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException("运行环境缺少SHA256", error); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java new file mode 100644 index 000000000..b93fc9ef9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.repository.DispatchCommandRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; + +/** 严格命令身份和不可变结果;当前权限/绑定仓必须由领域入口先核验。 */ +@Service +public class DispatchCommandLedger { + private final DispatchCommandRepository repository; + private final DispatchCommandCodec codec; + + /** 数据访问经过Repository,编解码不依赖进程内结果对象。 */ + public DispatchCommandLedger(DispatchCommandRepository repository, DispatchCommandCodec codec) { + this.repository = repository; + this.codec = codec; + } + + /** 原键恢复优先于当前版本判断,但绝不向另一主体披露原结果。 */ + @Transactional(propagation = Propagation.MANDATORY) + public Optional replay(DispatchContracts.Actor actor, String dispatchNo, + DispatchContracts.CommandIdentity identity) { + requireIdentity(actor, identity); + DispatchCommandRecord record = repository.find(identity.idempotencyKey()); + if (record == null) return Optional.empty(); + if (!Objects.equals(record.operatorId(), actor.operatorId())) + throw new BizException(ResultCode.FORBIDDEN, "该请求标识不属于当前操作员"); + boolean creatingUnknownNumber = dispatchNo == null && identity.action() == com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.CREATE; + if ((!creatingUnknownNumber && !Objects.equals(record.dispatchNo(), dispatchNo)) || !Objects.equals(record.action(), identity.action().name()) + || record.recordVersion() != identity.recordVersion() || !Objects.equals(record.commandHash(), identity.commandHash())) + throw new BizException(ResultCode.CONFLICT, "原请求标识不能更换排车、动作、版本或内容"); + var result = codec.replay(record.resultJson()); + if (!Objects.equals(result.dispatchNo(), record.dispatchNo()) || result.action() != identity.action() + || !Objects.equals(result.idempotencyKey(), record.idempotencyKey())) + throw new IllegalStateException("排车命令账本身份与持久结果不一致"); + return Optional.of(result); + } + + /** GET恢复原命令:先检查当前主体/目标/原动作权限,再从持久JSON读取。 */ + @Transactional(propagation = Propagation.MANDATORY) + public DispatchContracts.CommandResult readOriginal(DispatchContracts.Actor actor, String dispatchNo, String key, + java.util.Set allowedActions) { + var record = repository.find(key); + if (record == null) throw new BizException(ResultCode.NOT_FOUND,"未找到该请求的已提交结果,请保持原请求核对"); + if (actor == null || !Objects.equals(record.operatorId(),actor.operatorId()) || !Objects.equals(dispatchNo,record.dispatchNo())) + throw new BizException(ResultCode.FORBIDDEN,"无权读取该请求结果"); + var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.valueOf(record.action()); + if (!allowedActions.contains(action)) throw new BizException(ResultCode.FORBIDDEN,"当前没有原动作权限"); + return replay(actor,dispatchNo,new DispatchContracts.CommandIdentity(action,key,record.recordVersion(),record.commandHash())).orElseThrow(); + } + + /** 调用方已完成状态CAS后在同一事务保存结果,任何唯一键冲突必须整体回滚。 */ + @Transactional(propagation = Propagation.MANDATORY) + public void append(Long dispatchId, DispatchContracts.Actor actor, DispatchContracts.CommandIdentity identity, + DispatchContracts.CommandResult result) { + requireIdentity(actor, identity); + if (result.replayed() || result.action() != identity.action() + || !Objects.equals(result.idempotencyKey(), identity.idempotencyKey())) + throw new IllegalArgumentException("只能保存本次命令的原始结果"); + var row = new DispatchCommandRecord(IdWorker.getId(), dispatchId, result.dispatchNo(), identity.action().name(), + actor.operatorId(), identity.idempotencyKey(), identity.recordVersion(), identity.commandHash(), codec.encode(result), + LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS), ZoneOffset.UTC)); + try { + if (repository.insert(row) != 1) throw new IllegalStateException("排车命令结果未保存"); + } catch (DuplicateKeyException error) { + // 不在已经写过主单/费用的事务中吞掉冲突返回成功,外层必须回滚后原键恢复。 + throw new BizException(ResultCode.CONFLICT, "请求标识已使用,请保持原内容核对原结果"); + } + } + + /** 只接受可信主体和服务端产生的完整摘要,不允许空键或伪摘要。 */ + private static void requireIdentity(DispatchContracts.Actor actor, DispatchContracts.CommandIdentity identity) { + if (actor == null || actor.operatorId() == null || actor.operatorId().isBlank()) + throw new BizException(ResultCode.FORBIDDEN, "缺少可信操作身份"); + if (identity == null || identity.action() == null || identity.idempotencyKey() == null + || identity.idempotencyKey().isBlank() || identity.idempotencyKey().length() > 255 + || identity.recordVersion() < 0 || identity.commandHash() == null || !identity.commandHash().matches("[0-9a-f]{64}")) + throw new BizException(ResultCode.BAD_REQUEST, "命令标识、版本或摘要无效"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java new file mode 100644 index 000000000..918b346ec --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository; +import com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper.RescanCandidate; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 提交后的正常签收汇总;父CAS、唯一完成历史和指定信号确认必须共同提交。 */ +@Service +public class DispatchCompletionService implements DispatchCompletionPort { + private static final Actor SYSTEM=new Actor("SYSTEM_DELIVERY_RECONCILE",ActorMode.PLATFORM,null); + private final DispatchAggregateRepository repository; + private final DispatchExecutionRepository reads; + private final FleetTripExecutionService fleet; + private final DispatchExecutionPort execution; + private final DispatchDeliveryPort delivery; + private final DispatchSignalPort signals; + private final ObjectMapper json; + /** 仅依赖共同端口,禁止持签收子锁回调父流程。 */ + public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet, + DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json) { + this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json; + } + /** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */ + @Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) + public CompletionResult reconcile(Signal signal) { + if(signal==null || signal.signalId()==null || signal.signalId()<=0 || signal.dispatchNo()==null || signal.deliveryNo()==null)throw new IllegalArgumentException("签收信号身份不完整"); + var identity=fleet.lockDispatchCompletion(signal.dispatchNo());var row=repository.lock(signal.dispatchNo()); + if(row==null || !Objects.equals(row.getTripId(),identity.tripId()))throw new IllegalStateException("整车身份已变化"); + var context=execution.preview(SYSTEM,signal.dispatchNo());var facts=delivery.normalDeliveryFacts(context); + if(facts==null || !Objects.equals(facts.dispatchNo(),row.getDispatchNo()) || facts.expectedDeliveryNos()==null || !facts.expectedDeliveryNos().contains(signal.deliveryNo()))throw new IllegalStateException("签收信号不属于本车已安排派送"); + if(!Status.ACTIVE.name().equals(row.getStatus()) || !Set.of(Stage.DELIVERING.name(),Stage.DELIVERED.name()).contains(row.getStage()))throw new IllegalStateException("本车尚无合法在途运输事实"); + boolean changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC); + if(Stage.DELIVERING.name().equals(row.getStage()) && facts.allNormallySigned()) { + if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据"); + long version=row.getRecordVersion();row.setStage(Stage.DELIVERED.name());row.setDeliveryCompletedAt(LocalDateTime.ofInstant(facts.completedAt(),ZoneOffset.UTC)); + row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(now); + if(repository.saveState(row,version)!=1)throw new IllegalStateException("整车完成版本竞争失败"); + if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存"); + changed=true; + } + // 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。 + signals.acknowledge(signal,Instant.now()); + return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(), + row.getDeliveryCompletedAt()==null?null:row.getDeliveryCompletedAt().toInstant(ZoneOffset.UTC)); + } + /** 普通读候选不持子锁跨入汇总或重建事务。 */ + @Transactional(readOnly=true) + public List rescanCandidates(int limit) { + if(limit<1 || limit>200)throw new IllegalArgumentException("重扫范围不合法");return reads.rescan(limit); + } + /** 每个缺失信号独立短事务重核真实派送版本,竞争失败不阻塞下一张车。 */ + @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) + public void rescan(RescanCandidate candidate) {signals.enqueue(candidate.dispatchNo(),candidate.deliveryNo(),candidate.deliveryVersion(),Instant.now());} + /** 证据序列化失败必须随主阶段一起回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("正常签收证据保存失败",error);}} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java new file mode 100644 index 000000000..5e39f67a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchSignalPort; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.time.Instant; + +/** 有界、可重复的持久信号消费者,不在子事务持锁期间调用汇总。 */ +@Component +public class DispatchDeliveryCompletionWorker { + private static final Logger LOG=LoggerFactory.getLogger(DispatchDeliveryCompletionWorker.class); + private final DispatchCompletionService completion; + private final DispatchSignalPort signals; + /** 调用独立代理服务,确保失败事务已回滚再持久记录退避。 */ + public DispatchDeliveryCompletionWorker(DispatchCompletionService completion,DispatchSignalPort signals){this.completion=completion;this.signals=signals;} + /** 每批最多100条;单车失败不终止其他车次,已确认信号不会被失败记录重新激活。 */ + @Scheduled(fixedDelayString="${lastleg.dispatch.delivery-completion-poll-ms:5000}") + public void consume() { + for(var signal:signals.due(Instant.now(),100))try{completion.reconcile(signal);}catch(RuntimeException error){ + long delay=Math.min(900L,5L<();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId()); + for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车"); + var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey()); + if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平"); + sourceWorkflow.departWholeSources(context,actor,departed,at); + for(var item:repository.cargo(row.getId()))if(sources.release(row.getId(),item,LocalDateTime.ofInstant(at,ZoneOffset.UTC))<1)throw conflict("原发运来源占用已变化"); + fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion(); + row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(departed.shippedPackages()));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败"); + if(repository.history(IdWorker.getId(),row,Action.DEPART.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"departure",departed)),row.getUpdatedAt())!=1)throw conflict("发车历史未保存"); + var result=new CommandResult(number,Action.DEPART,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.DELIVERING,at,Effect.APPLIED, + new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Departed(at,departed.shippedPackages(),departed.outbounds(),departed.deliveries()),false,false); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 原装车快照不能缺项回退成当前建议值。 */ + private T read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("装车快照读取失败",error);}} + /** 发车证据失败与单据更新一起回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("发车证据保存失败",error);}} + /** 并发、来源或阶段不满足时整条命令失败。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java new file mode 100644 index 000000000..679ff388c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java @@ -0,0 +1,355 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchDraft; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftCommand; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; +import com.baiyue.lastleg.dispatch.mapper.DispatchDraftClaimMapper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchDraftRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.util.*; +import java.util.function.Consumer; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; + +/** 平台单仓多客户草稿;持久命令、服务器来源、排他占用和整批创建共同提交。 */ +@Service +public class DispatchDraftService implements DispatchPlanningPort { + private static final TypeReference> CANDIDATES = new TypeReference<>() { }; + private static final TypeReference> KEYS = new TypeReference<>() { }; + private final DispatchDraftRepository repository; + private final DispatchSourceRepository sources; + private final DispatchService sourceRules; + private final DispatchAggregateService aggregate; + private final DispatchCommandHasher hashes; + private final DispatchCommandCodec commandCodec; + private final ObjectMapper json; + private final Clock clock; + private final AppointmentWorkDispatchPort appointmentProvider; + + /** 只装配草稿仓储和实际来源/主单提供者,不持久化客户端伪造候选。 */ + @org.springframework.beans.factory.annotation.Autowired + public DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules, + DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider) { + this(repository,sources,sourceRules,aggregate,hashes,json,commandCodec,appointmentProvider,Clock.systemUTC()); + } + + /** 测试可控制锁等待跨越的时间边界,生产始终使用UTC系统时钟。 */ + DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules, + DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,Clock clock) { + this.repository=repository;this.sources=sources;this.sourceRules=sourceRules; + this.aggregate=aggregate;this.hashes=hashes;this.json=json;this.commandCodec=commandCodec;this.appointmentProvider=appointmentProvider;this.clock=Objects.requireNonNull(clock); + } + + /** 候选继续委托主流程已有真实查询和权限范围。 */ + @Override public CandidateList candidates(Actor actor,CandidateQuery query){platform(actor);return aggregate.candidates(actor,query);} + + /** 只读已保存来源快照,不把历史版本推断成最新可转换资格。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public List drafts(Actor actor){platform(actor);var rows=repository.active(actor.operatorId());var batches=readCandidateBatches(rows.stream().flatMap(r->selected(r).stream()).toList());return rows.stream().map(r->view(r,batches)).toList();} + + /** 服务器生成token,零版本创建结果可在进程重启后按原命令恢复。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft createDraft(Actor actor,CreateDraft request) { + platform(actor);if(request==null || request.warehouseId()==null || !request.warehouseId().matches("[1-9][0-9]*"))throw bad("请选择海外起运仓"); + var command=command(actor,"CREATE_DRAFT","",request.idempotencyKey(),request); + if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class); + var now=now();var row=new DispatchDraft();row.setId(IdWorker.getId()); + row.setDraftToken("DD-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT)); + row.setOperatorId(actor.operatorId());row.setWarehouseId(request.warehouseId());row.setRecordVersion(0L); + row.setPlanIdsJson("[]");row.setContainersJson("[]");row.setSourcesJson("[]");row.setStatus("ACTIVE"); + row.setCreatedAt(now);row.setUpdatedAt(now);row.setExpiresAt(now.plusMinutes(30));repository.insert(row); + return complete(command,view(row)); + } + + /** 新容器只归本拥有者草稿,容量沿既有15/30托选项。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft createContainer(Actor actor,CreateContainer request) { + Objects.requireNonNull(request);capacity(request.capacity()); + return change(actor,"CREATE_CONTAINER",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var all=repository.containers(row.getDraftToken());if(all.size()>=100)throw bad("每个草稿最多100个容器"); + var container=new DispatchDraftContainer();container.setId(IdWorker.getId());container.setDraftId(row.getId());container.setDraftToken(row.getDraftToken()); + container.setSequenceNo(all.stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0)+1); + container.setCapacity(request.capacity());container.setSourceKeysJson("[]");container.setCreatedAt(now());container.setUpdatedAt(now());repository.insert(container); + }); + } + + /** 来源资格和版本取真实提供者,锁定后才取得共享来源/实托占用。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft select(Actor actor,DraftSelection request) { + Objects.requireNonNull(request); + return change(actor,"SELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source()); + if(selected.stream().anyMatch(c->c.sourceKey().equals(key)))throw conflict("来源已在本草稿中,请勿重复选择"); + var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId()); + var selections=new ArrayList<>(selected.stream().map(Candidate::selection).toList());selections.add(request.source()); + var locked=sourceRules.lockDraftSources(row.getWarehouseId(),selections); + requireLease(row.getExpiresAt()); + verifyClaims(row,selected,containers); + var resolved=locked.stream().filter(s->s.sourceKey().equals(key)).findFirst().orElseThrow(()->conflict("所选来源不存在")); + var candidate=candidate(resolved);var next=new ArrayList<>(selected);next.add(candidate); + var keys=new ArrayList<>(keys(container));keys.add(key);requireCapacity(container.getCapacity(),keys,index(next)); + var at=now();sources.expire(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at); + if(!sources.claims(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at).isEmpty())throw conflict("所选来源或实托已被其他作业占用"); + requireLease(row.getExpiresAt()); + try {repository.claim(IdWorker.getId(),row,container.getId(),new DispatchDraftRepository.DispatchServiceSource(candidate.customerId(),key, + resolved.candidate().planPalletId(),resolved.candidate().warehousePalletId(),resolved.candidate().returnReceiptId()),at);} + catch(DuplicateKeyException error){throw conflict("所选来源或实托已被其他作业占用");} + container.setSourceKeysJson(write(keys));container.setUpdatedAt(at);repository.layout(container);row.setSourcesJson(write(next)); + }); + } + + /** 解除只认原容器与原来源完整身份;来源变旧不阻止拥有者释放自己的占用。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft unselect(Actor actor,DraftSelection request) { + Objects.requireNonNull(request); + return change(actor,"UNSELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source()); + var current=index(selected).get(key);if(current==null || !Objects.equals(current.selection(),request.source()))throw conflict("原来源身份或版本不一致"); + var container=container(repository.containers(row.getDraftToken()),request.draftContainerId());var keys=new ArrayList<>(keys(container)); + if(!keys.remove(key))throw conflict("来源不属于本容器"); + if(repository.release(row,key,container.getId(),now())!=1)throw conflict("原草稿占用已变化"); + container.setSourceKeysJson(write(keys));container.setUpdatedAt(now());repository.layout(container); + row.setSourcesJson(write(selected.stream().filter(c->!c.sourceKey().equals(key)).toList())); + }); + } + + /** 完整布局只能重排当前服务器来源和容器,不能用JSON新增货物或绕过容量。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft saveSnapshot(Actor actor,DraftSnapshot request) { + Objects.requireNonNull(request); + return change(actor,"SNAPSHOT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);var old=repository.containers(row.getDraftToken()); + if(request.containers()==null || request.containers().size()!=old.size())throw bad("布局必须包含全部原容器"); + var remaining=new HashSet<>(old.stream().map(DispatchDraftContainer::getId).toList());var seen=new HashSet();var sequence=new HashSet(); + var mapping=new HashMap();var byKey=index(selected); + for(var c:request.containers()) { + if(c==null || !remaining.remove(c.draftContainerId()) || c.sequenceNo()<1 || !sequence.add(c.sequenceNo()) || c.sourceKeys()==null)throw bad("容器身份或顺序不完整"); + capacity(c.capacity());requireCapacity(c.capacity(),c.sourceKeys(),byKey); + for(var key:c.sourceKeys()){if(!seen.add(key))throw bad("同一来源不能重复放入容器");mapping.put(key,c.draftContainerId());} + } + if(!remaining.isEmpty() || !seen.equals(byKey.keySet()))throw conflict("布局只能完整移动当前已锁来源"); + if(request.planIds()==null || request.planIds().stream().anyMatch(id->id==null || id<=0) || request.planIds().size()>500)throw bad("计划筛选不合法"); + sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList()); + var claims=verifyClaims(row,selected,old);var at=now(); + for(var claim:claims)if(!Objects.equals(claim.containerId(),mapping.get(claim.sourceKey())))repository.move(row,claim,mapping.get(claim.sourceKey()),at); + repository.stageSequences(row.getDraftToken()); + for(var c:request.containers()) {var existing=container(old,c.draftContainerId());existing.setSequenceNo(c.sequenceNo());existing.setCapacity(c.capacity()); + existing.setSourceKeysJson(write(c.sourceKeys()));existing.setUpdatedAt(at);repository.layout(existing);} + row.setPlanIdsJson(write(request.planIds())); + }); + } + + /** 仅移除空容器;来源必须通过显式解除动作处理。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft removeContainer(Actor actor,String token,Long id,DispatchCommands.ReasonCommand request) { + Objects.requireNonNull(request);if(request.reason()==null || request.reason().isBlank())throw bad("移除容器原因必填"); + return change(actor,"REMOVE_CONTAINER",token,request.recordVersion(),request.idempotencyKey(),Arrays.asList(token,id,request),row->{ + var container=container(repository.containers(token),id); + if(!keys(container).isEmpty() || repository.claims(row,now()).stream().anyMatch(c->Objects.equals(c.containerId(),id)))throw conflict("仅空容器可以删除"); + repository.remove(token,container.getId()); + }); + } + + /** 释放原拥有者草稿,绝不按过期token释放后来归属正式单或其他人的记录。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft release(Actor actor,DraftRelease request) { + Objects.requireNonNull(request); + return change(actor,"RELEASE",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + repository.release(row,null,null,now());row.setStatus("RELEASED");row.setReleasedAt(now());row.setSourcesJson("[]"); + for(var c:repository.containers(row.getDraftToken())){c.setSourceKeysJson("[]");c.setUpdatedAt(now());repository.layout(c);} + }); + } + + /** 全组先资源后来源,随后释放原草稿占用并调用真实整车创建;任一失败全批回滚。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public ConvertedDraft convert(Actor actor,ConvertDraft request) { + Objects.requireNonNull(request);platform(actor); + var command=command(actor,"CONVERT",request.draftToken(),request.idempotencyKey(),request); + if(command.getResultJson()!=null)return readConverted(command.getResultJson(),request.draftToken()); + var row=owned(actor,request.draftToken(),request.recordVersion());var deadline=row.getExpiresAt();var selected=selected(row);var containers=repository.containers(row.getDraftToken()); + var nonempty=containers.stream().filter(c->!keys(c).isEmpty()).toList(); + if(request.groups()==null || request.groups().isEmpty() || request.groups().size()!=nonempty.size())throw bad("须一次转换全部非空容器"); + var byId=new TreeMap();var childKeys=new HashSet(); + for(var group:request.groups()) { + if(group==null || group.dispatch()==null || group.draftContainerId()==null || byId.putIfAbsent(group.draftContainerId(),group)!=null)throw bad("转换分组缺失或重复"); + var c=container(nonempty,group.draftContainerId());var create=group.dispatch(); + if(!Objects.equals(create.warehouseId(),row.getWarehouseId()))throw conflict("整批转换必须使用草稿原起运仓"); + if(create.idempotencyKey()==null || create.idempotencyKey().isBlank() || !childKeys.add(create.idempotencyKey()))throw bad("每辆车须有独立创建命令键"); + var original=selected.stream().filter(s->keys(c).contains(s.sourceKey())).map(Candidate::selection).toList(); + if(!selectionIndex(original).equals(selectionIndex(create.cargo())))throw conflict("创建货物必须精确等于原容器来源及版本"); + requireCapacity(c.getCapacity(),keys(c),index(selected)); + } + if(!byId.keySet().equals(new TreeSet<>(nonempty.stream().map(DispatchDraftContainer::getId).toList())))throw bad("转换必须覆盖全部原容器"); + // 该提供者只锁全批资源,不写主单;绝不能先锁来源再锁下一辆车的资源。 + aggregate.lockDraftCreationResources(actor,byId.values().stream().map(DraftVehicle::dispatch).toList()); + sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList()); + requireLease(deadline); + verifyClaims(row,selected,containers); + if(repository.release(row,null,null,now())!=selected.size())throw conflict("草稿来源占用未完整核齐"); + var results=new ArrayList(); + for(var group:byId.values()) {var result=aggregate.create(actor,group.dispatch()); + if(result==null || result.replayed() || result.action()!=DispatchTypes.Action.CREATE)throw conflict("子创建命令已被其他批次使用"); + results.add(new ConvertedVehicle(group.draftContainerId(),result));} + long version=row.getRecordVersion();row.setRecordVersion(Math.addExact(version,1));row.setStatus("CONVERTED");row.setReleasedAt(now());row.setUpdatedAt(now());repository.save(row,version); + var result=complete(command,new ConvertedDraft(row.getDraftToken(),List.copyOf(results),false)); + requireLease(deadline);return result; + } + + /** 草稿写操作统一先恢复原命令,再校验当前拥有者、生命周期与公共版本。 */ + private Draft change(Actor actor,String action,String token,long version,String key,Object request,Consumer mutation) { + platform(actor);var command=command(actor,action,token,key,request);if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class); + var row=owned(actor,token,version);var deadline=row.getExpiresAt();mutation.accept(row);long before=row.getRecordVersion(); + requireLease(deadline); + var at=now();row.setRecordVersion(Math.addExact(before,1));row.setUpdatedAt(at); + if("ACTIVE".equals(row.getStatus())){ + row.setExpiresAt(at.plusMinutes(30));int renewed=repository.renew(row,at); + requireLease(deadline); + var claims=verifyClaims(row,selected(row),repository.containers(token)); + // 部分驱动对未改变值返回0,最终以锁内有效行、精确归属和完整到期时间证明续约;不能只延长根。 + if(renewed<0 || renewed>claims.size())throw conflict("草稿来源续约集合不完整"); + } + repository.save(row,before);var result=complete(command,view(row));requireLease(deadline);return result; + } + + /** 同键完整身份不一致拒绝,结果未知时只读取数据库原结果,不推断未执行。 */ + private DispatchDraftCommand command(Actor actor,String action,String token,String key,Object payload) { + if(key==null || key.isBlank() || key.length()>128)throw bad("命令键必填且最多128字符"); + if(token==null || token.length()>128)throw bad("草稿标识无效"); + var command=new DispatchDraftCommand();command.setId(IdWorker.getId());command.setIdempotencyKey(key);command.setOperatorId(actor.operatorId()); + command.setAction(action);command.setTargetToken(token);command.setRequestHash(hashes.source(Arrays.asList(actor.operatorId(),actor.mode(),action,token,payload)));command.setCreatedAt(now()); + var saved=repository.reserve(command); + if(saved==null || !Objects.equals(saved.getOperatorId(),actor.operatorId()) || !Objects.equals(saved.getAction(),action) + || !Objects.equals(saved.getTargetToken(),token) || !Objects.equals(saved.getRequestHash(),command.getRequestHash()))throw conflict("相同命令键的主体、动作、版本或载荷不同"); + if(!Objects.equals(saved.getId(),command.getId()) && saved.getResultJson()==null)throw new IllegalStateException("已提交草稿命令缺少原结果"); + return saved; + } + + /** 精确拥有者查询避免暴露他人草稿;正常变更不复活过期或已转换状态。 */ + private DispatchDraft owned(Actor actor,String token,long version) { + var row=repository.lock(token,actor.operatorId());if(row==null)throw new BizException(ResultCode.NOT_FOUND,"草稿不存在或不属于当前操作者"); + if(!"ACTIVE".equals(row.getStatus()))throw conflict("草稿已释放或转换");requireLease(row.getExpiresAt()); + if(row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("草稿版本已变化,请刷新");return row; + } + + /** 来源、容器与实际草稿占用逐项相等,不以JSON或总数相等替代归属。 */ + private List verifyClaims(DispatchDraft row,List selected,List containers) { + var claims=repository.claims(row,now());var mapping=new HashMap(); + for(var c:containers)for(var key:keys(c))if(mapping.putIfAbsent(key,c.getId())!=null)throw conflict("草稿来源被重复放入容器"); + var expected=index(selected);if(!mapping.keySet().equals(expected.keySet()) || claims.size()!=expected.size())throw conflict("草稿来源及占用未完整对应"); + var seen=new HashSet();for(var claim:claims){var c=expected.get(claim.sourceKey()); + if(c==null || !seen.add(claim.sourceKey()) || !Objects.equals(c.customerId(),claim.customerId()) + || !Objects.equals(c.selection().warehousePalletId(),claim.palletId()) || !Objects.equals(mapping.get(claim.sourceKey()),claim.containerId()) + || !Objects.equals(row.getExpiresAt(),claim.expiresAt()))throw conflict("草稿原占用身份或有效期已变化");} + return claims; + } + + /** 公开候选快照沿主流程同一字段映射,所有身份和版本来自锁内真实来源。 */ + private Candidate candidate(DispatchService.WholeSource source) { + var r=source.candidate();var d=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode()), + r.destinationType(),r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.deliveryAddress(),r.postalCode(),r.contactName(),r.contactPhone()); + var selection=new DispatchCommands.CargoSelection(source.selection().sourceType(),source.selection().planPalletId(),source.selection().returnReceiptId(),source.selection().relabelNo(),r.warehousePalletId(),source.sourceVersion()); + var candidate=new Candidate(selection,source.sourceKey(),source.sourceVersion(),r.customerId(),r.customerName(),r.overseasWarehouseId(),r.overseasWarehouseCode(),r.overseasWarehouseName(), + r.orderNo(),r.inboundCode(),r.planNo(),r.palletNo(),r.containerNo(),d,r.packageCount(),r.grossWeightKg(),r.volumeCbm(),"SOURCE_SUGGESTION",List.of(),List.of()); + return projectCandidate(candidate,readCandidateBatches(List.of(candidate))); + } + + /** 单容器容量按实际托盘去重;未收货计划托盘按真实来源键计位。 */ + private static void requireCapacity(int capacity,List keys,Map sources) { + var physical=new HashSet();var seen=new HashSet(); + for(var key:keys){var source=sources.get(key);if(source==null || !seen.add(key))throw bad("布局包含未知或重复来源"); + physical.add(source.selection().warehousePalletId()==null?key:"PALLET:"+source.selection().warehousePalletId());} + if(physical.size()>capacity)throw conflict("本容器实托数量超过容量"); + } + /** 只接受既有15/30托容器,不将计划容量当成装载实测。 */ + private static void capacity(int capacity){if(capacity!=15 && capacity!=30)throw bad("容器容量仅支持15托或30托");} + /** 按来源键核对完整原始选择,缺失或重复不能退化为部分转换。 */ + private static Map selectionIndex(List selections){ + if(selections==null)throw bad("创建货物来源必填");var result=new TreeMap(); + for(var s:selections)if(result.putIfAbsent(DispatchService.wholeSourceKey(s),s)!=null)throw bad("来源重复");return result; + } + /** 草稿内来源键唯一,不能以覆盖Map掩盖重复持久数据。 */ + private static Map index(List selected){var result=new TreeMap();for(var c:selected)if(result.putIfAbsent(c.sourceKey(),c)!=null)throw conflict("草稿来源重复");return result;} + /** 容器必须是本草稿中实际已创建的服务器身份。 */ + private static DispatchDraftContainer container(List containers,Long id){if(id==null)throw bad("容器标识必填");return containers.stream().filter(c->Objects.equals(c.getId(),id)).findFirst().orElseThrow(()->conflict("容器不属于本草稿"));} + /** 只读服务器持久来源,不解析客户端自定义候选。 */ + private List selected(DispatchDraft row){return read(row.getSourcesJson(),CANDIDATES);} + /** 容器布局只保存来源键,实际资格取草稿服务器来源。 */ + private List keys(DispatchDraftContainer row){return read(row.getSourceKeysJson(),KEYS);} + /** 返回公开单一版本及来源快照,不泄露内部命令或存储字段。 */ + private Draft view(DispatchDraft row){return view(row,readCandidateBatches(selected(row)));} + /** 整页复用一份权威批读;不改来源摘要、草稿版本、占用或命令结果的重放语义。 */ + private Draft view(DispatchDraft row,Map batches){return new Draft(row.getDraftToken(),row.getRecordVersion(),row.getStatus(),row.getWarehouseId(),repository.containers(row.getDraftToken()).stream() + .map(c->new DraftContainer(c.getId(),c.getSequenceNo(),c.getCapacity(),keys(c))).toList(),selected(row).stream().map(c->projectCandidate(c,batches)).toList(),row.getExpiresAt().toInstant(ZoneOffset.UTC));} + /** 真实客户及计划托盘来自服务器来源,整页一次调用原Provider。 */ + private Map readCandidateBatches(List candidates) { + var identities=candidates.stream().filter(c->c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList(); + var result=new LinkedHashMap(); + for(int offset=0;offset batches) { + if(c.selection().planPalletId()==null)return c; + var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())); + var reasons=new LinkedHashSet(); + if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons()); + var refs=result==null?List.of():result.appointmentBatches().stream().map(b->new BatchReference(b.batchKey(),b.batchNo(),b.customerId(), + b.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),b.recordVersion())).toList(); + return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),refs,List.copyOf(reasons)); + } + /** 原结果完整保存,异常时随所有业务写入一起回滚。 */ + private T complete(DispatchDraftCommand command,T result){repository.complete(command.getId(),write(result));return result;} + /** 原批结果的每张车使用公共Action编解码器恢复,不能让抽象Payload触发当前快照或重新创建回退。 */ + private ConvertedDraft readConverted(String stored,String expectedToken) { + try { + var root=json.readTree(stored); + if(root==null || !root.isObject() || !root.hasNonNull("draftToken") || !root.hasNonNull("vehicles") + || !root.get("vehicles").isArray() || root.get("vehicles").isEmpty() || !root.hasNonNull("replayed") + || !root.get("replayed").isBoolean())throw new IllegalArgumentException("原转换结果字段不完整"); + String token=json.treeToValue(root.get("draftToken"),String.class); + if(!Objects.equals(token,expectedToken))throw new IllegalArgumentException("原转换结果不属于本草稿"); + var vehicles=new ArrayList();var ids=new HashSet(); + for(var vehicle:root.get("vehicles")) { + if(!vehicle.isObject() || !vehicle.hasNonNull("draftContainerId") || !vehicle.hasNonNull("dispatch"))throw new IllegalArgumentException("原转换车辆字段不完整"); + Long id=json.treeToValue(vehicle.get("draftContainerId"),Long.class); + if(id==null || id<=0 || !ids.add(id))throw new IllegalArgumentException("原转换容器身份不完整"); + var dispatch=commandCodec.decode(vehicle.get("dispatch").toString()); + if(dispatch.action()!=DispatchTypes.Action.CREATE)throw new IllegalArgumentException("原转换车辆动作不是创建"); + vehicles.add(new ConvertedVehicle(id,dispatch)); + } + // 仅批次返回标记为重放,子车仍是不可变原结果,原版本/时间/载荷逐项保留。 + return new ConvertedDraft(token,List.copyOf(vehicles),true); + }catch(JsonProcessingException | IllegalArgumentException error){throw new IllegalStateException("草稿转换原结果损坏",error);} + } + /** 复用配置好的时间和Long序列化,持久结果不得保留内存对象引用。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("草稿结果保存失败",error);}} + /** 进程重启后从不可变JSON恢复原公开结果。 */ + private T read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("草稿结果损坏",error);}} + /** 来源和布局使用封闭类型读取,非法存储不能按空草稿降级。 */ + private T read(String value,TypeReference type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("草稿来源损坏",error);}} + /** 草稿只属于平台操作者,客户和PDA不能复用整车写入口。 */ + private static void platform(Actor actor){if(actor==null || actor.operatorId()==null || actor.operatorId().isBlank() || actor.mode()!=DispatchTypes.ActorMode.PLATFORM)throw new BizException(ResultCode.FORBIDDEN,"仅平台操作者可操作草稿");} + /** 存储时间统一为UTC,本地进程时区不改变过期或幂等身份。 */ + private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS),ZoneOffset.UTC);} + /** 等待锁、续约或写入后仍须在原有效期内,不能在过期后复活占用再返回成功。 */ + private void requireLease(LocalDateTime deadline){if(deadline==null || !deadline.isAfter(now()))throw conflict("草稿已过期,请重新创建并核对来源");} + /** 参数错误采用统一业务结果码。 */ + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} + /** 状态或依据变化不能被解释为新的成功命令。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java new file mode 100644 index 000000000..56df18287 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchPdaReadRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** PDA按绑定仓读取真正整车,列表批量装配全部明细而不逐行请求详情。 */ +@Service +public class DispatchPdaQueryService { + private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow; + /** 共用主流程资格,PDA不维护第二套版本和阶段。 */ + public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;} + /** 列表只展示绑定仓实际作业,阶段使用共同枚举。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public List list(Actor actor,Stage stage){require(actor);return assemble(actor,repository.headers(actor.boundWarehouseId(),null,stage==null?null:stage.name()));} + /** 单车也先在SQL约束绑定仓,其他仓业务编号不可用于读取。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.PdaTask task(Actor actor,String number) { + require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); + var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); + } + /** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */ + private List assemble(Actor actor,List roots) { + if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号"); + var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId)); + var picked=repository.picked(ids).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var invalid=repository.invalid(ids).stream().collect(Collectors.toMap(p->p.dispatchId(),p->p.quantity())); + var positions=repository.positions(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(p->p.getCustomerId()+":"+p.getPalletId())); + var cartons=repository.cartons(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(c->c.itemId()));var result=new ArrayList(); + for(var root:roots) { + var items=cargo.getOrDefault(root.getId(),List.of());var detail=new ArrayList(); + for(var item:items) { + var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet()); + boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType()); + var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream() + .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages())+(relabel?n(p.getHoldPackages()):0),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); + var sourceCartons=cartons.getOrDefault(item.getId(),List.of()).stream().filter(c->marks.contains(c.cartonMark())) + .map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList(); + detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons)); + } + boolean complete=n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0 + && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())); + var actions=new EnumMap(workflow.actions(actor,root)); + if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平")); + else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); + var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); + // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 + result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); + } + return List.copyOf(result); + } + /** 仓和操作者均来自受信身份,不接收请求自报。 */ + private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} + /** 缺失计数不作为业务标识使用。 */ + private static long n(Number value){return value==null?0:value.longValue();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java new file mode 100644 index 000000000..68e058837 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ +@Service +public class DispatchPickingService { + private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; + private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; + private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; + private final DispatchCommandLedger commands;private final DispatchCommandHasher hashes;private final ObjectMapper json; + /** 库存命令与来源归属继续使用既有真实生产者,不用请求数量伪造库存。 */ + public DispatchPickingService(DispatchAggregateService workflow,DispatchAggregateRepository repository,DispatchSourceRepository sources, + DispatchPickingRepository picking,DispatchPickOrderAttributionService attribution,InventoryCommandService inventory, + ReturnRelabelDispatchStock returns,DispatchCommandLedger commands,DispatchCommandHasher hashes,ObjectMapper json) { + this.workflow=workflow;this.repository=repository;this.sources=sources;this.picking=picking;this.attribution=attribution;this.inventory=inventory;this.returns=returns;this.commands=commands;this.hashes=hashes;this.json=json; + } + /** 原键恢复先于当前版本/阶段,全部写入跟随同一根事务回滚。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult pick(Actor actor,String number,long itemId,DispatchCommands.Pick request) { + if(actor==null || actor.mode()!=ActorMode.PDA || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可扫码"); + var context=workflow.lockForArrangement(actor,number);var row=repository.lock(number); + var identity=new CommandIdentity(Action.PICK,request.idempotencyKey(),request.recordVersion(),hashes.command(actor,Action.PICK,number,request.recordVersion(),Arrays.asList(itemId,request))); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING + || context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码"); + if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法"); + try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");} + var item=repository.cargo(row.getId()).stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在")); + if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓"); + materialize(row.getId(),item); + var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC); + if(task==null) { + task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId()); + task.setTaskNo(number); + task.setStatus("PICKING");task.setWarehouseId(actor.boundWarehouseId());task.setPushedAt(at);task.setCreatedAt(at);task.setUpdatedAt(at); + if(picking.createTask(task)!=1)throw conflict("本车作业未保存"); + } + if(!Objects.equals(task.getWarehouseId(),actor.boundWarehouseId()))throw conflict("本车作业仓已变化"); + long before=picking.itemPicked(item.getCustomerId(),task.getId(),itemId); + if(request.packages()>item.getPackageCount()-before)throw conflict("实际扫码超过本来源应装箱数"); + boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage()); + if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); + long pickId=IdWorker.getId();Long returnCartonId=null; + if(nativeReturn) { + attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); + returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); + } else { + var position=picking.position(request.positionId()); + if(position==null || !Objects.equals(item.getCustomerId(),position.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),position.getPalletId()) || !Objects.equals(actor.boundWarehouseId(),position.getOverseasWarehouseId()))throw conflict("实际库存仓位不属于本车本客户实托"); + Long fallback="RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null; + attribution.preparePicking(item.getCustomerId(),request.positionId(),fallback); + var actual=sources.stock(item.getCustomerId(),item.getWarehousePalletId(),true).stream().filter(s->s.id()==request.positionId()).findFirst().orElseThrow(()->conflict("实际库存仓位已移动")); + if(!Objects.equals(actual.warehouseId(),actor.boundWarehouseId()))throw conflict("实际库存起运仓已变化"); + String base="DISPATCH:"+number+":"+itemId+":"+request.idempotencyKey(); + if(fallback!=null)inventory.release(item.getCustomerId(),actor.operatorId(),move(request,number,item.getSourceBusinessNo(),base+":RELABEL_RELEASE")); + inventory.allocate(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":ALLOCATE")); + inventory.pick(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":PICK")); + inventory.ship(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":SHIP")); + } + var pick=new DispatchPdaPick();pick.setId(pickId);pick.setCustomerId(item.getCustomerId());pick.setTaskId(task.getId());pick.setDispatchItemId(itemId); + pick.setInventoryPositionId(request.positionId());pick.setReturnCartonId(returnCartonId);pick.setPackages(request.packages());pick.setProductQuantity(request.productQuantity());pick.setIdempotencyKey(request.idempotencyKey());pick.setPickedAt(at);pick.setCreatedAt(at); + if(picking.pick(pick)!=1)throw conflict("真实扫码明细未保存"); + if(nativeReturn)attribution.attributeNativeReturn(item.getCustomerId(),pick,item.getOrderId()); + else attribution.attribute(item.getCustomerId(),pick,"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null,item.getReturnReceiptId()); + task.setOperatorId(actor.operatorId());task.setStatus("PICKING");task.setUpdatedAt(at);if(picking.saveTask(task)!=1)throw conflict("本车作业进度未保存"); + long version=row.getRecordVersion();row.setPickedPackageCount(Math.toIntExact(repository.realPicked(row.getId())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); + if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败"); + boolean complete=workflow.preview(actor,number).pickingReconciled(); + if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存"); + var result=new CommandResult(number,Action.PICK,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.LOADING,at.toInstant(ZoneOffset.UTC),Effect.APPLIED, + new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */ + private void materialize(long dispatch,DispatchItem item) { + if(item.getWarehousePalletId()!=null)return; + if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托"); + var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); + if(orders.isEmpty())throw conflict("原计划缺少真实订单组成"); + for(var id:orders)if(sources.lockOrder(item.getCustomerId(),id)==null)throw conflict("原计划订单已变化或暂扣"); + if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0); + if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) + || !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType()) + || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对"); + if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单"); + try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC))!=1)throw conflict("原计划来源占用已变化");} + catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");} + item.setWarehousePalletId(actual.warehousePalletId());item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); + if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存"); + } + /** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */ + private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());} + /** 不可变扫码证据序列化失败随根命令回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("扫码证据保存失败",error);}} + /** 实际来源或版本冲突不能降级为成功。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} + /** 明确请求值错误。 */ + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index fd2359dfe..f8ed8cf0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -10,16 +10,12 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; import com.baiyue.lastleg.dispatch.entity.DispatchDraft; -import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper; -import com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse; -import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; @@ -52,6 +48,191 @@ import java.util.stream.Collectors; @Service public class DispatchService { + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository wholeSources; + @Resource private DispatchCommandHasher wholeHashes; + + /** 整车一次选择的真实客户来源,未持久化客户子排车。 */ + public record WholeSource(DispatchPalletCandidate candidate, + com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { } + + /** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */ + public List wholeCandidates(String operator, + com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) { + validateSourceStage(query.sourceStage()); + if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream() + .noneMatch(d -> query.draftToken().equals(d.getDraftToken()))) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源"); + var result=new java.util.ArrayList(); + if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query)); + if(query.planIds()==null || query.planIds().isEmpty()) { + if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query)); + if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) { + var rows=wholeSources.availableRelabel(query); + if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); + result.addAll(measuredRelabelCandidates(rows)); + result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo())); + } + } + if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); + return result.stream().filter(row -> query.destinationWarehouseCode()==null + || query.destinationWarehouseCode().equals(row.fbaWarehouseCode())) + .sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),"")) + .thenComparing(row -> Objects.toString(row.orderNo(),"")) + .thenComparing(row -> Objects.toString(row.palletNo(),""))).toList(); + } + + /** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public List lockWholeSources(String warehouseId, + List selections) { + return resolveWholeSources(warehouseId,selections,true,500); + } + + /** 草稿可逐托暂存换标选择,正式建单仍要求本申请全部托盘,事实校验和锁序共用。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public List lockDraftSources(String warehouseId,List selections) { + return resolveWholeSources(warehouseId,selections,false,1000); + } + + /** 先锁全部申请/来源订单,再按物理托盘顺序锁来源根和库存,不能按分组交错。 */ + private List resolveWholeSources(String warehouseId,List selections,boolean completeRelabel,int limit) { + if (selections == null || selections.isEmpty()) return List.of(); + if (selections.size() > limit) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"选择来源超过完整核验上限"); + var chosen = new java.util.LinkedHashMap(); + var relabelGroups = selections.stream().filter(s -> s != null && s.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.RELABEL) + .collect(Collectors.groupingBy(s -> Objects.toString(s.relabelNo(), ""), java.util.TreeMap::new, Collectors.toList())); + var relabelRows = new java.util.HashMap>(); + for (var group : relabelGroups.entrySet()) { + String customer = wholeSources.relabelOwner(group.getKey()); + var application = customer == null ? null : mutations().lockRelabel(customer, group.getKey()); + if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus()) || !"TRUCK".equals(application.getDeliveryMethod()) + || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"换标申请尚不具备排车资格"); + relabelBindingGuard.requireBound(application); + relabelMeasurements.requireMeasured(application); + var complete = java.util.stream.Stream.concat(measuredRelabelCandidates(mutations().relabelCandidates(customer, group.getKey())).stream(), + returnRelabelStock.candidates(customer,null,group.getKey()).stream()).toList(); + var requested = group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection::warehousePalletId).collect(Collectors.toSet()); + var available=complete.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()); + if (requested.contains(null) || requested.size() != group.getValue().size() || complete.isEmpty() || !available.containsAll(requested) + || completeRelabel && !requested.equals(available)) + throw new ResponseStatusException(HttpStatus.CONFLICT,"换标排车须完整选择本申请全部应排托盘"); + relabelRows.put(group.getKey(), complete); + } + for (var selection : selections) { + String key = wholeSourceKey(selection); + if (chosen.containsKey(key)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"同一来源不能重复选择"); + DispatchPalletCandidate row; + if (selection.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.INBOUND_PLAN) { + String customer = requireSingleCustomerScope(wholeSources.planOwners(selection.planPalletId()), "所选来源"); + row = requireWholeCandidate(wholeSources.selectedPlan(customer, selection.planPalletId())); + } else if (selection.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.RETURN_RECEIPT) { + String customer = requireSingleCustomerScope(wholeSources.returnOwners(selection.returnReceiptId()), "所选退回来源"); + row = requireWholeCandidate(wholeSources.selectedReturn(customer, selection.returnReceiptId())); + } else { + row = relabelRows.getOrDefault(selection.relabelNo(), List.of()).stream() + .filter(c -> Objects.equals(c.warehousePalletId(), selection.warehousePalletId())).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.CONFLICT,"换标托盘来源已变化")); + } + if (!Objects.equals(warehouseId, row.overseasWarehouseId()) || row.packageCount() == null || row.packageCount() <= 0) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物必须属于本车起运仓且有真实可排数量"); + if(selection.warehousePalletId()!=null && !Objects.equals(selection.warehousePalletId(),row.warehousePalletId())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选实托身份已变化,请重新核对来源"); + if (row.customerId() == null || !row.customerId().matches("[1-9][0-9]*")) + throw new ResponseStatusException(HttpStatus.CONFLICT,"来源缺少真实客户归属"); + chosen.put(key, new WholeSource(row, selection, key, null)); + } + // 先收齐所有客户/所有实托的来源订单锁,不在拿到库存锁后再回头取另一订单。 + var owners = new java.util.TreeMap(); + for (var source : chosen.values()) { + var row = source.candidate(); + var ids = source.selection().sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.INBOUND_PLAN + ? wholeSources.sourceOrders(row.customerId(),row.warehousePalletId(),row.planPalletId()) + : row.orderId() == null ? List.of() : List.of(row.orderId()); + if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物缺少真实订单组成"); + ids.forEach(id -> owners.put(id,row.customerId())); + } + owners.forEach((id,customer) -> { if (wholeSources.lockOrder(customer,id) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"来源订单缺失或已暂扣,不能排车"); }); + var result = new java.util.ArrayList(); + for (var source : chosen.values().stream().sorted(java.util.Comparator.comparingLong(s -> + s.candidate().warehousePalletId() == null ? Long.MAX_VALUE : s.candidate().warehousePalletId()) + .thenComparingLong(s -> s.candidate().planPalletId() == null ? 0L : s.candidate().planPalletId())).toList()) { + var row = source.candidate(); + if (row.planPalletId() != null && wholeSources.lockPlan(row.customerId(),row.planPalletId()) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"计划托盘来源已变化"); + if (row.returnReceiptId() != null && wholeSources.lockReturn(row.customerId(),row.returnReceiptId()) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"退回实收来源已变化"); + String version = wholeSourceVersion(row,source.sourceKey(),true); + if (!Objects.equals(version,source.selection().sourceVersion())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选来源数量、组成或版本已变化,请重新核对"); + result.add(new WholeSource(row,source.selection(),source.sourceKey(),version)); + } + return List.copyOf(result); + } + + /** GET候选与写入锁内使用同一摘要字段;不包含客户端显示顺序或锁拥有者。 */ + public String wholeSourceVersion(DispatchPalletCandidate row, String key, boolean lockStock) { + var facts = new java.util.TreeMap(); + facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId()); + facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId()); + facts.put("palletId",row.warehousePalletId()); facts.put("packages",row.packageCount()); facts.put("sourceStage",row.sourceStage()); + facts.put("destinationType",row.destinationType()); facts.put("destinationWarehouseId",row.destinationWarehouseId()); + facts.put("destinationWarehouseCode",row.destinationWarehouseCode()); facts.put("fbaWarehouseCode",row.fbaWarehouseCode()); + facts.put("deliveryAddress",row.deliveryAddress()); facts.put("postalCode",row.postalCode()); + facts.put("contactName",row.contactName()); facts.put("contactPhone",row.contactPhone()); + facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm()); + facts.put("cartonMarks",row.relabelCartonMarks()); + if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':')))); + var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock); + if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId()))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源"); + facts.put("stock",stock); + facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId())); + facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId())); + return wholeHashes.source(facts); + } + + /** 三种可选来源必须互斥,业务键不使用任意客户参数。 */ + public static String wholeSourceKey(com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection source) { + if (source == null || source.sourceType() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择真实货物来源"); + return switch (source.sourceType()) { + case INBOUND_PLAN -> { if (source.planPalletId() == null || source.returnReceiptId() != null || source.relabelNo() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"计划托盘来源字段不匹配"); yield "PLAN:"+source.planPalletId(); } + case RETURN_RECEIPT -> { if (source.returnReceiptId() == null || source.planPalletId() != null || source.relabelNo() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回来源字段不匹配"); yield "RETURN:"+source.returnReceiptId(); } + case RELABEL -> { if (source.relabelNo() == null || source.relabelNo().isBlank() || source.warehousePalletId() == null + || source.planPalletId() != null || source.returnReceiptId() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"换标来源字段不匹配"); yield "RELABEL:"+source.relabelNo()+":"+source.warehousePalletId(); } + }; + } + + /** 只接受唯一来源快照,不以缺失候选伪造成功。 */ + private static DispatchPalletCandidate requireWholeCandidate(List rows) { + if (rows.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物不再具备排车资格"); + return rows.get(0); + } + + /** 所有明细保存成功后推进各真实客户换标申请,仍处在主单事务中。 */ + public void bindWholeRelabel(List sources, List saved) { + for (var item : saved) if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.reserve(item); + for (var no : sources.stream().filter(s -> s.selection().relabelNo() != null).map(s -> s.selection().relabelNo()).distinct().sorted().toList()) { + String customer = sources.stream().filter(s -> no.equals(s.selection().relabelNo())).findFirst().orElseThrow().candidate().customerId(); + if (mutations().markRelabel(customer,no) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT,"换标申请已被其他排车占用"); + } + } + + /** 本车零扫码取消后恢复真实换标申请及原生退货箱预占,不生成退库流水。 */ + public void releaseWholeRelabel(List items) { + var groups=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())) + .collect(Collectors.groupingBy(DispatchItem::getSourceBusinessNo,java.util.TreeMap::new,Collectors.toList())); + for(var group:groups.values()) { + var first=group.get(0);var application=mutations().lockRelabel(first.getCustomerId(),first.getSourceBusinessNo()); + if(application==null || !"PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus()))throw new ResponseStatusException(HttpStatus.CONFLICT,"换标原排车预占状态已变化"); + } + for(var item:items.stream().filter(i->"RETURN_RELABEL".equals(i.getSourceStage())).sorted(java.util.Comparator.comparing(DispatchItem::getId)).toList())returnRelabelStock.release(item); + for(var group:groups.values()){var first=group.get(0);mutations().restoreRelabel(first.getCustomerId(),first.getSourceBusinessNo());} + } @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workChanges; public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; @@ -62,7 +243,6 @@ public class DispatchService { @Resource private DispatchBolMapper bolLines; @Resource private DispatchPalletLockMapper locks; @Resource private DispatchDraftMapper drafts; - @Resource private DispatchDraftContainerMapper draftContainers; @Resource private DispatchCandidateMapper candidates; @Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; @@ -159,489 +339,11 @@ public class DispatchService { return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); } - public List activeDrafts(String operatorId) { - return drafts.findActiveByOperator(trimToNull(operatorId)).stream() - .map(item -> new DispatchDraftResponse(item.getDraftToken(), item.getPlanIdsJson(), item.getContainersJson(), containers(item.getDraftToken()))).toList(); - } - private List containers(String token) { - return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), - locks.findActiveByDraftContainer(token, c.getId()).stream() - .map(x -> x.getReturnReceiptId() == null ? String.valueOf(x.getPlanPalletId()) : "RETURN:" + x.getReturnReceiptId()).toList())).toList(); - } - @Transactional - public DispatchContainerResponse createDraftContainer(String operatorId, DispatchRequests.DraftContainer request) { - if (!Set.of(15, 30).contains(request.capacity())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托"); - } - DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); - int next = draftContainers.findByToken(request.draftToken()).stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0) + 1; - DispatchDraftContainer c = new DispatchDraftContainer(); c.setId(IdWorker.getId()); c.setDraftId(draft.getId()); c.setDraftToken(request.draftToken()); c.setSequenceNo(next); c.setCapacity(request.capacity()); c.setCreatedAt(LocalDateTime.now()); c.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(c); - return new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), List.of()); - } - private DispatchDraft ensureDraft(String operatorId, String token) { - DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId)); - if (draft != null) return draft; - LocalDateTime now = LocalDateTime.now(); draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(token); draft.setOperatorId(trimToNull(operatorId)); draft.setStatus("ACTIVE"); draft.setCreatedAt(now); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); drafts.insert(draft); return draft; - } - @Transactional - public void saveDraftSnapshot(String operatorId, DispatchRequests.DraftSnapshot request) { - String customerId = resolveCustomerScopeForPlans(request.planIds()); - LocalDateTime now = LocalDateTime.now(); - DispatchDraft draft = drafts.lockActive(request.draftToken(), trimToNull(operatorId)); - if (draft == null) { - draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(request.draftToken()); - draft.setOperatorId(trimToNull(operatorId)); draft.setCustomerId(customerId); draft.setStatus("ACTIVE"); - draft.setCreatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); - } - draft.setPlanIdsJson(request.planIds().stream().map(String::valueOf).collect(Collectors.joining(","))); - draft.setContainersJson(request.containersJson()); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); - drafts.insertOrUpdate(draft); - } - @Transactional - public void lockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); - DispatchDraftContainer boundContainer = request.draftContainerId() == null ? null : draftContainers.selectById(request.draftContainerId()); - if (boundContainer == null && request.draftContainerId() == null) { - var existing = draftContainers.findByToken(request.draftToken()); - if (existing.size() > 1) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "存在多个装车分组,请明确选择目标分组"); - if (existing.size() == 1) boundContainer = existing.get(0); - else { - boundContainer = new DispatchDraftContainer(); boundContainer.setId(IdWorker.getId()); boundContainer.setDraftId(draft.getId()); boundContainer.setDraftToken(request.draftToken()); boundContainer.setSequenceNo(1); boundContainer.setCapacity(30); boundContainer.setCreatedAt(LocalDateTime.now()); boundContainer.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(boundContainer); - } - } - if (boundContainer == null || !request.draftToken().equals(boundContainer.getDraftToken()) || !Objects.equals(boundContainer.getDraftId(), draft.getId())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不存在"); - DispatchPalletCandidate candidate = requireDraftSource(customerId, request); - LocalDateTime now = LocalDateTime.now(); - DispatchPalletLock active = activeLock(customerId, candidate); - if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) { - releaseLock(active, now); - active = null; - } - if (active != null) { - if (request.draftToken().equals(active.getDraftToken()) - && Objects.equals(trimToNull(operatorId), active.getOperatorId())) { - if (!Objects.equals(boundContainer.getId(), active.getDraftContainerId())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "托盘已锁定到其他装车分组,请先移除后重新选择"); - return; - } - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another dispatch draft: " + candidate.palletNo()); - } - // Older releases did not persist active_key=NULL; clear that inactive unique-key residue before relocking. - locks.clearInactiveActiveKey(customerId, candidate.sourcePalletKey()); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setId(IdWorker.getId()); - lock.setCustomerId(customerId); - lock.setSourcePalletKey(candidate.sourcePalletKey()); - lock.setActiveKey(candidate.sourcePalletKey()); - lock.setPlanId(candidate.planId()); - lock.setPlanPalletId(candidate.planPalletId()); - lock.setReturnReceiptId(candidate.returnReceiptId()); - lock.setWarehousePalletId(candidate.warehousePalletId()); - lock.setDraftToken(request.draftToken()); - lock.setDraftContainerId(boundContainer.getId()); - lock.setOperatorId(trimToNull(operatorId)); - lock.setStatus("ACTIVE"); - lock.setLockedAt(now); - lock.setExpiresAt(now.plusMinutes(30)); - lock.setCreatedAt(now); - lock.setUpdatedAt(now); - try { - locks.insert(lock); - } catch (DuplicateKeyException exception) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another dispatch draft: " + candidate.palletNo(), exception); - } - } - - @Transactional - public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchPalletCandidate candidate = requireDraftSource(customerId, request); - DispatchPalletLock active = activeLock(customerId, candidate); - if (active == null) return; - requireDraftOwner(active, operatorId, request.draftToken()); - if (active.getDispatchOrderId() != null) { - DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId()); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - requirePalletEditingAllowed(dispatch); - DispatchItem item = request.returnReceiptId() == null ? items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()) - : items.selectOne(new LambdaQueryWrapper().eq(DispatchItem::getCustomerId, customerId) - .eq(DispatchItem::getDispatchOrderId, dispatch.getId()).eq(DispatchItem::getReturnReceiptId, request.returnReceiptId()).last("FOR UPDATE")); - if (item != null) removeLockedItem(dispatch, item, List.of(active)); - else releaseLock(active, LocalDateTime.now()); - return; - } - releaseLock(active, LocalDateTime.now()); - } - - @Transactional - public void releaseDraft(String operatorId, String draftToken) { - List activeLocks = locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getDraftToken, draftToken) - .eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId)) - .eq(DispatchPalletLock::getStatus, "ACTIVE") - .isNull(DispatchPalletLock::getDispatchOrderId)); - LocalDateTime now = LocalDateTime.now(); - activeLocks.forEach(lock -> releaseLock(lock, now)); - draftContainers.delete(new LambdaQueryWrapper().eq(DispatchDraftContainer::getDraftToken, draftToken)); - DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); - if (draft != null) { draft.setStatus("RELEASED"); draft.setReleasedAt(now); draft.setUpdatedAt(now); drafts.updateById(draft); } - } - - @Transactional - public void deleteDraftContainer(String operatorId, String token, Long containerId) { - if (drafts.lockActive(token, trimToNull(operatorId)) == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿不存在或已失效"); - DispatchDraftContainer c = draftContainers.selectById(containerId); - if (c == null || !token.equals(c.getDraftToken())) return; - List bound = locks.selectList(new LambdaQueryWrapper().eq(DispatchPalletLock::getDraftContainerId, containerId).eq(DispatchPalletLock::getStatus, "ACTIVE")); - bound.forEach(lock -> { requireDraftOwner(lock, operatorId, token); releaseLock(lock, LocalDateTime.now()); }); - draftContainers.deleteById(containerId); - } - - @Transactional - public List preArrangeDraft( - String operatorId, DispatchRequests.PreArrangeDraft request) { - String token = trimToNull(request.draftToken()); - if (token == null || request.groups() == null || request.groups().isEmpty() || request.groups().size() > 100) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择需要预排的装车分组"); - DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId)); - if (draft == null || draft.getExpiresAt() != null && !draft.getExpiresAt().isAfter(LocalDateTime.now())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "排车草稿已失效或不属于当前操作人"); - var containers = draftContainers.findByToken(token); - var groups = new java.util.LinkedHashMap(); - Set allSources = new HashSet<>(); - for (var group : request.groups()) { - if (group == null || group.draftContainerId() == null || groups.putIfAbsent(group.draftContainerId(), group) != null) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车分组不能重复"); - Set selected = requestedGroupSources(group); - if (selected.isEmpty() || !selected.stream().allMatch(allSources::add)) - throw new ResponseStatusException(HttpStatus.CONFLICT, "每个来源只能加入一个非空装车分组"); - } - if (allSources.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); - var prepared = new java.util.ArrayList(); - Set physicalPallets = new HashSet<>(); - Set dispatchIds = new HashSet<>(); - Set actualGroups = new HashSet<>(); - for (var container : containers.stream().sorted(java.util.Comparator.comparing(DispatchDraftContainer::getId)).toList()) { - if (!Objects.equals(container.getDraftId(), draft.getId())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不属于当前草稿"); - List bound = locks.lockActiveByDraftContainer(token, container.getId()); - if (bound.isEmpty()) continue; - actualGroups.add(container.getId()); - var group = groups.get(container.getId()); - if (group == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿分组已变化,请重新加载"); - if (container.getCapacity() == null || !Set.of(15, 30).contains(container.getCapacity())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托"); - if (bound.size() > container.getCapacity()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组超过车型托位容量"); - Set actual = new HashSet<>(); - Set customers = new HashSet<>(); - Set groupDispatchIds = new HashSet<>(); - for (var lock : bound) { - requireDraftOwner(lock, operatorId, token); - actual.add(lock.getReturnReceiptId() == null ? "PLAN:" + lock.getPlanPalletId() : "RETURN:" + lock.getReturnReceiptId()); - customers.add(lock.getCustomerId()); - if (lock.getSourcePalletKey() == null || !physicalPallets.add(lock.getSourcePalletKey())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复排车"); - if (lock.getDispatchOrderId() != null) groupDispatchIds.add(lock.getDispatchOrderId()); - } - if (!actual.equals(requestedGroupSources(group)) || actual.size() != bound.size()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "托盘所属装车分组已变化,请重新加载"); - String customer = resolveCustomerScopeForSources(group.planPalletIds(), group.returnReceiptIds()); - if (!customers.equals(Set.of(customer))) throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组客户归属不一致"); - if (groupDispatchIds.size() > 1 || !groupDispatchIds.stream().allMatch(dispatchIds::add)) - throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿与排车分组不一致,请核对原排车单"); - prepared.add(new PreparedDispatchGroup(container.getId(), customer, group)); - } - if (!actualGroups.equals(groups.keySet())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不存在、为空或已失效"); - var results = new java.util.ArrayList(); - for (var group : prepared) { - var command = new DispatchRequests.PreArrange(group.request().planPalletIds(), null, null, null, - token, null, group.request().returnReceiptIds(),group.request().batchAllocations()); - var response = preArrangeContainer(group.customerId(), operatorId, command, group.containerId(), true); - results.add(new com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult(group.containerId(), response)); - } - return List.copyOf(results); - } - - private Set requestedGroupSources(DispatchRequests.DraftGroup group) { - Set result = new HashSet<>(); - for (Long id : group.planPalletIds()) if (id == null || !result.add("PLAN:" + id)) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不能重复或为空"); - for (Long id : group.returnReceiptIds()) if (id == null || !result.add("RETURN:" + id)) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "退回来源不能重复或为空"); - return result; - } - - private record PreparedDispatchGroup(Long containerId, String customerId, DispatchRequests.DraftGroup request) { } - - @Transactional - public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { - return preArrangeContainer(customerId, operatorId, request, null, false); - } - - private DispatchResponse preArrangeContainer(String customerId, String operatorId, - DispatchRequests.PreArrange request, Long expectedContainerId, boolean preserveExactReplay) { - if(StringUtils.hasText(request.fbaAppointmentNo()) && request.batchAllocations().isEmpty()) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"预约关联必须提供不可变批次键及逐票实物分配,不能仅填写预约号"); - if (StringUtils.hasText(request.relabelNo())) { - if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用"); - return preArrangeRelabel(customerId, operatorId, request); - } - if (!StringUtils.hasText(customerId)) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "No customer business data is available for dispatch"); - } - List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); - List receiptIds = request.returnReceiptIds().stream().distinct().sorted().toList(); - if (palletIds.size() + receiptIds.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); - if ((palletIds.isEmpty() && receiptIds.isEmpty()) || palletIds.size() != request.planPalletIds().size() - || receiptIds.size() != request.returnReceiptIds().size()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); - } - List selected = new java.util.ArrayList<>(); - if (!palletIds.isEmpty()) selected.addAll(candidates.lockSelected(customerId, palletIds)); - if (!receiptIds.isEmpty()) selected.addAll(returnCandidates.lockSelected(customerId, receiptIds)); - if (selected.size() != palletIds.size() + receiptIds.size()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); - } - if (selected.stream().map(DispatchPalletCandidate::sourcePalletKey).distinct().count() != selected.size()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复选择,请先拆托核对来源"); - if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) { - boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType())); - throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba - ? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车"); - } - assertNoDetainedOrders(customerId, selected); - boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .anyMatch(value -> value == null || value.isBlank()); - if (missingWarehouse) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "所选托盘中存在尚未关联海外仓的托盘"); - } - Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .map(String::trim).collect(Collectors.toSet()); - if (warehouseIds.size() != 1) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘"); - } - String draftToken = trimToNull(request.draftToken()); - Set selectedContainers = new HashSet<>(); - for (DispatchPalletCandidate candidate : selected) { - DispatchPalletLock active = activeLock(customerId, candidate); - if (draftToken != null) { - if (active == null) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet draft lock has expired: " + candidate.palletNo()); - } - requireDraftOwner(active, operatorId, draftToken); - selectedContainers.add(active.getDraftContainerId()); - } else if (active != null) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another active dispatch: " + candidate.palletNo()); - } - } - - if (draftToken != null && (selectedContainers.size() != 1 || selectedContainers.contains(null) - || expectedContainerId != null && !selectedContainers.contains(expectedContainerId))) - throw new ResponseStatusException(HttpStatus.CONFLICT, "多个装车分组必须按分组批量预排,不能合并提交"); - - LocalDateTime now = LocalDateTime.now(); - Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraftContainer(draftToken, selectedContainers.iterator().next()); - DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId); - boolean refreshing = dispatch != null; - if (preserveExactReplay && refreshing) { - var existing = items.findByDispatchOrder(customerId, dispatch.getId()); - if (existing.size() == selected.size() && existing.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()) - .equals(selected.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()))) - { appointmentAllocations.requireReplay(customerId,dispatch.getId(),request.batchAllocations()); return response(dispatch, existing); } - } - if (refreshing) { - requirePalletEditingAllowed(dispatch); - } else { - dispatch = new DispatchOrder(); - dispatch.setId(IdWorker.getId()); - dispatch.setCustomerId(customerId); - dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId())); - dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); - dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setStatus(PENDING_DEPARTURE); - dispatch.setPickedPackageCount(0); - dispatch.setShippedPackageCount(0); - dispatch.setCreatedBy(trimToNull(operatorId)); - dispatch.setCreatedAt(now); - } - dispatch.setRoute(candidateRoute(selected)); - dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); - dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); - dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); - dispatch.setItemCount(selected.size()); - dispatch.setPalletCount(selected.size()); - dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount) - .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - dispatch.setUpdatedAt(now); - if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch); - - List previousItems = items.findByDispatchOrder(customerId, dispatch.getId()); - Set selectedPalletIds = selected.stream().map(DispatchService::selectionKey) - .collect(Collectors.toSet()); - if (refreshing) { - List previousLocks = locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, customerId) - .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); - previousItems.stream().filter(item -> !selectedPalletIds.contains(selectionKey(item))) - .forEach(item -> { - previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())) - .forEach(lock -> releaseLock(lock, now)); - items.deleteById(item.getId()); - }); - } - Set existingPalletIds = previousItems.stream() - .map(DispatchService::selectionKey).filter(selectedPalletIds::contains).collect(Collectors.toSet()); - - for (DispatchPalletCandidate candidate : selected) { - DispatchPalletLock lock = activeLock(customerId, candidate); - if (draftToken != null) { - requireDraftOwner(lock, operatorId, draftToken); - lock.setDispatchOrderId(dispatch.getId()); - lock.setExpiresAt(null); - lock.setUpdatedAt(now); - locks.updateById(lock); - } else { - lock = new DispatchPalletLock(); - lock.setId(IdWorker.getId()); - lock.setCustomerId(customerId); - lock.setSourcePalletKey(candidate.sourcePalletKey()); - lock.setActiveKey(candidate.sourcePalletKey()); - lock.setPlanId(candidate.planId()); - lock.setPlanPalletId(candidate.planPalletId()); - lock.setReturnReceiptId(candidate.returnReceiptId()); - lock.setWarehousePalletId(candidate.warehousePalletId()); - lock.setDispatchOrderId(dispatch.getId()); - lock.setOperatorId(trimToNull(operatorId)); - lock.setStatus("ACTIVE"); - lock.setLockedAt(now); - lock.setCreatedAt(now); - lock.setUpdatedAt(now); - try { - locks.insert(lock); - } catch (DuplicateKeyException exception) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); - } - } - if (existingPalletIds.contains(selectionKey(candidate))) continue; - DispatchItem item = new DispatchItem(); - item.setId(IdWorker.getId()); - item.setDispatchOrderId(dispatch.getId()); - item.setCustomerId(customerId); - item.setPlanId(candidate.planId()); - item.setPlanPalletId(candidate.planPalletId()); - item.setReturnReceiptId(candidate.returnReceiptId()); - item.setWarehousePalletId(candidate.warehousePalletId()); - item.setOrderId(candidate.orderId()); - item.setOverseasWarehouseId(candidate.overseasWarehouseId()); - item.setOverseasWarehouseName(candidate.overseasWarehouseName()); - item.setOrderNo(candidate.orderNo()); - item.setOrderRefs(candidate.orderRefs()); - item.setInboundCode(candidate.inboundCode()); - item.setPalletNo(candidate.palletNo()); - item.setDestinationWarehouseId(candidate.destinationWarehouseId()); - item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); - item.setDestinationWarehouseName(candidate.destinationWarehouseName()); - item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); - item.setDestinationType(candidate.destinationType()); - item.setDeliveryAddress(candidate.deliveryAddress()); - item.setPostalCode(candidate.postalCode()); - item.setContactName(candidate.contactName()); - item.setContactPhone(candidate.contactPhone()); - item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); - item.setGrossWeightKg(candidate.grossWeightKg()); - item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); - item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); - item.setSourceStage(candidate.sourceStage()); - item.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks())); - item.setCreatedAt(now); - items.insert(item); - } - if (!refreshing) { - DispatchPdaTask pdaTask = new DispatchPdaTask(); - pdaTask.setId(IdWorker.getId()); - pdaTask.setCustomerId(customerId); - pdaTask.setDispatchOrderId(dispatch.getId()); - pdaTask.setTaskNo("DPDA" + pdaTask.getId()); - pdaTask.setStatus("PDA_PENDING"); - pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId()); - pdaTask.setPushedAt(now); - pdaTask.setCreatedAt(now); - pdaTask.setUpdatedAt(now); - pdaTasks.insert(pdaTask); - } - if (draftToken != null) { - DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); - if (draft != null) { - draft.setExpiresAt(null); - draft.setUpdatedAt(now); - drafts.updateById(draft); - } - } - // Exact grouped replay must return the same persisted timestamp precision - // on the initial call as on later reads from DATETIME(3). - DispatchOrder responseOrder = preserveExactReplay - ? Objects.requireNonNull(orders.lockById(customerId, dispatch.getId()), "Saved dispatch is missing") - : dispatch; - var assignedItems=items.findByDispatchOrder(customerId, dispatch.getId()); - appointmentAllocations.bind(customerId,dispatch.getId(),assignedItems.stream().map(i->new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList(),request.batchAllocations()); - return response(responseOrder, assignedItems); - } - - /** 在申请锁内校验全托并原子建立整单排车。 */ - @Transactional - private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { - RelabelApplication application = mutations().lockRelabel(customerId, request.relabelNo()); - if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); - } - if (!"TRUCK".equals(application.getDeliveryMethod()) - || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态"); - relabelBindingGuard.requireBound(application); - relabelMeasurements.requireMeasured(application); - List selected = java.util.stream.Stream.concat( - measuredRelabelCandidates(mutations().relabelCandidates(customerId, request.relabelNo())).stream(), - returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList(); - // 整单履约状态只能由完整托盘集合推进,普通换标与退货换标遵守同一门禁。 - Set requested = new HashSet<>(request.planPalletIds()); - if (requested.contains(null) || requested.size() != request.planPalletIds().size()) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标托盘不得为空或重复选择"); - if (selected.isEmpty() || !requested.equals(selected.stream() - .map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()))) - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标排车须一次选择本申请全部已绑定托盘,请刷新候选后完整选择"); - selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); - if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); - String warehouse = selected.get(0).overseasWarehouseId(); - if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓"); - LocalDateTime now = LocalDateTime.now(); - DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); - dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); mutations().createOrder(dispatch); - for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); mutations().createItem(item); } - DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); mutations().createTask(task); - for (DispatchItem item : mutations().items(dispatch)) { - if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId()) - && Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) { - item.setSourceStage("RETURN_RELABEL"); mutations().saveItem(item); returnRelabelStock.reserve(item); - } - } - if (mutations().markRelabel(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); - return response(dispatch, mutations().items(dispatch)); - } private void assertNoDetainedOrders(String customerId, List selected) { List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java index 887b5d037..2b7517eab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java @@ -24,8 +24,20 @@ public class ReturnRelabelDispatchStock { @Resource private ReturnInventoryService inventory; public List candidates(String customer, String orderNo, String relabelNo) { + return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo)); + } + + /** 整车候选只解析已经按仓限定且有界的原生退货记录。 */ + public List wholeCandidates(String warehouse,String orderNo) { + var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null); + if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围"); + return resolve(rows); + } + + /** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */ + private List resolve(List rows) { List result = new ArrayList<>(); - for (var candidate : candidates.nativeReturnRelabelAvailable(customer, orderNo, relabelNo)) { + for (var candidate : rows) { var pallet = physical.resolve(candidate.palletNo()); if (pallet == null || !String.valueOf(pallet.warehouseId()).equals(candidate.overseasWarehouseId()) || !Set.of("IDLE", "OCCUPIED").contains(pallet.status())) continue; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java index 0adec6c24..0f829934b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java @@ -9,17 +9,17 @@ import java.util.List; @Mapper public interface ReturnCartonStockReceiptMapper extends BaseMapper { record Shipment(Long pickId, Long dispatchItemId, String dispatchNo,Long productQuantity) { } - /** 原箱必须真实出库且逐箱归属于当前异常、派送、客户及原尾程订单。 */ + /** 整车与任务共享,原箱仍由本客户的明细、扫码及逐票归属证明真实发运。 */ @Select("SELECT p.id pick_id,di.id dispatch_item_id,d.dispatch_no,p.product_quantity FROM ll_exception_order e " + "JOIN ll_exception_return_item frozen ON frozen.exception_order_id=e.id AND frozen.customer_id=e.customer_id " + "AND frozen.outbound_pallet_item_id=#{itemId} AND frozen.return_carton_id=#{cartonId} AND frozen.inventory_position_id IS NULL AND frozen.approved_return_packages=1 AND frozen.received_packages=0 " + "JOIN ll_delivery_order delivery ON delivery.id=e.delivery_order_id AND delivery.customer_id=e.customer_id " + "JOIN ll_outbound_order o ON o.id=delivery.outbound_order_id AND o.customer_id=e.customer_id AND o.outbound_no=e.outbound_no AND o.status='OUTBOUNDED' " + "JOIN ll_outbound_pallet_item oi ON oi.outbound_order_id=o.id AND oi.customer_id=e.customer_id AND oi.id=#{itemId} " + - "JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id AND d.customer_id=e.customer_id " + + "JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id " + "JOIN ll_dispatch_item di ON di.dispatch_order_id=d.id AND di.customer_id=e.customer_id AND di.warehouse_pallet_id=oi.warehouse_pallet_id AND di.source_stage='RETURN_RELABEL' " + "AND di.inbound_code=oi.inbound_code AND COALESCE(di.destination_warehouse_code,'')=COALESCE(oi.destination_warehouse_code,'') " + - "JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id AND t.customer_id=e.customer_id " + + "JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id " + "JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND p.customer_id=e.customer_id AND p.dispatch_item_id=di.id AND p.return_carton_id=#{cartonId} AND p.inventory_position_id IS NULL AND p.packages=1 " + "JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=e.customer_id AND a.order_id=e.last_mile_order_id AND a.packages=1 " + "WHERE e.id=#{exceptionId} AND e.customer_id=#{customerId} AND e.status='PROCESSING' AND e.return_direction='OVERSEAS_WAREHOUSE'") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 83a79ea80..685804d1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -545,7 +545,7 @@ public class ExceptionOrderService { Map aggregated) { if (outbound.getDispatchOrderId() == null) return; String customerId = delivery.getCustomerId(); - DispatchPdaTask task = dispatchPdaTasks.findByDispatch(customerId, outbound.getDispatchOrderId()); + DispatchPdaTask task = dispatchPdaTasks.findByDispatch(outbound.getDispatchOrderId()); if (task == null) return; Map dispatchItemById = new LinkedHashMap<>(); dispatchItems.findByDispatchOrder(customerId, outbound.getDispatchOrderId()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java index 243a9ffb7..3216dd0b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java @@ -61,7 +61,6 @@ public class FleetHandoverInspectionService { for (var binding : current) { var originalBinding = departedBindings.get(binding.getDispatchNo()); if (!seen.add(binding.getDispatchNo()) || originalBinding == null - || !Objects.equals(originalBinding.customerId(), binding.getCustomerId()) || !Objects.equals(originalBinding.packageCount(), binding.getPackageCount())) throw conflict("客户排车归属或箱数与原始发车不一致"); } @@ -69,7 +68,6 @@ public class FleetHandoverInspectionService { for (var binding : departedBindings.values()) { var fact = dispatches.transitEvidence(binding.dispatchNo(), lock); if (!Objects.equals(binding.dispatchNo(), fact.dispatchNo()) - || !Objects.equals(binding.customerId(), fact.customerId()) || !trip.getWarehouseId().toString().equals(fact.warehouseId()) || binding.packageCount() != fact.shippedPackages()) throw conflict("实际发运与车次客户、仓库或箱数不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java index 2e0a9966f..7adc9b7b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -43,7 +43,7 @@ public class FleetHandoverPdaQuery { if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业"); var evidence = dispatches.transitEvidence(dispatchNo, false); - if (!Objects.equals(evidence.customerId(), binding.getCustomerId()) || !Long.toString(warehouseId).equals(evidence.warehouseId()) + if (!Long.toString(warehouseId).equals(evidence.warehouseId()) || !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound(); var original = history.selectList(new LambdaQueryWrapper().eq(FleetTripHistory::getTripId, trip.getId()) .eq(FleetTripHistory::getAction, "DEPART")); @@ -55,7 +55,7 @@ public class FleetHandoverPdaQuery { var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(), row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(), row.cargo().terminalAt(), row.transport())).toList(); - var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo))); + var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("dispatchNo", dispatchNo))); Handover handover = null; if (latest != null) { var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java index de5162f38..1d9ce39cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java @@ -2,8 +2,8 @@ package com.baiyue.lastleg.fleet.exception; import java.util.List; -/** Internal port response. Identity always comes from the original customer dispatch. */ -public record FleetTransitEvidence(String dispatchNo, String customerId, String warehouseId, +/** 整车发运事实;真实客户由每条Delivery.cargo携带。 */ +public record FleetTransitEvidence(String dispatchNo, String warehouseId, int shippedPackages, List deliveries) { public record Transport(String vehiclePlate, String driverName, String driverPhone) { } public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index 7c2d1aa87..00835edbf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -64,12 +64,12 @@ public class FleetBookingReservations { public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);} private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());} /** 按实际入仓来源绑定整票实收,合托不能掩盖外接承诺。 */ - public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List sources,String operator){ + public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,String customerId,List sources,String operator){ var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode)); for(var entry:bySource.entrySet()){ - var row=new FleetBookingCargoRepository(bookings).forInboundSource(dispatch.customerId(),entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue; + var row=new FleetBookingCargoRepository(bookings).forInboundSource(customerId,entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue; if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订"); - if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); + if(!Objects.equals(row.getCustomerId(),customerId)||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认"); var expected=new TreeMap(); for(var pallet:accepted.source().pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index b7f6a252d..fdf8ad43d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -38,6 +38,7 @@ public class FleetBookingService { private final FleetQuoteCandidateService quotes; @jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders; @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; + @jakarta.annotation.Resource private FleetDispatchGateway dispatches; @jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands; public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, @@ -201,10 +202,11 @@ public class FleetBookingService { planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence); } row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); - var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo)); + bindToWhole(trip,row,request.dispatchNo(),operator); + history.recordExecution(trip,trip.getRecordVersion(),"BOOKING_BIND","关联外接预订 "+bookingNo,operator); var result=booking(trip,bookingNo); if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物"); - return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false); + return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(result),false); } @Transactional public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) { @@ -225,17 +227,27 @@ public class FleetBookingService { var plan=execution.capacityPlan(tripNo); if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入"); reservations.segments(trip,plan.capacityPlan(),row.getId(),row); - String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator); - var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo)); + String dispatchNo=planning.resolveIncoming(tripNo,row.getCustomerId(),evidence,operator,"FLEET_INCOMING:"+hash(java.util.List.of(bookingNo,request.idempotencyKey()))); + trip=trip(tripNo,true);bindToWhole(trip,row,dispatchNo,operator); + history.recordExecution(trip,trip.getRecordVersion(),"BOOKING_ARRANGE","实收核对后排入本车 "+bookingNo,operator); row=booking(trip,bookingNo); if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源"); row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); - var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false); + var result=new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),false); var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator); command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash); command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command); return result; } + /** 外接来源只能绑定该车已有的一对一主单,不能将另一整车挂到当前车次。 */ + private void bindToWhole(FleetTripEntity trip,FleetBookingEntity booking,String number,String operator) { + var identity=history.dispatchIdentity(number); + if(identity.tripId()!=trip.getId())throw conflict("外接实收只能加入本车唯一排车主单"); + var accepted=reservations.receipt(booking);if(accepted==null)throw conflict("此外接预订缺少实际收货依据"); + planning.validateIncomingDispatch(number,booking.getCustomerId(),accepted.source()); + var snapshot=dispatches.lockForPlanning(number); + reservations.bindIncoming(trip,snapshot,booking.getCustomerId(),dispatches.sourceCargo(number,booking.getCustomerId()),operator); + } private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){ if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java index 9b4516de6..b46c15da9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java @@ -9,17 +9,18 @@ import org.springframework.web.server.ResponseStatusException; @Service public class FleetDispatchBindingGuard { private final FleetTripDispatchMapper bindings; - private final com.fasterxml.jackson.databind.ObjectMapper json; - public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, com.fasterxml.jackson.databind.ObjectMapper json) { this.bindings = bindings; this.json = json; } + private final FleetDispatchGateway dispatches; + public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches) { this.bindings = bindings; this.dispatches = dispatches; } /** Dispatch -> task -> binding locks; never acquire the parent trip lock from a PDA pick. */ @Transactional(propagation = Propagation.MANDATORY) public void requirePickingApproved(long dispatchId) { var binding = bindings.lockByDispatch(dispatchId); if (binding == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "请先关联车次并通过调度审核"); - var review = FleetTripReview.read(json, binding.getReviewJson()); - if (!"APPROVED".equals(review.status())) throw new ResponseStatusException(HttpStatus.CONFLICT, - "REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"); + var fact=dispatches.workflows(java.util.List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); + if(fact==null || !"ACTIVE".equals(fact.status()) || !"LOADING".equals(fact.stage()) + || !"APPROVED".equals(fact.dispatchReview().status()) || !"APPROVED".equals(fact.loadingReview().status())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"请先完成本车两级审核再扫码"); } @Transactional(propagation = Propagation.MANDATORY) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java index 5891cafe9..c34618809 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java @@ -4,9 +4,14 @@ import java.time.Instant; /** Customer dispatches remain owned by dispatch; fleet only orchestrates their physical trip. */ public interface FleetDispatchGateway { - record Snapshot(long dispatchId, String dispatchNo, String customerId, String warehouseId, int packageCount) { } + /** 审核唯一事实来自整车主单,Fleet只持有物理资源关联。 */ + record Workflow(String status,String stage,long recordVersion, + FleetTripModels.ReviewState dispatchReview,FleetTripModels.ReviewState loadingReview) { } + java.util.Map workflows(java.util.List dispatchNos); + record Snapshot(long dispatchId, String dispatchNo, String warehouseId, int packageCount) { } record SourceCargo(String inboundCode,String warehousePalletId,String palletNo,int packages,String destinationKey) { } - java.util.List sourceCargo(String dispatchNo); + java.util.List customers(String dispatchNo); + java.util.List sourceCargo(String dispatchNo,String customerId); record Assignment(String vehiclePlate, String driverName, String driverPhone) { } static String destinationKey(String type,String code,String fba,String address,String postalCode) { @@ -19,23 +24,17 @@ public interface FleetDispatchGateway { /** Locks the dispatch and rejects any picking or departure already performed. */ Snapshot lockForPlanning(String dispatchNo); - /** Allows pre-departure resource/time changes without changing cargo or recorded picking. */ - Snapshot lockForAssignment(String dispatchNo); - /** Original dispatch command; caller must first detach the dispatch from its vehicle. */ - void removeItem(String dispatchNo,long itemId); - /** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */ - void assign(Snapshot dispatch, Assignment assignment); FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock); FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock); record ReturnedReceipt(String itemId,String palletId,int packages,Instant receivedAt) { } record TerminalDelivery(String customerId,String deliveryNo,String destinationKey,int packages, Instant terminalAt,String status,long recordVersion,java.util.List receipts) { } - record TerminalEvidence(String dispatchNo,String customerId,String warehouseId,int shippedPackages, + record TerminalEvidence(String dispatchNo,String warehouseId,int shippedPackages, java.util.List deliveries) { } /** Narrow execution facts for appointment eligibility, derived from the original verified evidence. */ default TerminalEvidence terminalEvidence(String dispatchNo,boolean lock) { var evidence=transitEvidence(dispatchNo,lock); - return new TerminalEvidence(evidence.dispatchNo(),evidence.customerId(),evidence.warehouseId(),evidence.shippedPackages(), + return new TerminalEvidence(evidence.dispatchNo(),evidence.warehouseId(),evidence.shippedPackages(), evidence.deliveries().stream().map(d->new TerminalDelivery(d.cargo().customerId(),d.cargo().deliveryNo(), d.cargo().destinationKey(),d.cargo().packages(),d.cargo().terminalAt(),d.status(),d.recordVersion(), d.cargo().returnedCargo().stream().map(r->new ReturnedReceipt(r.itemId(),r.palletId(),r.packages(),r.receivedAt())).toList())).toList()); @@ -44,6 +43,4 @@ public interface FleetDispatchGateway { com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock); /** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */ void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment); - FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator, - String idempotencyKey, String remark, Instant departedAt); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java index 56a5d1fc3..cfb3ffba9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java @@ -4,8 +4,6 @@ import com.baiyue.common.core.api.PageResult; /** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */ public interface FleetDispatchPlanningGateway { - record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { } - PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size); void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt); - String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator); + String resolveIncoming(String tripNo,String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator,String commandKey); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java index 2c9fef6c2..b084f0443 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java @@ -13,31 +13,28 @@ public class FleetDispatchWorkflowQuery { public record Review(String status, String reason, java.time.Instant reviewedAt) { } public record Workflow(String tripNo, Review dispatchReview, Review loadingReview) { } private final FleetTripDispatchMapper bindings; - private final FleetTripMapper trips; - private final ObjectMapper json; + private final FleetDispatchGateway dispatches; - public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, ObjectMapper json) { - this.bindings = bindings; this.trips = trips; this.json = json; + public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches) { + this.bindings = bindings; this.dispatches = dispatches; } public Map read(List dispatchNos) { if (dispatchNos.isEmpty()) return Map.of(); var members = bindings.findByDispatchNos(dispatchNos); if (members.isEmpty()) return Map.of(); - var parents = trips.selectBatchIds(members.stream().map(FleetTripDispatch::getTripId).distinct().toList()) - .stream().collect(Collectors.toMap(FleetTripEntity::getId, trip -> trip)); + var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList()); Map result = new HashMap<>(); for (var member : members) { - var trip = parents.get(member.getTripId()); - if (trip == null) throw new IllegalStateException("关联车次不存在"); + var fact = facts.get(member.getDispatchNo()); + if (fact == null) throw new IllegalStateException("关联整车不存在"); result.put(member.getDispatchNo(), new Workflow(member.getTripNo(), - review(member.getReviewJson()), review(trip.getLoadingReviewJson()))); + review(fact.dispatchReview()), review(fact.loadingReview()))); } return result; } - private Review review(String value) { - var state = FleetTripReview.read(json, value); - return new Review(state.status(), state.reason(), state.reviewedAt()); + private Review review(FleetTripModels.ReviewState value) { + return new Review(value.status(), value.reason(), value.reviewedAt()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java index cb9951283..5ac871213 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java @@ -9,6 +9,21 @@ import java.util.List; public final class FleetExecutionModels { private FleetExecutionModels() { } + /** 整车主单自动建立资源记录的原生输入,不引用排车持久化实体。 */ + public record DispatchVehicle(String mode, Long vehicleId, String plate, String carrierName, + Integer palletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm, + List evidenceFileIds) { } + public record DispatchDriver(String mode, Long driverId, String name, String phone, String licenseNo, + java.time.LocalDate licenseExpiresOn, List evidenceFileIds, + Instant expectedTaskEndAt) { } + public record DispatchShellRequest(long dispatchId, String dispatchNo, long warehouseId, + String idempotencyKey, String commandHash, Instant plannedDepartureAt, + DispatchVehicle vehicle, DispatchDriver driver, String remark) { } + public record DispatchShell(long tripId, String tripNo, String warehouseId, String warehouseCode, + String warehouseName, DispatchVehicle vehicle, DispatchDriver driver, + String vehicleStatus) { } + public record DispatchShellLock(long tripId, String tripNo, String warehouseId) { } + public record DispatchStop(String name, String address, String destinationKey) { } public record Cargo(String dispatchNo, String customerId, String dispatchItemId, String warehousePalletId, String palletNo, int packages, String destinationKey, String destinationLabel) { } public record Manifest(String dispatchNo, String warehouseId, String vehiclePlate, String driverName, String driverPhone, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java index dccb04145..a1675fc05 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java @@ -12,6 +12,9 @@ public interface FleetResourceMapper { List candidates(@Param("kind") String kind, @Param("ids") List ids); @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}") Assignment find(@Param("kind") String kind, @Param("id") long id); + /** 草稿批量转换先锁齐资源占用身份,不能在第一个组来源之后再等下一组资源。 */ + @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} FOR UPDATE") + Assignment lock(@Param("kind")String kind,@Param("id")long id); @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id") List byTrip(@Param("tripId") long tripId); @Insert("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES(#{kind},#{id},#{tripId},#{tripNo})") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java index 580f9122a..94bd1ecf9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java @@ -86,11 +86,11 @@ public class FleetTripCompletionService { if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus()))) throw bad("请确认车辆结束任务后的实际状态"); if (!vehicle && request.vehicleStatus() != null) throw bad("结束司机任务不能修改车辆状态"); - if (vehicle) { + if (vehicle && trip.getVehicleId()!=null) { var current = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在")); requireRelease("VEHICLE", trip.getVehicleId(), trip.getId()); vehicleService.changeStatus(trip.getVehicleId(), operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), current.recordVersion())); - } else { + } else if(!vehicle && trip.getDriverId()!=null) { // A now-expired or disabled driver may end their task; this never re-enables their qualification. requireRelease("DRIVER", trip.getDriverId(), trip.getId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java index 065e973b1..cca5f30ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java @@ -12,12 +12,9 @@ public class FleetTripDispatch { @TableId(type = IdType.INPUT) private Long dispatchId; private String dispatchNo; - private String customerId; private Long tripId; private String tripNo; private Integer packageCount; private String boundBy; private LocalDateTime boundAt; - @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) - private String reviewJson; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java index 3de088e92..0962f424a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java @@ -6,6 +6,20 @@ import java.util.List; @Mapper public interface FleetTripDispatchMapper extends BaseMapper { + /** 整车主单一对一关联,无客户头部或绑定级审核。 */ + @Insert("INSERT INTO ll_fleet_trip_dispatch(dispatch_id,dispatch_no,trip_id,trip_no,package_count,bound_by,bound_at) " + + "VALUES(#{dispatchId},#{dispatchNo},#{tripId},#{tripNo},0,#{operator},#{at})") + int insertWholeDispatch(@Param("dispatchId") long dispatchId, @Param("dispatchNo") String dispatchNo, + @Param("tripId") long tripId, @Param("tripNo") String tripNo, + @Param("operator") String operator, @Param("at") java.time.LocalDateTime at); + + /** 只锁司机档案身份,历史命令恢复不以当前证照资格重新执行。 */ + @Select("SELECT id FROM ll_fleet_driver WHERE id=#{id} FOR UPDATE") + Long lockDriverIdentity(@Param("id") long id); + + /** 主单货量和唯一资源关系在父锁内同步,不按客户子单累加。 */ + @Update("UPDATE ll_fleet_trip_dispatch SET package_count=#{packages} WHERE dispatch_no=#{number}") + int updateWholeQuantity(@Param("number") String dispatchNo, @Param("packages") int packages); @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE trip_id=#{tripId} ORDER BY dispatch_id") List listByTrip(@Param("tripId") long tripId); @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no=#{dispatchNo}") 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 index 1c377d3e0..748287597 100644 --- 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 @@ -41,6 +41,98 @@ public class FleetTripExecutionService { this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations; } + /** 对排车域暴露原生资源创建入口,不暴露Fleet实体和Mapper。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) { + return history.createDispatchShell(operator, request); + } + /** 资源批量预锁不得在第一组来源锁之后才执行。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDispatchCreationResources(List vehicles,List drivers){history.lockDispatchCreationResources(vehicles,drivers);} + + /** 建立主单后的唯一关系,不让前端执行旧绑定动作。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void linkDispatchShell(String operator,long dispatchId,String dispatchNo,String tripNo) { + history.linkDispatchShell(operator,dispatchId,dispatchNo,tripNo); + } + + /** 保持排车域只依赖Fleet原生应用入口。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo) { return history.lockDispatchShell(dispatchNo); } + /** 全范围父级和资源预锁,调用方随后一次收齐全部dispatch。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockExistingDispatchShells(List dispatchNos){history.lockExistingDispatchShells(dispatchNos);} + + /** 只读返回唯一资源身份。 */ + public FleetExecutionModels.DispatchShellLock dispatchIdentity(String no){return history.dispatchIdentity(no);} + /** 汇总仅取得资源父身份锁,不申请车辆司机主档锁或释放资源。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchCompletion(String no){return history.lockDispatchCompletion(no);} + /** 装车读数与真实站序共同展示。 */ + public List dispatchStops(String no){return history.dispatchStops(no);} + /** 按排车父事务维护真实站序。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void reorderDispatchStops(String no,String operator,List keys,String reason){history.reorderDispatchStops(no,operator,keys,reason);} + /** 主单取消与资源占用释放同事务协调。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void cancelDispatchShell(String no,String operator,String reason){history.cancelDispatchShell(no,operator,reason);} + + /** 整车主流程已持父锁;容量沿原策略校验,保存实载不会扣库存或发车。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void confirmDispatchLoad(String no,String operator,String manifestHash,List cargo, + List measurements,String reason) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车已开始运输"); + var ownSegments=capacity(trip,cargo,measurements); + var plan=new FleetExecutionModels.PlannedLoad(manifestHash,cargo,measurements,ownSegments,operator,Instant.now()); + var segments=bookingReservations.segments(trip,plan,null,null); + // 同时核对原有外接预订占用;不因新主流程绕过已确认容量。 + var load=new FleetExecutionModels.ConfirmedLoad(manifestHash,cargo,measurements,segments,operator,Instant.now()); + trip.setLoadJson(write(load));history.recordExecution(trip,trip.getRecordVersion(),"DISPATCH_CONFIRM_LOAD",reason,operator); + } + + /** 已锁父级后复验已确认实载及原预约资格,不在此扣库存或锁客户单据。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void requireDispatchDeparture(String no,String manifestHash) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对"); + if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId()); + bookingReservations.segments(trip,new FleetExecutionModels.PlannedLoad(load.manifestHash(),load.cargo(),load.pallets(),load.segments(),load.operatorId(),load.confirmedAt()),null,null); + appointmentAllocations.requireDeparture(no); + } + + /** 客户单据和来源均已核对后记录实际运输状态,资源占用保留到原任务结束入口。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void departDispatchShell(String no,String operator,Instant at,String reason) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车资源任务已开始执行"); + trip.setStatus("IN_TRANSIT");trip.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + history.recordExecution(trip,trip.getRecordVersion(),"DEPART",reason,operator); + } + + /** 编辑先锁齐全部新旧身份,保持全局资源锁序。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String no,Long vehicle,Long driver) { return history.lockDispatchShell(no,vehicle,driver); } + + /** 当前资格读取不借主单头客户或客户端主档显示字段。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell dispatchResources(String no,FleetExecutionModels.DispatchVehicle vehicle,FleetExecutionModels.DispatchDriver driver) { + return history.dispatchResources(no,vehicle,driver); + } + + /** 同事务保存已核实的新资源与计划。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void saveDispatchResources(String no,String operator,FleetExecutionModels.DispatchShell actual,Instant plannedAt,String remark) { + history.saveDispatchResources(no,operator,actual,plannedAt,remark); + } + + /** 父事务内更新一车货量与真实目的地。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void updateDispatchCargo(String dispatchNo, int packages, List stops) { + history.updateDispatchCargo(dispatchNo,packages,stops); + } + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) public FleetExecutionModels.Planning capacityPlan(String tripNo) { var trip = requireTrip(tripNo, false); @@ -76,7 +168,6 @@ public class FleetTripExecutionService { private String planningBlockReason(FleetTripEntity trip, List cargo) { if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量"; - if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积"; String vehicleReason = currentVehicleBlockReason(trip, false); if (vehicleReason != null) return vehicleReason; if (cargo.isEmpty()) return "请先关联本车自有货排车单"; @@ -87,7 +178,10 @@ public class FleetTripExecutionService { /** Caller holds the parent trip lock before requesting a vehicle write lock. */ public String currentVehicleBlockReason(FleetTripEntity trip, boolean lock) { - if (trip.getVehicleId() == null) return "请先分配车辆"; + if (trip.getVehicleId() == null) return trip.getVehiclePlate()!=null && !trip.getVehiclePlate().isBlank() + && trip.getMaxPalletSpaces()!=null && trip.getMaxPalletSpaces()>0 + && trip.getMaxLoadKg()!=null && trip.getMaxLoadKg().signum()>0 + && trip.getMaxVolumeCbm()!=null && trip.getMaxVolumeCbm().signum()>0?null:"请先确认本趟临时车辆资料和实际容量"; var vehicle = (lock ? vehicles.lockById(trip.getVehicleId()) : vehicles.findById(trip.getVehicleId())).orElse(null); if (vehicle == null || !"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) return "车辆当前不可用,请重新分配车辆并核对计划容量"; @@ -107,22 +201,7 @@ public class FleetTripExecutionService { 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) { @@ -170,83 +249,22 @@ public class FleetTripExecutionService { } private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) { if (!gate.canPick()) return gate; - var review = FleetTripReview.read(json, binding.getReviewJson()); + var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); + if(fact==null)return gate.withPicking(false,"整车审核事实缺失"); + var review = fact.dispatchReview(); 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() : "请先通过理货审核再发车"; + var members=bindings.listByTrip(trip.getId()); + var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList()); + return !members.isEmpty() && members.stream().allMatch(b->facts.containsKey(b.getDispatchNo()) && "APPROVED".equals(facts.get(b.getDispatchNo()).loadingReview().status()))?null:"请先通过本车理货审核再发车"; } - @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); - // Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result. - for(var member:members) appointmentAllocations.requireDeparture(member.getDispatchNo()); - Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); - 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(); @@ -261,7 +279,8 @@ public class FleetTripExecutionService { 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() + " 尚未通过调度审核"; + var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); + if (fact==null || !"APPROVED".equals(fact.dispatchReview().status())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核"; } for (var manifest : manifests) { if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致"; 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 index 24ae4d6f6..f92b9dd3e 100644 --- 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 @@ -36,10 +36,10 @@ public final class FleetTripModels { 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 DispatchBinding(String dispatchNo, int packageCount, String boundBy, Instant boundAt, ReviewState review, List appointmentBatches) { } /** Original bound references only; these quantities never authorize current allocation. */ - public record PreviousDispatch(String dispatchNo,String customerId,long recordVersion,Instant recordedAt, + public record PreviousDispatch(String dispatchNo,long recordVersion,Instant recordedAt, List appointmentBatches) { } public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName, String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java deleted file mode 100644 index 7e5848ca2..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.baiyue.lastleg.fleet.trip; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.security.core.context.SecurityContextHolder; -import java.util.*; -import java.time.LocalDateTime; -import java.time.ZoneOffset; - -/** Shared by dispatch and appointment consumers; all business writes commit together. */ -@Service -public class FleetTripPreparationService { - public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion, - @Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId, - @NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos, - @NotBlank @Size(min=8,max=128) String idempotencyKey) { } - private final FleetTripService service; - private final FleetTripMapper trips; - private final FleetCapacityOfferCommandMapper commands; - private final ObjectMapper json; - public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) { - this.service=service;this.trips=trips;this.commands=commands;this.json=json; - } - @Transactional - public FleetTripModels.Detail prepare(String operator,Prepare request) { - boolean create=request.tripNo()==null||request.tripNo().isBlank(); - if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); } - permission("platform:fleet:trip:edit"); - if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择"); - if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机"); - if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划"); - var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo()); - if(!create&&initial==null)throw conflict("所选车次不存在"); - String payload=write(request); - if(initial!=null) { - var locked=trips.lockByNo(initial.getTripNo()); - var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); - if(previous!=null) { - if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); - try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} - } - if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次"); - } - FleetTripModels.Detail detail; - if(create) { - var p=request.plan(); - detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey())); - // Concurrent same-key create may have committed while waiting for its unique constraint. - var locked=trips.lockByNo(detail.trip().tripNo()); - var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); - if(previous!=null) { - if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); - try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} - } - detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源")); - } else { - detail=service.detail(request.tripNo()); - if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对"); - } - for(String number:request.dispatchNos().stream().sorted().toList()) - detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次")); - var row=trips.findByNo(detail.trip().tripNo()); - var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator); - command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload)); - command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command); - return detail; - } - private static void permission(String name) { - var auth=SecurityContextHolder.getContext().getAuthentication(); - if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限"); - } - private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} - private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception 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/FleetTripRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java index cf51f9ffb..1f9bbd79a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java @@ -24,6 +24,16 @@ public class FleetTripRepository { public FleetTripEntity lockCreated(String operator,String key) { return trips.lockCreated(operator,key); } /** 新增车次记录。 */ public int insertTrip(FleetTripEntity row) { return trips.insert(row); } + /** 保存整车的一对一资源关系,不伪造客户或子排车。 */ + public int insertWholeDispatch(long dispatchId, String dispatchNo, FleetTripEntity trip, String operator) { + return bindings.insertWholeDispatch(dispatchId, dispatchNo, trip.getId(), trip.getTripNo(), operator, trip.getCreatedAt()); + } + /** 普通读定位唯一整车关系,父锁后必须重新验证。 */ + public FleetTripDispatch wholeBinding(String number) { return bindings.findByDispatchNo(number); } + /** 只锁司机身份,不因资格过期阻断原命令恢复。 */ + public Long lockDriverIdentity(long id) { return bindings.lockDriverIdentity(id); } + /** 同步整车资源快照货量。 */ + public int wholeQuantity(String number, int packages) { return bindings.updateWholeQuantity(number,packages); } /** 按业务号查询车次。 */ public FleetTripEntity findTrip(String number) { return trips.findByNo(number); } /** 按业务号加锁读取车次。 */ @@ -50,6 +60,8 @@ public class FleetTripRepository { public int insertHistory(FleetTripHistory row) { return histories.insert(row); } /** 查询资源实时占用。 */ public FleetResourceMapper.Assignment resource(String kind,long id) { return resources.find(kind,id); } + /** 批量正式建车的资源占用在取来源锁之前统一锁齐。 */ + public FleetResourceMapper.Assignment lockResource(String kind,long id){return resources.lock(kind,id);} /** 批量读取候选资源占用,避免列表逐项查询。 */ public List resourceOccupancy(String kind,List ids) { return resources.candidates(kind,ids); } /** 唯一键防止车辆或司机同时承接两趟任务。 */ 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 index 5d44b4731..0c58654a6 100644 --- 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 @@ -93,11 +93,7 @@ public class FleetTripService { }).toList(); } - /** 创建派送用途车次,保留原有请求幂等语义。 */ - @Transactional - public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) { - return createPlan(operator, request, "DELIVERY"); - } + /** 取货车次首次历史即标识取货用途,复用统一的资源和计划规则。 */ @Transactional @@ -105,6 +101,264 @@ public class FleetTripService { return createPlan(operator, request, "PICKUP"); } + /** 主单创建事务内建立空资源记录,允许未知计划/临时资料,审核时再核齐。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) { + var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); + var trip = new FleetTripEntity(); + trip.setId(IdWorker.getId()); + trip.setTripNo("TR-" + new java.math.BigInteger(UUID.randomUUID().toString().replace("-", ""), 16).toString(36).toUpperCase(Locale.ROOT)); + trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(request.commandHash()); + trip.setStatus("PLANNED"); trip.setTripPurpose("DELIVERY"); trip.setWarehouseId(request.warehouseId()); + trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); + trip.setPlannedDepartureAt(request.plannedDepartureAt() == null ? null : utc(request.plannedDepartureAt())); + trip.setStopsJson("[]"); trip.setRemark(request.remark()); trip.setRecordVersion(0L); + trip.setCreatedBy(operator); trip.setUpdatedBy(operator); trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt()); + if (repository.insertTrip(trip) != 1) throw conflict("整车资源记录创建失败"); + + // 创建记录首先取得本trip写锁,随后只按车辆→司机/资源的顺序取锁。 + var vehicle = request.vehicle(); + String vehicleStatus = null; + if (vehicle != null && "MASTER".equals(vehicle.mode())) { + if (vehicle.vehicleId() == null) throw bad("请选择车辆档案"); + var actual = vehicles.lockById(vehicle.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); + if (!"OVERSEAS".equals(actual.region()) || !"IN_SERVICE".equals(actual.status())) throw bad("请选择在役海外车辆"); + vehicle = new FleetExecutionModels.DispatchVehicle("MASTER", actual.id(), actual.plateNumber(), + actual.driverOrDispatchCompany(), actual.maxPalletSpaces(), actual.maxLoadKg(), actual.maxVolumeCbm(), List.of()); + vehicleStatus = actual.status(); + } else if (vehicle != null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId() != null)) { + throw bad("临时车辆不得关联或伪造车辆档案"); + } + var driver = request.driver(); + if (driver != null && "MASTER".equals(driver.mode())) { + if (driver.driverId() == null) throw bad("请选择司机档案"); + var actual = drivers.requireDispatchable(driver.driverId(), driver.expectedTaskEndAt()); + if (!"OVERSEAS".equals(actual.details().region())) throw bad("请选择海外司机"); + driver = new FleetExecutionModels.DispatchDriver("MASTER", Long.valueOf(actual.id()), actual.details().name(), + actual.details().phone(), actual.details().licenseNumber(), actual.details().licenseExpiresOn(), + actual.licenseFileId() == null ? List.of() : List.of(Long.valueOf(actual.licenseFileId())), driver.expectedTaskEndAt()); + } else if (driver != null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId() != null)) { + throw bad("临时司机不得关联或伪造司机档案"); + } + if (vehicle != null) { + if (vehicle.vehicleId() != null) reserve("VEHICLE", vehicle.vehicleId(), trip); + trip.setVehicleId(vehicle.vehicleId()); trip.setVehiclePlate(vehicle.plate()); + trip.setMaxPalletSpaces(vehicle.palletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); + } + if (driver != null) { + if (driver.driverId() != null) reserve("DRIVER", driver.driverId(), trip); + trip.setDriverId(driver.driverId()); trip.setDriverName(driver.name()); trip.setDriverPhone(driver.phone()); + trip.setExpectedDriverEndAt(driver.expectedTaskEndAt() == null ? null : utc(driver.expectedTaskEndAt())); + } + if (repository.updateTrip(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()) + .eq(FleetTripEntity::getRecordVersion, 0L)) != 1) + throw conflict("整车资源关联保存失败"); + return new FleetExecutionModels.DispatchShell(trip.getId(), trip.getTripNo(), warehouse.id(), warehouse.code(), + warehouse.name(), vehicle, driver, vehicleStatus); + } + + /** 批量草稿转换先统一锁车辆、司机和占用,后续新trip为事务内新身份且不可被其他会话引用。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDispatchCreationResources(List requestedVehicles,List requestedDrivers) { + var vehicleIds=new TreeSet();var driverIds=new TreeSet(); + for(var vehicle:requestedVehicles)if(vehicle!=null && "MASTER".equals(vehicle.mode())) { + if(vehicle.vehicleId()==null || vehicle.vehicleId()<=0)throw bad("请选择真实车辆档案");vehicleIds.add(vehicle.vehicleId()); + } + for(var driver:requestedDrivers)if(driver!=null && "MASTER".equals(driver.mode())) { + if(driver.driverId()==null || driver.driverId()<=0)throw bad("请选择真实司机档案");driverIds.add(driver.driverId()); + } + for(var id:vehicleIds)if(vehicles.lockById(id).isEmpty())throw bad("车辆档案不存在"); + for(var id:driverIds)if(repository.lockDriverIdentity(id)==null)throw bad("司机档案不存在"); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + } + + /** 主单插入后再建立关系,允许数据库同时约束trip和dispatch两个外键。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void linkDispatchShell(String operator,long dispatchId,String dispatchNo,String tripNo) { + var trip=repository.lockTrip(tripNo); + if(trip==null || !"DELIVERY".equals(trip.getTripPurpose()) || !repository.listBindings(trip.getId()).isEmpty()) + throw conflict("整车资源已关联其他主单"); + if(repository.insertWholeDispatch(dispatchId,dispatchNo,trip,operator)!=1) throw conflict("整车唯一资源关系未保存"); + } + + /** 新主流程先锁trip、车辆、司机身份,再由调用方锁dispatch。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo) { + return lockDispatchShell(dispatchNo,null,null); + } + + /** 原预约跨车改量先收齐所有trip,再收齐全部车辆、司机和占用,尚不锁dispatch。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockExistingDispatchShells(List numbers) { + if(numbers==null || numbers.isEmpty() || numbers.size()>1000 || numbers.stream().anyMatch(n->n==null || n.isBlank()) || new HashSet<>(numbers).size()!=numbers.size())throw bad("请选择完整且不重复的原排车范围"); + var observed=new TreeMap(); + for(var number:numbers) { + var binding=repository.wholeBinding(number); + if(binding==null || observed.putIfAbsent(binding.getTripId(),binding)!=null)throw conflict("原排车资源范围缺失或非一对一关联"); + } + var locked=new ArrayList(); + for(var entry:observed.entrySet()) { + var trip=repository.lockTrip(entry.getValue().getTripNo()); + if(trip==null || !entry.getKey().equals(trip.getId()))throw conflict("原排车资源范围已变化"); + locked.add(trip); + } + for(var trip:locked) { + var bindings=repository.listBindings(trip.getId());var original=observed.get(trip.getId()); + if(bindings.size()!=1 || !Objects.equals(bindings.get(0).getDispatchId(),original.getDispatchId()) || !Objects.equals(bindings.get(0).getDispatchNo(),original.getDispatchNo()))throw conflict("原排车一对一关联已变化,请重读完整范围"); + } + var vehicleIds=new TreeSet();var driverIds=new TreeSet(); + for(var trip:locked){if(trip.getVehicleId()!=null)vehicleIds.add(trip.getVehicleId());if(trip.getDriverId()!=null)driverIds.add(trip.getDriverId());} + for(var id:vehicleIds)vehicles.lockById(id); + for(var id:driverIds)repository.lockDriverIdentity(id); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + } + + /** 只读定位本车资源身份,不取得写资格。 */ + @Transactional(readOnly=true) + public FleetExecutionModels.DispatchShellLock dispatchIdentity(String dispatchNo) { + var binding=repository.wholeBinding(dispatchNo);if(binding==null)throw conflict("整车资源关系缺失"); + var trip=repository.findTrip(binding.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 签收汇总只锁资源父记录身份,不变更或释放车辆司机,不申请其主档锁。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchCompletion(String no) { + var observed=repository.wholeBinding(no);if(observed==null)throw conflict("整车资源关系缺失"); + var trip=repository.lockTrip(observed.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + var bindings=repository.listBindings(trip.getId());if(bindings.size()!=1 || !no.equals(bindings.get(0).getDispatchNo()))throw conflict("整车资源关系已变化"); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 实载确认沿现有站序核对卸货位置,不能由客户端自由编造站点。 */ + @Transactional(readOnly=true) + public List dispatchStops(String no) { + var identity=dispatchIdentity(no);var trip=repository.findTrip(identity.tripNo()); + return read(trip.getStopsJson(),new TypeReference>(){}).stream() + .map(stop->new FleetExecutionModels.DispatchStop(stop.name(),stop.address(),stop.destinationKey())).toList(); + } + + /** 仅重排现有真实目的地,保留起点和外接预订;不接受客户端伪造名称或地址。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void reorderDispatchStops(String no,String operator,List destinationKeys,String reason) { + var identity=dispatchIdentity(no);var trip=repository.lockTrip(identity.tripNo()); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅未发车的路线可调整"); + var previous=read(trip.getStopsJson(),new TypeReference>(){}); + if(previous.isEmpty())throw conflict("本车缺少真实起运仓"); + var byKey=new LinkedHashMap(); + for(int index=1;index199 + || destinationKeys.stream().anyMatch(Objects::isNull) || new HashSet<>(destinationKeys).size()!=destinationKeys.size() + || !new HashSet<>(destinationKeys).equals(byKey.keySet()))throw bad("请完整重排本车既有目的地,保留外接卸货点"); + var stops=new ArrayList();stops.add(previous.get(0));destinationKeys.forEach(key->stops.add(byKey.get(key))); + bookingReservations.requireRoute(trip,stops);long version=trip.getRecordVersion(); + if(repository.updateTrip(null,new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version) + .set(FleetTripEntity::getStopsJson,write(stops)).set(FleetTripEntity::getPlannedLoadJson,null).set(FleetTripEntity::getLoadJson,null) + .set(FleetTripEntity::getLoadingReviewJson,null).set(FleetTripEntity::getRecordVersion,version+1).set(FleetTripEntity::getUpdatedBy,operator).set(FleetTripEntity::getUpdatedAt,now()))!=1)throw conflict("资源路线版本已变化"); + record(repository.findTrip(identity.tripNo()),"DISPATCH_ROUTE",reason,operator); + } + + /** 主单零扫码取消时协调本车资源,外接已确认业务沿原取消前置保留。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void cancelDispatchShell(String no,String operator,String reason) { + var identity=dispatchIdentity(no);var trip=repository.lockTrip(identity.tripNo()); + if(!"PLANNED".equals(trip.getStatus()))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,trip.getRecordVersion(),"DISPATCH_CANCEL",reason,operator); + } + + /** 编辑资源时先锁齐旧新车辆,再锁齐旧新司机,禁止持司机锁回头抢新车辆。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo,Long vehicleId,Long driverId) { + var observed = repository.wholeBinding(dispatchNo); + if (observed == null) throw conflict("整车资源关联缺失,请核对原排车单"); + var trip = repository.lockTrip(observed.getTripNo()); + if (trip == null) throw conflict("整车资源记录缺失"); + var bindings = repository.listBindings(trip.getId()); + if (bindings.size() != 1 || !dispatchNo.equals(bindings.get(0).getDispatchNo())) throw conflict("整车资源关联已变化"); + var vehicleIds=new TreeSet(); if(trip.getVehicleId()!=null)vehicleIds.add(trip.getVehicleId());if(vehicleId!=null)vehicleIds.add(vehicleId); + for(var id:vehicleIds) vehicles.lockById(id); + var driverIds=new TreeSet();if(trip.getDriverId()!=null)driverIds.add(trip.getDriverId());if(driverId!=null)driverIds.add(driverId); + for(var id:driverIds) repository.lockDriverIdentity(id); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 已持父级全部身份锁后,复验资源实际资格;快照不信任请求中的主档名称和容量。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell dispatchResources(String dispatchNo,FleetExecutionModels.DispatchVehicle vehicle, + FleetExecutionModels.DispatchDriver driver) { + var binding=repository.wholeBinding(dispatchNo);if(binding==null)throw conflict("整车资源关系缺失"); + var trip=repository.lockTrip(binding.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + String vehicleStatus=null; + if(vehicle!=null && "MASTER".equals(vehicle.mode())) { + if(vehicle.vehicleId()==null)throw bad("请选择车辆档案"); + var actual=vehicles.findById(vehicle.vehicleId()).orElseThrow(()->bad("车辆档案不存在")); + if(!"OVERSEAS".equals(actual.region()) || !"IN_SERVICE".equals(actual.status()))throw conflict("车辆当前不可执行海外任务"); + var assignment=repository.resource("VEHICLE",actual.id()); + if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("车辆已被其他车次占用"); + vehicle=new FleetExecutionModels.DispatchVehicle("MASTER",actual.id(),actual.plateNumber(),actual.driverOrDispatchCompany(), + actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),List.of());vehicleStatus=actual.status(); + } else if(vehicle!=null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId()!=null)) throw bad("临时车辆不得关联车辆档案"); + if(driver!=null && "MASTER".equals(driver.mode())) { + if(driver.driverId()==null)throw bad("请选择司机档案"); + var actual=drivers.requireDispatchable(driver.driverId(),driver.expectedTaskEndAt()); + if(!"OVERSEAS".equals(actual.details().region()))throw conflict("司机当前不可执行海外任务"); + var assignment=repository.resource("DRIVER",driver.driverId()); + if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("司机已被其他车次占用"); + driver=new FleetExecutionModels.DispatchDriver("MASTER",Long.valueOf(actual.id()),actual.details().name(),actual.details().phone(), + actual.details().licenseNumber(),actual.details().licenseExpiresOn(),actual.licenseFileId()==null?List.of():List.of(Long.valueOf(actual.licenseFileId())),driver.expectedTaskEndAt()); + } else if(driver!=null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId()!=null))throw bad("临时司机不得关联司机档案"); + return new FleetExecutionModels.DispatchShell(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString(),trip.getWarehouseCode(),trip.getWarehouseName(),vehicle,driver,vehicleStatus); + } + + /** 已核对来源的更新原子替换资源占用,保留本趟附加能力和旧资源历史。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void saveDispatchResources(String dispatchNo,String operator,FleetExecutionModels.DispatchShell actual,Instant plannedAt,String remark) { + var trip=repository.lockTrip(actual.tripNo());if(trip==null || !"PLANNED".equals(trip.getStatus()))throw conflict("本车资源任务已开始执行"); + var v=actual.vehicle();var d=actual.driver();Long vehicleId=v==null?null:v.vehicleId(),driverId=d==null?null:d.driverId(); + if(trip.getVehicleId()!=null && !Objects.equals(trip.getVehicleId(),vehicleId))release("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null && !Objects.equals(trip.getDriverId(),driverId))release("DRIVER",trip.getDriverId(),trip.getId()); + if(vehicleId!=null)reserve("VEHICLE",vehicleId,trip);if(driverId!=null)reserve("DRIVER",driverId,trip); + long version=trip.getRecordVersion(); + var update=new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version) + .set(FleetTripEntity::getVehicleId,vehicleId).set(FleetTripEntity::getVehiclePlate,v==null?null:v.plate()) + .set(FleetTripEntity::getMaxPalletSpaces,v==null?null:v.palletSpaces()).set(FleetTripEntity::getMaxLoadKg,v==null?null:v.maxLoadKg()) + .set(FleetTripEntity::getMaxVolumeCbm,v==null?null:v.maxVolumeCbm()).set(FleetTripEntity::getDriverId,driverId) + .set(FleetTripEntity::getDriverName,d==null?null:d.name()).set(FleetTripEntity::getDriverPhone,d==null?null:d.phone()) + .set(FleetTripEntity::getExpectedDriverEndAt,d==null||d.expectedTaskEndAt()==null?null:utc(d.expectedTaskEndAt())) + .set(FleetTripEntity::getPlannedDepartureAt,plannedAt==null?null:utc(plannedAt)).set(FleetTripEntity::getRemark,remark) + .set(FleetTripEntity::getPlannedLoadJson,null).set(FleetTripEntity::getLoadJson,null).set(FleetTripEntity::getLoadingReviewJson,null) + .set(FleetTripEntity::getRecordVersion,version+1).set(FleetTripEntity::getUpdatedAt,now()).set(FleetTripEntity::getUpdatedBy,operator); + if(repository.updateTrip(null,update)!=1)throw conflict("整车资源版本已变化"); + record(repository.findTrip(actual.tripNo()),"DISPATCH_EDIT",remark,operator); + } + + /** 父锁内同步真实货量和目的地;已确认外接站点顺序不得重排。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void updateDispatchCargo(String dispatchNo, int packages, List destinations) { + var binding = repository.wholeBinding(dispatchNo); + if (binding == null) throw conflict("整车关联缺失"); + var trip = repository.lockTrip(binding.getTripNo()); + if (trip == null || !"PLANNED".equals(trip.getStatus())) throw conflict("仅未发车整车可以调整货物"); + List existing = read(trip.getStopsJson(),new TypeReference>() { }); + var stops = new ArrayList<>(existing); + if (stops.isEmpty()) stops.add(new FleetTripModels.Stop(trip.getWarehouseName(),null,"ORIGIN:"+trip.getWarehouseId())); + var keys = new HashSet(); stops.forEach(stop -> keys.add(stop.destinationKey())); + for (var stop : destinations) if (keys.add(stop.destinationKey())) stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey())); + trip.setStopsJson(write(stops)); trip.setPlannedLoadJson(null); trip.setLoadJson(null); trip.setLoadingReviewJson(null); + trip.setRecordVersion(trip.getRecordVersion()+1); trip.setUpdatedAt(now()); + if (repository.updateTrip(trip,new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId())) != 1 + || repository.wholeQuantity(dispatchNo,packages) != 1) throw conflict("整车货物快照保存失败"); + } + /** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */ private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) { String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request)); @@ -136,6 +390,7 @@ public class FleetTripService { @Transactional public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); + if(!repository.listBindings(trip.getId()).isEmpty())throw conflict("本车资源请通过排车主单调整并重新审核"); requireReason(request.reason()); // 分配始终先锁车辆再锁司机,唯一占用记录作为并发分配的最终约束。 var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); @@ -161,21 +416,17 @@ public class FleetTripService { 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 : repository.listBindings(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); var currentBindings=bindingResponses(trip); var history=previousDispatches(trip,currentBindings); - return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason()); + return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, loadingReview(trip),history.dispatches(),history.unavailableReason()); } /** 修改未发车计划;取货车次保留原单取货点和目的仓。 */ @Transactional public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); + if(!repository.listBindings(trip.getId()).isEmpty())throw conflict("本车计划请通过排车主单调整并重新审核"); requireReason(request.reason()); validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); bookingReservations.requireRoute(trip, request.stops()); @@ -184,37 +435,13 @@ public class FleetTripService { if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线"); boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt())) && trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt())); - for (var binding : repository.listBindings(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 = repository.lockBinding(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 (repository.updateBinding(binding) != 1) throw conflict("排车单审核保存失败"); - trip.setLoadingReviewJson(null); - persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator); - return detail(trip); - } + /** 取消未执行车次,释放资源并将取货原单退回待安排。 */ @Transactional @@ -238,43 +465,9 @@ public class FleetTripService { return detail(trip); } - @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; - /** 读取当前车次可关联的客户排车候选。 */ - @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) - public com.baiyue.common.core.api.PageResult dispatchCandidates(String tripNo,String keyword,int page,int size) { - var trip=repository.findTrip(tripNo); - if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在"); - if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物"); - return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size); - } - /** 关联同仓客户排车单并同步车辆司机,禁止取货车次混用。 */ - @Transactional - public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { - var trip = planned(tripNo, request.recordVersion()); - if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次不能关联出库排车单"); - requireReason(request.reason()); - if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); - var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); - appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId()); - if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓"); - var bound = repository.lockBinding(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 (repository.insertBinding(binding) != 1) throw conflict("排车单关联失败"); } - catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); } - bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator); - var route = matchedStops(response(trip).stops(), dispatches.manifest(dispatch.dispatchNo(), false).cargo()); - validatePlan(instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()), route); - bookingReservations.requireRoute(trip, route); - trip.setStopsJson(write(route)); - dispatches.assign(dispatch, assignment(trip)); - persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator); - return detail(trip); - } + + static List matchedStops(List existing, List cargo) { // Keep established stop indexes/order: confirmed bookings may refer to them. @@ -297,20 +490,7 @@ public class FleetTripService { return List.copyOf(result); } - /** 解除尚未执行的排车关联并使原装载审核失效。 */ - @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 = repository.lockBinding(dispatch.dispatchId()); - if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); - dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null)); - if (repository.deleteBinding(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) { @@ -362,14 +542,25 @@ public class FleetTripService { var currentBindings=bindingResponses(trip); var history=previousDispatches(trip,currentBindings); return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null - : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason()); + : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, loadingReview(trip),history.dispatches(),history.unavailableReason()); } - /** 将客户隔离的排车关联转换为详情展示模型。 */ + /** Fleet只展示主单当前审核事实;没有主单的取货记录没有排车审核。 */ + private FleetTripModels.ReviewState loadingReview(FleetTripEntity trip) { + var members=repository.listBindings(trip.getId()); + if(members.isEmpty())return new FleetTripModels.ReviewState("NOT_SUBMITTED",null,null,null,null); + if(members.size()!=1)throw conflict("车次必须唯一关联整车主单"); + var fact=dispatches.workflows(List.of(members.get(0).getDispatchNo())).get(members.get(0).getDispatchNo()); + if(fact==null)throw conflict("资源关联缺少真实整车审核事实"); + return fact.loadingReview(); + } + /** 将整车关联和逐客户预约引用转换为详情展示模型。 */ private List bindingResponses(FleetTripEntity trip) { var current=repository.listBindings(trip.getId()); var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList()); + var facts=dispatches.workflows(current.stream().map(FleetTripDispatch::getDispatchNo).toList()); + if(current.stream().anyMatch(b->!facts.containsKey(b.getDispatchNo())))throw conflict("资源关联缺少真实整车审核事实"); return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(), - binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList(); + binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), facts.get(binding.getDispatchNo()).dispatchReview(),references.getOrDefault(binding.getDispatchId(),List.of()))).toList(); } /** 组装当前车次关联单据和预约追溯信息。 */ private record AppointmentHistory(List dispatches,String unavailableReason) { } @@ -389,9 +580,9 @@ public class FleetTripService { var references=new ArrayList(); for(var batch:binding.appointmentBatches()) { var missing=batch.sources().stream().filter(source->seen.add(referenceKey(batch,source))).toList(); - if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.customerId(),missing)); + if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.recordVersion(),batch.customerId(),missing)); } - if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),binding.customerId(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references))); + if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references))); } } return new AppointmentHistory(List.copyOf(previous),unavailableReason); @@ -430,8 +621,6 @@ public class FleetTripService { 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)) repository.updateBindings(new LambdaUpdateWrapper() - .eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null)); trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator); if (repository.updateTrip(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1) throw conflict("车次已被修改,请刷新后重试"); @@ -444,7 +633,7 @@ public class FleetTripService { 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()), + : read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), loadingReview(trip), trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId())))); if (repository.insertHistory(event) != 1) throw conflict("车次历史保存失败"); } @@ -463,6 +652,8 @@ public class FleetTripService { } /** 按指定模型解析历史 JSON,损坏记录明确报错。 */ private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } } + /** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */ + private T read(String value, TypeReference type) { try { return json.readValue(value,type); } catch (JsonProcessingException error) { throw new IllegalStateException("车次集合事实读取失败",error); } } /** 为幂等请求生成不可逆内容摘要。 */ 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); } } /** 使用统一时钟获取 UTC 业务时间。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java index d7fbcd061..a54777c1d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java @@ -51,7 +51,7 @@ public class PlatformFleetCapacityOfferController { public ApiResponse incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));} @PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')") public ApiResponse receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));} - @PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')") + @PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:dispatch:create')") public ApiResponse arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));} @PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')") 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 index 21cec563c..ff93ee439 100644 --- 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 @@ -27,13 +27,7 @@ public class PlatformFleetTripController { throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历"); return ApiResponse.success(calendar.calendar(from,to)); } - @jakarta.annotation.Resource private FleetTripPreparationService preparation; - @PostMapping("/prepare") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody FleetTripPreparationService.Prepare request) { - return ApiResponse.success(preparation.prepare(operator(user),request)); - } + private final FleetTripService service; private final FleetTripExecutionService execution; private final FleetTripCompletionService completion; @@ -47,11 +41,7 @@ public class PlatformFleetTripController { @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) { @@ -78,43 +68,17 @@ public class PlatformFleetTripController { public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) { return ApiResponse.success(service.cancel(tripNo, operator(user), request)); } - @GetMapping("/{tripNo}/dispatch-candidates") - @PreAuthorize("hasAuthority('platform:fleet:trip:list')") - public ApiResponse> dispatchCandidates(@PathVariable String tripNo, - @RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page, - @RequestParam(defaultValue="20") @Min(1) @Max(100) int size) { - return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size)); - } - @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)); } @@ -127,12 +91,7 @@ public class PlatformFleetTripController { @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(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java index 216503c41..49bdc2429 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -44,7 +44,8 @@ public interface InboundFbaAppointmentQueryMapper { @Select(""" SELECT COUNT(DISTINCT d.id) FROM ll_dispatch_appointment_allocation a - JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id + JOIN ll_dispatch_order d ON d.id=a.dispatch_id + JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id AND i.plan_pallet_id=a.plan_pallet_id WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',#{appointmentId}) AND d.status<>'CANCELLED' """) long countActiveDispatches(@Param("appointmentId") Long appointmentId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java index 9bf6eae1e..17fd19e7d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java @@ -9,5 +9,5 @@ public interface FbaAppointmentExecutionPort { record Scope(String version,List members) { } Scope inspect(List members); /** Locks original trips and selected customers' dispatch/execution rows before core batch locks. */ - Scope lockAndInspect(List members,String expectedVersion); + Scope lockAndInspect(List members,String expectedVersion,String operator); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java index 17bff899f..9ec4742de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java @@ -11,8 +11,8 @@ public interface FbaAppointmentTransportPort { int originalPackages, int retainedPackages, String laterBatchKey) { } record Result(List vehicles, List nextActions) { } Scope inspect(List batchKeys); - /** Lock order: trip -> dispatch -> outbound/PDA -> appointment; called BEFORE core locks batches. */ - Scope lockAndInspect(List batchKeys, String expectedVersion); + /** 锁全范围trip→车辆/司机资源→dispatch;必须先于核心批次与来源锁。 */ + Scope lockAndInspect(List batchKeys, String expectedVersion,String operator); /** Same transaction as core allocation. Throw on failure so physical/allocation changes roll back together. */ - Result apply(Scope locked, List moves, String operator, String reason); + Result apply(Scope locked, List moves, String operator, String reason,String parentCommandKey); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java index 0edb7b049..5a07887fc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java @@ -72,7 +72,7 @@ public class FbaReceivingService { public Result propose(String operator,Propose request) { return command("RECEIVING_PROPOSE",operator,request.idempotencyKey(),request,()->{ var intent=request.intent();var followup=work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate()); - var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion());requireMutable(scope); + var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion(),operator);requireMutable(scope); var batch=batches.lockReceivingBatch(intent.batchKey());requireVersion(batch,intent.batchVersion()); recheckTransport(List.of(intent.batchKey()),scope); if(store.hasPending(intent.batchKey(),intent.customerId())) throw conflict("本客户已有待确认变化,请继续原记录"); @@ -86,6 +86,7 @@ public class FbaReceivingService { return new Result(pending.changeKey(),"WAITING",List.of(batch),pending.originalAgreements(),List.of(new Step("RECORD_CHANGE","SUCCESS",pending.changeKey(),"原安排及数量保留,待确认变化已阻止相关发车")),List.of("CONTINUE_WAREHOUSE_CONFIRMATION")); }); } + /** 原批次、运输、收货组和跟进在同一父命令事务内处理,跟进guard最后进入。 */ public Result resolve(String operator,String changeKey,Resolve request) { return command("RECEIVING_RESOLVE:"+changeKey,operator,request.idempotencyKey(),request,()->{ var initial=store.change(changeKey,false);var intent=initial.intent(); @@ -93,7 +94,7 @@ public class FbaReceivingService { // picking starts, without editing cargo, the appointment, or vehicle facts. var withdrawing="WITHDRAW".equals(request.decision()); var followup=withdrawing?null:work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate()); - var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion()); + var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion(),operator); if(!withdrawing) requireMutable(scope); var batch=batches.lockReceivingBatch(intent.batchKey());var pending=store.change(changeKey,true); if(pending.version()!=request.version() || !"WAITING".equals(pending.status())) throw conflict("变化已被处理,请重新读取当前结果"); @@ -112,13 +113,12 @@ public class FbaReceivingService { var currentKey=store.currentAgreement(intent.batchKey(),intent.customerId()); if(!Objects.equals(currentKey==null?null:currentKey.agreementKey(),original==null?null:original.agreementKey())) throw conflict("本客户有效预约关系已变化"); var changed=batches.moveReceivingCustomer(operator,intent);var retained=changed.get(0);var later=changed.get(1); - work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator); var movements=new ArrayList(); for(var source:pending.originalMembers().get(0).sources()) { var q=intent.quantities().stream().filter(v->v.sourceKey().equals(source.sourceKey()) && v.destinationKey().equals(source.destinationKey())).findFirst().orElseThrow(); movements.add(new FbaAppointmentTransportPort.Move(intent.batchKey(),intent.customerId(),source.sourceKey(),source.destinationKey(),source.packageCount(),q.retainedPackages(),later.batchKey())); } - var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason()); + var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason(),request.idempotencyKey()); var agreements=new ArrayList(); if(original!=null) { var members=new ArrayList<>(original.members());members.removeIf(m->m.batchKey().equals(intent.batchKey()) && m.customerId().equals(intent.customerId())); @@ -130,13 +130,16 @@ public class FbaReceivingService { var steps=List.of(new Step("ALLOCATE_CARGO","SUCCESS",later.batchKey(),"原来源总量不变,后送量已落在新批次"),new Step("ADJUST_TRANSPORT","SUCCESS",intent.batchKey(),"原车辆按实际保留货物调整")); var actions=new ArrayList();actions.add("BOOK_LATER_BATCH");actions.addAll(physical.nextActions()); var result=new Result(changeKey,"APPLIED",List.of(projected,later),List.copyOf(agreements),steps,List.copyOf(actions)); - store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches());return result; + store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches()); + // 原运输、批次、来源和收货组全部写完后才获取跟进guard。 + work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator); + return result; }); } public Result refreshCargo(String operator,RefreshCargo request) { return command("RECEIVING_REFRESH_CARGO",operator,request.idempotencyKey(),request,()->{ var selection=request.member();var selected=List.of(new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId())); - var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked); + var locked=execution.lockAndInspect(selected,request.executionVersion(),operator);requireRebookable(selected,locked); var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion()); var current=execution.inspect(selected); if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); @@ -151,7 +154,7 @@ public class FbaReceivingService { public Result plan(String operator,Plan request) { return command("RECEIVING_PLAN",operator,request.idempotencyKey(),request,()->{ var selection=request.member();var memberKey=new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId()); - var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked); + var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion(),operator);requireRebookable(selected,locked); var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion()); var current=execution.inspect(selected); if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); @@ -173,7 +176,7 @@ public class FbaReceivingService { var selections=request.bookings().stream().flatMap(b->b.members().stream()).toList(); var keys=selections.stream().map(Selection::batchKey).distinct().sorted().toList(); var executionMembers=selections.stream().map(s->new FbaAppointmentExecutionPort.Member(s.batchKey(),s.customerId())).distinct().toList(); - var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion()); + var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion(),operator); requireRebookable(executionMembers,executionScope); var snapshots=lock(keys); // An unbound customer can acquire a new dispatch before we obtain the batch lock. @@ -209,7 +212,7 @@ public class FbaReceivingService { public Result changeGroup(String operator,String key,GroupChange request) { return command("RECEIVING_GROUP:"+key,operator,request.idempotencyKey(),request,()->{ var initial=store.agreement(key,false);var keys=initial.members().stream().map(Member::batchKey).distinct().sorted().toList(); - var scope=transport.lockAndInspect(keys,request.transportVersion());requireMutable(scope);var snapshots=lock(keys); + var scope=transport.lockAndInspect(keys,request.transportVersion(),operator);requireMutable(scope);var snapshots=lock(keys); recheckTransport(keys,scope); var group=store.agreement(key,true);requireActive(group); if(group.version()!=request.version()) throw conflict("共同预约范围已变化,请重新核对全部客户"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java index 5bc4fbd0a..86288be8b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.appointmentwork.*; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.*; +import org.springframework.transaction.support.*; import java.util.*; import java.util.stream.Collectors; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -18,16 +19,16 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(dispatchIds.stream().anyMatch(id->id==null || id<=0)) throw bad("排车引用缺少真实排车身份"); var rows=mapper.references(dispatchIds.stream().distinct().sorted().toList()); if(rows.size()>20000) throw unavailable("排车批次引用超过完整读取上限"); - record Group(long dispatchId,String batchKey,String batchNo,String customerId) { } + record Group(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId) { } var groups=new LinkedHashMap,Integer>>(); for(var row:rows) { - if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0) + if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.recordVersion()==null || row.recordVersion()<0 || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0) throw unavailable("原排车逐票批次引用不完整"); - groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.customerId()),ignored->new LinkedHashMap<>()) + groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.recordVersion(),row.customerId()),ignored->new LinkedHashMap<>()) .merge(List.of(row.sourceKey(),row.destinationKey()),row.packages(),Math::addExact); } var result=new LinkedHashMap>();dispatchIds.forEach(id->result.put(id,new ArrayList<>())); - groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.customerId(), + groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.recordVersion(),group.customerId(), sources.entrySet().stream().map(e->new BatchSource(e.getKey().get(0),e.getKey().get(1),e.getValue())).toList()))); result.replaceAll((id,refs)->List.copyOf(refs));return Collections.unmodifiableMap(result); } @@ -36,53 +37,95 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo private final AppointmentSourceService sources; private final AppointmentWorkChanges changes; private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; + private final AppointmentWorkDispatchCandidates candidates; + private final Object prelockedBatchesKey=new Object(); + private final Object pendingInvalidationKey=new Object(); + /** 注入原预约、真实实物和收货确认权威。 */ public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes, - com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving) { - this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving; + com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving,AppointmentWorkDispatchCandidates candidates) { + this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving;this.candidates=candidates; } - public void bind(String customer,long dispatch,List items,List allocations) { - if(allocations==null) throw bad("批次实物分配清单必填"); - if(allocations.isEmpty()) { - if(mapper.byDispatch(dispatch).stream().anyMatch(a->a.batchKey().startsWith("FBA_APPOINTMENT:"))) throw conflict("已关联预约批次,修改实物时须同时提交完整批次分配"); - for(var item:items) if(item.planPalletId()!=null) requireNoBatch(customer,item.planPalletId()); - var privateRows=new ArrayList(); - for(var item:items) if(item.planPalletId()!=null) privateRows.addAll(privateAllocation(customer,dispatch,item)); - if(!privateRows.isEmpty() || mapper.count(dispatch)>0) { mapper.remove(dispatch);privateRows.forEach(mapper::insert);changes.requirementsChanged(); } - return; + /** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */ + @Transactional(readOnly=true,propagation=Propagation.SUPPORTS) + public Map candidateBatches(List input) {return candidates.batchReferences(input);} + /** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */ + public void lockBatches(Map expectedBatchVersions) { + if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效"); + var expected=new TreeMap(); + for(var entry:expectedBatchVersions.entrySet()) { + batchId(entry.getKey()); + if(entry.getValue()==null || entry.getValue()<0) throw bad("预约批次版本必填且不能小于零"); + expected.put(entry.getKey(),entry.getValue()); } - if(allocations.size()>1000) throw bad("批次实物分配最多1000条"); - var byPallet=items.stream().filter(i->i.planPalletId()!=null).collect(Collectors.toMap(PhysicalItem::planPalletId,i->i)); - var seen=new HashSet(); - for(var row:allocations) { - if(row==null || row.packageCount()<=0 || !customer.equals(row.customerId()) || !byPallet.containsKey(row.planPalletId())) throw conflict("批次分配必须属于本客户已选择的实物托盘"); - if(!seen.add(row.planPalletId()+"/"+row.batchKey()+"/"+row.sourceKey()+"/"+row.destinationKey())) throw bad("批次实物分配重复"); + @SuppressWarnings("unchecked") var locked=(Set)TransactionSynchronizationManager.getResource(prelockedBatchesKey); + if(locked!=null && !locked.containsAll(expected.keySet())) throw conflict("预约批次须在来源锁前一次锁齐"); + for(var entry:expected.entrySet()) { + var row=mapper.lockAppointment(batchId(entry.getKey())); + if(row==null || row.getVersion()==null || row.getVersion().longValue()!=entry.getValue()) throw conflict("预约批次版本已变化,请重新读取实物候选"); } - var batches=new TreeSet<>(allocations.stream().map(Allocation::batchKey).toList()); - for(var batch:batches) lock(batch); - var persisted=new ArrayList(); - for(var item:byPallet.values()) { - var selected=allocations.stream().filter(a->item.planPalletId().equals(a.planPalletId())).toList(); + if(locked==null && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.bindResource(prelockedBatchesKey,Set.copyOf(expected.keySet())); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务结束后释放本地预锁证明。 */ + public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(prelockedBatchesKey); } + }); + } + } + /** 逐项复核真实明细,只替换本次触及实物;完整有效集合参与同车及跨车余量核对。 */ + public void bindItems(long dispatch,List items,List allocations) { + if(dispatch<=0 || items==null || items.isEmpty() || items.size()>1000 || allocations==null || allocations.size()>1000) throw bad("批次实物分配范围无效"); + var actual=mapper.dispatchPhysicalById(dispatch).stream().collect(Collectors.toMap(AppointmentWorkDispatchMapper.DispatchPhysical::itemId,i->i)); + var byPallet=new HashMap,PhysicalItem>();var touched=new TreeSet(); + for(var item:items) { + if(item==null || item.itemId()==null || !touched.add(item.itemId())) throw bad("受影响实物身份缺失或重复"); + var row=actual.get(item.itemId()); + if(row==null || !Objects.equals(item.customerId(),row.customerId()) || !Objects.equals(item.planPalletId(),row.planPalletId()) + || !Objects.equals(item.sourceStage(),row.sourceStage()) || !Objects.equals(item.packages(),row.packages()) || row.packages()==null || row.packages()<=0) + throw conflict("受影响实物与本车真实客户、托盘或数量不一致"); + if(item.planPalletId()!=null && byPallet.put(List.of(item.customerId(),item.planPalletId().toString()),item)!=null) throw conflict("同客户托盘存在重复明细"); + } + var versions=new TreeMap();var seen=new HashSet>(); + for(var a:allocations) { + if(a==null || a.customerId()==null || a.planPalletId()==null || a.sourceKey()==null || a.destinationKey()==null || a.packageCount()<=0 + || !byPallet.containsKey(List.of(a.customerId(),a.planPalletId().toString()))) throw conflict("批次分配必须属于本次已核实的真实客户实物"); + batchId(a.batchKey()); + if(a.recordVersion()==null || a.recordVersion()<0) throw bad("预约批次版本必填且不能小于零"); + var previous=versions.putIfAbsent(a.batchKey(),a.recordVersion()); + if(previous!=null && !previous.equals(a.recordVersion())) throw conflict("同一预约批次不能提交不同版本"); + if(!seen.add(List.of(a.customerId(),a.planPalletId().toString(),a.batchKey(),a.sourceKey(),a.destinationKey()))) throw bad("批次实物分配重复"); + } + var previous=mapper.byDispatch(dispatch); + var effective=new ArrayList<>(previous.stream().filter(a->!touched.contains(a.itemId())).toList()); + var next=new ArrayList(); + for(var item:items) if(item.planPalletId()!=null) { + var selected=allocations.stream().filter(a->item.customerId().equals(a.customerId()) && item.planPalletId().equals(a.planPalletId())).toList(); if(selected.isEmpty()) { - var privateRows=privateAllocation(customer,dispatch,item); - if(privateRows.isEmpty()) throw conflict("批次排车须明确每个托盘的逐票实物分配"); - persisted.addAll(privateRows);continue; + requireNoBatch(item.customerId(),item.planPalletId()); + next.addAll(privateAllocation(item.customerId(),dispatch,item)); + } else { + validatePhysical(item.customerId(),item,selected,"RECEIVED".equals(item.sourceStage()),false); + for(var a:selected) next.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),item.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount())); } - validatePhysical(customer,item,selected,"RECEIVED".equals(item.sourceStage()),false); - for(var a:selected) persisted.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),customer,a.sourceKey(),a.destinationKey(),a.packageCount())); } + effective.addAll(next); + var batches=effective.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).collect(Collectors.toCollection(TreeSet::new)); + @SuppressWarnings("unchecked") var prelocked=(Set)TransactionSynchronizationManager.getResource(prelockedBatchesKey); + if(TransactionSynchronizationManager.isSynchronizationActive() && (prelocked==null || !prelocked.containsAll(batches))) throw conflict("预约批次未在来源锁前完成预锁"); for(var batch:batches) { - var context=appointments.batchContext(batch); var appointment=context.appointment(); - // Pre-arrangement reserves real cargo only. requireReceiving separately gates actual departure per customer. + var appointment=appointments.batchContext(batch).appointment(); + if(appointment.version()==null || appointment.version()<0 || versions.containsKey(batch) && appointment.version().longValue()!=versions.get(batch)) throw conflict("预约批次版本已变化,请重新读取实物候选"); if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次"); - requireCurrentAllocation(appointment,customer,dispatch,allocations.stream().filter(a->batch.equals(a.batchKey())).toList()); + for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) + requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())) + .map(a->allocation(a,appointment.version().longValue())).toList()); } - mapper.remove(dispatch); persisted.forEach(mapper::insert); changes.requirementsChanged(); + // 所有校验结束后才按明确item删除;没有整车清空操作。 + for(var item:touched) mapper.removeItem(dispatch,item); + next.forEach(mapper::insert);invalidateAtBusinessEnd(); } - public void requireReplay(String customer,long dispatch,List allocations) { - if(allocations==null || allocations.stream().anyMatch(a->a==null || !customer.equals(a.customerId()))) throw conflict("重放排车的逐票批次分配不一致"); - var original=mapper.byDispatch(dispatch).stream().filter(a->!a.batchKey().startsWith("DISPATCH:")) - .map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(); - if(allocations.size()!=original.size() || !new HashSet<>(allocations).equals(new HashSet<>(original))) throw conflict("原排车已存在,不能通过重放请求改写批次分配"); + /** 从原分配构造当前权威版本视图,不写入第三套版本账本。 */ + private Allocation allocation(AppointmentWorkDispatchMapper.Bound row,Long version) { + return new Allocation(row.palletId(),row.batchKey(),version,row.customerId(),row.sourceKey(),row.destinationKey(),row.packages()); } private void validatePhysical(String customer,PhysicalItem item,List selected,boolean received,boolean picked) { var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer); @@ -108,14 +151,16 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo } private void checkDeparture(String dispatchNo,boolean locked) { var rows=mapper.bindings(dispatchNo); - var linked=rows.stream().map(AppointmentWorkDispatchMapper.Bound::palletId).collect(Collectors.toSet()); - for(var item:mapper.dispatchPhysical(dispatchNo)) if(item.planPalletId()!=null && !linked.contains(item.planPalletId())) { + var physical=mapper.dispatchPhysical(dispatchNo); + for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId()))) + throw conflict("预约分配与本车真实客户实物不一致"); + var batches=rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).distinct().sorted().toList(); + if(locked) for(var batch:batches) lock(batch); + for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) { requireNoBatch(item.customerId(),item.planPalletId()); if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车"); } - for(var batch:rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { - if(batch.startsWith("DISPATCH:")) continue; - if(locked) lock(batch); + for(var batch:batches) { var a=appointments.batchContext(batch).appointment(); if(!a.replacementBatchKeys().isEmpty()) throw conflict("关联批次已被替代,请重新核对实物批次后发车"); var allocated=rows.stream().filter(row->batch.equals(row.batchKey())).toList(); @@ -124,13 +169,14 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo for(var customer:allocated.stream().map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().toList()) { requireReceiving(batch,customer,allocated.stream().filter(row->customer.equals(row.customerId())).toList()); requireCurrentAllocation(a,customer,dispatchIds.get(0),allocated.stream().filter(row->customer.equals(row.customerId())) - .map(row->new Allocation(row.palletId(),row.batchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages())).toList()); + .map(row->allocation(row,a.version()==null?null:a.version().longValue())).toList()); } } - for(var group:rows.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.Bound::palletId)).values()) { + for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) { var first=group.get(0); - validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.palletId(),"RECEIVED",null), - group.stream().map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(),true,true); + var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow(); + validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()), + group.stream().map(a->allocation(a,null)).toList(),true,true); } } private void requireReceiving(String batch,String customer,List rows) { @@ -168,7 +214,20 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty())) throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); } - public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId); changes.requirementsChanged(); } + /** 调用方已锁主单与原批次,删除只作用于指定实物。 */ + public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); } + /** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */ + private void invalidateAtBusinessEnd() { + if(!TransactionSynchronizationManager.isSynchronizationActive()) {changes.requirementsChanged();return;} + if(TransactionSynchronizationManager.hasResource(pendingInvalidationKey)) return; + TransactionSynchronizationManager.bindResource(pendingInvalidationKey,Boolean.TRUE); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 所有业务写入后获取guard;失败仍使整个原事务回滚。 */ + public void beforeCommit(boolean readOnly) {changes.requirementsChanged();} + /** 清除本事务本地标记,不复用于下一个请求。 */ + public void afterCompletion(int status) {TransactionSynchronizationManager.unbindResourceIfPossible(pendingInvalidationKey);} + }); + } public void requireTripBinding(String dispatchNo,long tripId) { for(var batch:mapper.bindings(dispatchNo).stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { if(batch.startsWith("DISPATCH:")) continue; @@ -190,8 +249,12 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo return rows; } private void lock(String batch) { - if(batch==null || !batch.startsWith("FBA_APPOINTMENT:")) throw bad("预约批次键无效"); - try { long id=Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); if(!batch.equals("FBA_APPOINTMENT:"+id) || mapper.lockAppointment(id)==null) throw conflict("预约批次不存在"); } - catch(NumberFormatException | IndexOutOfBoundsException error) { throw bad("预约批次键无效"); } + if(mapper.lockAppointment(batchId(batch))==null) throw conflict("预约批次不存在"); + } + /** 批次键只接受权威业务键,拒绝前导零、负数和溢出。 */ + private long batchId(String batch) { + if(batch==null || !batch.matches("FBA_APPOINTMENT:[1-9][0-9]*")) throw bad("预约批次键无效"); + try { return Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); } + catch(NumberFormatException error) { throw bad("预约批次键无效"); } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java index c0a15bd6d..46fbf5285 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.work; import com.baiyue.lastleg.appointmentplanning.*; import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,8 +16,62 @@ public class AppointmentWorkDispatchCandidates { private final AppointmentWorkDispatchMapper mapper; private final InboundFbaAppointmentService appointments; private final AppointmentSourceService sources; + /** 注入唯一原批次、实物和来源规范化边界。 */ public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; } + /** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */ @Transactional(readOnly=true) + public Map batchReferences(List input) { + if(input==null || input.size()>1000 || input.stream().anyMatch(i->i==null || i.customerId()==null || i.customerId().isBlank() || i.planPalletId()==null || i.planPalletId()<=0)) throw bad("候选批读须提供最多1000个真实客户托盘"); + var identities=input.stream().distinct().sorted(Comparator.comparing(CandidatePhysical::customerId).thenComparing(CandidatePhysical::planPalletId)).toList(); + if(identities.isEmpty()) return Map.of(); + var rows=mapper.candidateSources(identities); + if(rows.size()>20000) throw unavailable("候选逐票实物超过完整读取上限"); + var physical=rows.stream().filter(r->r.orderId()!=null && r.destinationKey()!=null) + .map(r->new AppointmentWorkDispatchMapper.Physical(r.orderId(),r.customerId(),r.destinationKey(),r.plannedPackages(),r.countedPackages())).toList(); + var canonical=canonical(physical); + var refs=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); + if(refs.size()>20000) throw unavailable("候选逐票来源超过完整读取上限"); + var orders=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).distinct().toList(); + var batches=refs.isEmpty()?List.of():mapper.candidateBatches(refs,orders); + if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限"); + var output=new LinkedHashMap(); + for(var identity:identities) { + var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList(); + var reasons=new LinkedHashSet();var references=new LinkedHashMap,List>(); + boolean related=false,unassigned=false;boolean occupied=selected.stream().anyMatch(AppointmentWorkDispatchMapper.CandidateSource::locked); + if(selected.isEmpty()) reasons.add("客户实物托盘不存在或归属已变化"); + for(var row:selected) { + var count="RECEIVED".equals(row.sourceStage())?row.countedPackages():row.plannedPackages(); + if(row.orderId()==null || row.destinationKey()==null || count==null || count<=0) {reasons.add("逐票实物来源或箱数尚未核实");continue;} + if(!row.destinationKey().startsWith("FBA:")) continue; + var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey(); + var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList(); + if(matched.isEmpty()) {unassigned=true;continue;} + related=true;long available=0;var seen=new HashSet(); + for(var b:matched) { + if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;} + if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;} + int remaining=Math.toIntExact(b.packages()-b.allocated());available=Math.addExact(available,remaining); + if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count))); + } + if(availablenew BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList(); + output.put(identity,new CandidateBatches(result,List.copyOf(reasons),occupied)); + } + return Collections.unmodifiableMap(output); + } + /** 保持原来源接口100票边界,按不同订单分块,重复目的地不增加远程调用。 */ + private Map canonical(List physical) { + var unique=new LinkedHashMap(); + for(var row:physical) unique.putIfAbsent(new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.orderId()),row); + var values=new ArrayList<>(unique.values());var result=new LinkedHashMap(); + for(int offset=0;offsetitem.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托"); - allocations.add(new Allocation(candidate.planPalletId(),batch,part.customerId(),ref.sourceKey(),part.destinationKey(),count)); + if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失"); + allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count)); } if(physical.isEmpty()) { packages=null; reasons.add("缺少逐票实物分配"); } output.add(new Candidate(candidate.planPalletId(),candidate.palletNo(),candidate.planId(),candidate.planNo(),candidate.customerId(),candidate.sourceStage(),packages,List.copyOf(allocations),reasons.stream().distinct().toList())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 72ade2a4f..3a43629fe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -10,17 +10,55 @@ public interface AppointmentWorkDispatchMapper { record Bound(long dispatchId,long itemId,long palletId,String batchKey,String customerId,String sourceKey,String destinationKey,int packages) { } record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,boolean locked) { } record DispatchPhysical(long itemId,Long planPalletId,String customerId,String sourceStage,Integer packages) { } - record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,Integer pickedPackages,Integer shippedPackages, + record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,String dispatchStage,Long dispatchVersion,Integer pickedPackages,Integer shippedPackages, java.time.LocalDateTime updatedAt,long itemId,long palletId,Integer itemPackages,String sourceStage, String batchKey,String sourceKey,String destinationKey,int packages,String tripNo,Long tripVersion,String tripStatus, Long outboundId,String outboundStatus,String taskStatus) { } record ReceivingSource(String batchKey,String customerId,String sourceKey,String destinationKey,Integer packages) { } - record ReferenceRow(long dispatchId,String batchKey,String batchNo,String customerId,String sourceKey,String destinationKey,int packages) { } + record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { } + record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } + record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { } + /** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */ + @Select(""" + + """) + List candidateSources(@Param("items") List items); + /** 原有效批次版本与跨车占用由同次查询提供,不逐候选读取batchContext。 */ + @Select(""" + + """) + List candidateBatches(@Param("refs") List refs,@Param("orders") List orders); @Select("""