Compare commits

...
Author SHA1 Message Date
ouyangxq d7616899d8 Unify dispatch operations with matched published transfer fixture 2026-10-20 00:29:25 +08:00
346 changed files with 29244 additions and 12579 deletions
+8 -4
View File
@@ -1,5 +1,9 @@
# 单测试环境数据库空库初始化
排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;`ll_dispatch_physical_pallet_lock` 独立保存无代表客户的实托排他占用,`ll_dispatch_pallet_lock.physical_lock_id` 关联逐客户逻辑来源,同车可共享、跨主单或草稿容器仍排他。`ll_dispatch_item` 按主单和完整来源键唯一,`ll_dispatch_item_source` 冻结换标明细的实际位置、绑定箱数、真实件数(未知为空)及原货源事实;拣货归属新增 `dispatch_item_source_id`,与实收、退回实收、原生退货箱三类凭据四者互斥,不伪造普通收货凭据。智能草稿按拥有者、起运仓和版本维护,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。
`ll_physical_pallet_guard` 以真实全局实托ID和首次创建时间提供排车冻结与仓内改托的共同串行点,独立于运输占用,不保存客户、状态、有效期或第三种拥有者。`ll_dispatch_item_source.relabel_round_key`保存真实作业轮次,不能把扫码引起的库存和申请状态变化误当作换标轮次变化。排车、草稿和仓内调用必须使用匹配的公共锁实现;本条只说明空库基线结构,不表示已执行或已部署。
本土散板简化下单的当前结构同步于 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` | 228 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -57,7 +61,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
- 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。
- 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。
- 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。
- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;拣货`receipt_id`与`return_receipt_id`恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。
- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;`receipt_id`、`return_receipt_id`、`return_carton_id`、`dispatch_item_source_id` 四类拣货归属凭据恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。
- 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。
- Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。
@@ -70,7 +74,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/215`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -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`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -6356,10 +6356,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 +6380,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 +6460,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 +6738,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 +6761,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 +7041,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 +7127,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));
+277 -108
View File
@@ -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,108 +347,215 @@ 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 (
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',
plan_id bigint DEFAULT NULL COMMENT '入仓计划ID',
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_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 '托盘号',
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 '海外仓名称快照',
destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID',
destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照',
destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照',
fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码',
destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE',
delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照',
postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照',
contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照',
package_count int NOT NULL DEFAULT '0' COMMENT '箱数',
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) NOT NULL COMMENT '客户租户ID',
plan_id BIGINT NULL COMMENT '入仓计划ID',
plan_pallet_id BIGINT NULL COMMENT '计划托盘ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID',
order_id BIGINT NULL COMMENT '订单ID',
order_no VARCHAR(128) NULL COMMENT '本条真实来源订单业务号快照',
order_refs VARCHAR(2000) NULL COMMENT '关联订单信息',
inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号',
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码',
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE',
delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照',
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照',
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),未知为NULL,不以材积重代替',
volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)',
volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)',
source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段',
carton_marks varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标箱唛映射快照',
source_business_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型',
source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)',
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)',
source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照',
source_business_type VARCHAR(32) NULL COMMENT '来源业务类型',
source_business_no VARCHAR(128) NULL COMMENT '来源业务单号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,source_key),
KEY idx_ll_dispatch_item_order (customer_id,order_id),
KEY idx_ll_dispatch_item_inbound (customer_id,inbound_code),
KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id)
) 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_item_source (
id BIGINT NOT NULL COMMENT '逻辑运输来源贡献快照主键',
dispatch_order_id BIGINT NOT NULL COMMENT '所属整车排车主单ID',
dispatch_item_id BIGINT NOT NULL COMMENT '所属客户逻辑排车明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '本条真实客户ID',
relabel_application_id BIGINT NOT NULL COMMENT '换标申请ID',
relabel_item_id BIGINT NOT NULL COMMENT '换标申请原明细ID',
relabel_round_key VARCHAR(128) NOT NULL COMMENT '冻结的真实换标作业轮次,扫码数量变化不改变轮次',
warehouse_pallet_id BIGINT NOT NULL COMMENT '当前真实物理托盘ID',
inventory_position_id BIGINT NOT NULL COMMENT '绑定后实际库存位置ID',
inventory_balance_id BIGINT NOT NULL COMMENT '绑定后实际库存余额ID',
source_inventory_balance_id BIGINT NULL COMMENT '原货源库存余额快照,未知为空',
source_inbound_order_id BIGINT NULL COMMENT '逐换标明细原货源订单ID,未知为空',
source_inbound_order_no VARCHAR(128) NULL COMMENT '逐换标明细原货源订单业务号快照',
source_inbound_code VARCHAR(128) NULL COMMENT '逐换标明细原入仓业务号快照',
notified_package_count BIGINT NOT NULL COMMENT '本来源通知箱数,不代表全部换标成功',
package_count BIGINT NOT NULL COMMENT '本来源实际绑定的可拣箱数',
product_quantity BIGINT NULL COMMENT '有真实依据的来源产品件数,未知为空',
binding_basis_hash CHAR(64) NOT NULL COMMENT '申请轮次、位置及逐来源贡献的冻结依据摘要',
unit_facts_json JSON NOT NULL COMMENT '逐件新旧箱唛及成功或异常工作事实快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_dispatch_item_source_contribution (dispatch_item_id,relabel_item_id,inventory_position_id),
KEY idx_dispatch_item_source_dispatch (dispatch_order_id,dispatch_item_id),
KEY idx_dispatch_item_source_relabel (customer_id,relabel_application_id,relabel_item_id),
CONSTRAINT chk_dispatch_item_source_packages CHECK(notified_package_count>=0 AND package_count>0),
CONSTRAINT chk_dispatch_item_source_quantity CHECK(product_quantity IS NULL OR product_quantity>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标逻辑来源实际贡献不可变快照';
CREATE TABLE ll_physical_pallet_guard (
warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间',
PRIMARY KEY (warehouse_pallet_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车冻结与仓内实托组成修改的共同串行点';
CREATE TABLE ll_dispatch_physical_pallet_lock (
id BIGINT NOT NULL COMMENT '物理实托占用主键',
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID',
warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID',
active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空',
dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥',
draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥',
draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID',
operator_id VARCHAR(128) NULL COMMENT '占用操作人ID',
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态',
expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空',
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
released_at DATETIME(3) 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_dispatch_physical_active_pallet (active_warehouse_pallet_id),
KEY idx_dispatch_physical_order (dispatch_order_id,status),
KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status),
CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)),
CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL))
) 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 '生效中的资源排他键',
plan_id bigint DEFAULT NULL COMMENT '入仓计划ID',
plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID',
warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID',
dispatch_order_id bigint DEFAULT NULL COMMENT '正式派车单ID,草稿锁阶段为空',
draft_container_id bigint DEFAULT NULL COMMENT '智能排车草稿装车托盘ID',
draft_token varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '智能排车草稿随机标识',
operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿锁操作人ID',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态',
locked_at datetime(3) NOT NULL COMMENT '锁定时间',
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 '创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
id BIGINT NOT NULL COMMENT '主键ID',
physical_lock_id BIGINT NULL COMMENT '实际物理实托占用ID,非物理来源为空',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键',
active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键',
plan_id BIGINT NULL COMMENT '入仓计划ID',
plan_pallet_id BIGINT NULL COMMENT '计划托盘ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID',
dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空',
draft_container_id BIGINT NULL COMMENT '智能排车草稿装车托盘ID',
draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识',
operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID',
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态',
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空',
released_at DATETIME(3) 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 '更新时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key),
KEY idx_dispatch_logical_physical (physical_lock_id,status),
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='派车托盘排他锁';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户逻辑运输来源占用';
CREATE TABLE ll_dispatch_pda_operation (
id bigint NOT NULL COMMENT '主键ID',
@@ -505,27 +614,24 @@ CREATE TABLE ll_return_stock_receipt (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据';
CREATE TABLE ll_dispatch_pda_pick_order (
id bigint NOT NULL COMMENT '主键ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
pick_id bigint NOT NULL COMMENT 'PDA拣货记录ID',
receipt_id BIGINT NULL COMMENT '入仓实收记录ID',
order_id bigint NOT NULL COMMENT '订单ID',
packages bigint NOT NULL COMMENT '本次操作箱数',
product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID',
receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数',
product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥',
dispatch_item_source_id BIGINT NULL COMMENT '换标实际来源贡献快照ID,与其他三类真实凭据互斥',
KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id),
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1),
PRIMARY KEY (id),
KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id),
KEY idx_dispatch_pick_item_source(customer_id,dispatch_item_source_id),
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) + (dispatch_item_source_id IS NOT NULL) = 1),
PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id),
KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA拣货订单归属记录';
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 +642,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 +1933,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 +2018,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 +3117,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 +3290,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 '出车实测历史主键',
@@ -4713,4 +4882,4 @@ CREATE TABLE ll_fleet_pickup_history (
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 220 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
@@ -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, "出车实测凭证必须归属平台");
}
@@ -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}
@@ -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<FileObjectEntity> 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<FileObjectEntity> query, String column) {
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column
+ " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}").matcher(query.getSqlSegment());
assertThat(matcher.find()).as("复合归属条件 %s", column).isTrue();
return query.getParamNameValuePairs().get(matcher.group(1));
}
/** 创建隔离临时目录中的真实存储,不访问运行环境。 */
private BusinessFileStorage storage() {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
/** 复用生产服务,数据库边界由 Mapper 替身记录实际查询。 */
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
return service;
}
/** 提供具有真实 PDF 文件头的最小上传内容。 */
private static MockMultipartFile pdf() {
return new MockMultipartFile("file", "evidence.pdf", "application/pdf",
"%PDF-1.7\nevidence".getBytes(StandardCharsets.US_ASCII));
}
/** 使用图像编码器生成真实 JPG、PNG,避免测试只验证扩展名。 */
private static byte[] image(String format) throws java.io.IOException {
var output = new ByteArrayOutputStream();
var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB);
assertThat(javax.imageio.ImageIO.write(image, format, output)).isTrue();
return output.toByteArray();
}
}
@@ -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'
@@ -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$$
+2 -2
View File
@@ -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: 当前商户成员权限查询成功
@@ -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")
@@ -39,8 +39,7 @@ class OverseasPhase4PermissionSqlContractTest {
private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+");
private static final Set<String> 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<String> 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);
@@ -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<String> 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");
}
@@ -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<String> platformPermissions() throws Exception {
LinkedHashSet<String> 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;
}
@@ -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
+9 -9
View File
@@ -16,7 +16,7 @@
业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。
拼车接货与车次准备:`fleet/trip/`、`fleet/business/`;合同`fleet-preparation-v1.yaml`,流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.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 +55,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 +73,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` |
@@ -95,12 +95,12 @@
- 海外仓全流程连续覆盖尾程订单、入仓计划/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天提醒与阻断分别返回。
- 排车主流程以工作区 `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=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
- 排车执行与 `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。报价或登记来货不冒充库存/已出库;取消未执行预订仅释放承运容量,来货及已入库货物保留。
+79
View File
@@ -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`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
@@ -20,6 +20,10 @@
- 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。
- 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。
### 整车排车的既有预约接线边界(2026-10-19)
既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。
### 平台自营统计边界
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
+1 -1
View File
@@ -34,7 +34,7 @@
<executions>
<execution>
<id>default-test</id>
<configuration><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
<configuration><reuseForks>false</reuseForks><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
</execution>
<!-- H2复杂查询规划与普通单测分进程,仍沿用父级1GB上限并执行全部断言。 -->
<execution>
@@ -73,6 +73,40 @@ function analyze(sourceSet = 'main') {
}));
}
test('original appointment adapters use only the reviewed dispatch port types', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const prefix = 'com.baiyue.lastleg.inboundplan.work.';
const port = 'com.baiyue.lastleg.dispatch.port.';
const expected = [
['FbaAppointmentExecutionAdapter', 'DispatchAppointmentAmendmentPort'],
['FbaAppointmentExecutionAdapter', 'DispatchContracts'],
['FbaAppointmentExecutionAdapter', 'DispatchTypes'],
['FbaAppointmentTransportAdapter', 'DispatchAppointmentAmendmentPort'],
['FbaAppointmentTransportAdapter', 'DispatchCommands'],
['FbaAppointmentTransportAdapter', 'DispatchContracts'],
['FbaAppointmentTransportAdapter', 'DispatchTypes'],
].map(([source, target]) => `${prefix}${source}->${port}${target}`).sort();
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), expected);
assert.equal(baseline.allowedSliceEdges.includes('inbound-pda->dispatch-delivery'), false);
const violations = contractViolations(report, baseline);
for (const edge of expected) assert.equal(violations.some(message => message.includes(edge)), false);
// 精确端口边不允许原适配器直连整车实现,也不授权其他预约消费者借用此许可。
const forbidden = [
{ source: prefix + 'FbaAppointmentExecutionAdapter', target: 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' },
{ source: prefix + 'AppointmentWorkDispatchBinding', target: port + 'DispatchAppointmentAmendmentPort' },
];
for (const edge of forbidden) {
assert.ok(report.classes.some(entry => entry.fqcn === edge.source));
assert.ok(report.classes.some(entry => entry.fqcn === edge.target));
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
}
});
test('current source does not add slice dependencies beyond the Lastleg baseline', () => {
execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], {
cwd: moduleRoot,
@@ -19,11 +19,11 @@ public interface AppointmentImpactRelationMapper {
""")
List<Appointment> appointments(String id);
@Select("""
SELECT DISTINCT d.customer_id,d.dispatch_no FROM ll_order o
SELECT DISTINCT i.customer_id,d.dispatch_no FROM ll_order o
JOIN ll_dispatch_item i ON i.order_id=o.id AND i.customer_id=o.customer_id
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.customer_id=i.customer_id
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id
WHERE o.source_container_booking_id=#{id} AND o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED')
AND d.status NOT IN ('CANCELLED','COMPLETED') ORDER BY d.dispatch_no LIMIT 1001
AND d.status='ACTIVE' AND d.stage<>'DELIVERED' ORDER BY d.dispatch_no LIMIT 1001
""")
List<AppointmentImpactTransportGateway.Dispatch> dispatches(String id);
}
@@ -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<BatchSource> sources) { }
record BatchReference(String batchKey,String batchNo,Long recordVersion,String customerId,List<BatchSource> sources) { }
record CandidatePhysical(String customerId,Long planPalletId) { }
/** sources数量由本托真实逐票量和原批次剩余量共同限定;不包含零余量的伪可选项。 */
record CandidateBatches(List<BatchReference> appointmentBatches,List<String> blockReasons,boolean occupied) { }
/** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
void bind(String customerId,long dispatchId,List<PhysicalItem> items,List<Allocation> allocations);
void requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
void lockBatches(Map<String,Long> expectedBatchVersions);
/** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */
void bindItems(long dispatchId,List<PhysicalItem> touchedItems,List<Allocation> allocations);
void requireDeparture(String dispatchNo);
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
String departureBlockReason(String dispatchNo);
@@ -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<com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> externalFreight(
public ApiResponse<java.util.List<PreviewGroup>> 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<ArrangementPreview> 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<DeliveryResponses.TransferResult> transfer(
@Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单")
public ApiResponse<CommandResult> 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();
@@ -31,14 +31,6 @@ public class MerchantDeliveryController {
return ApiResponse.success(service.deliveryPage(customer(user), query));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')")
public ApiResponse<DeliveryResponses.TransferResult> 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<DeliveryResponses.DeliveryDetail> 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()); }
}
@@ -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<DeliveryFeeCoverageModels.Preview> preview(@AuthenticationPrincipal AuthenticatedUser user,
public ApiResponse<java.util.List<PreviewGroup>> 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());
}
}
@@ -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> cargo, List<FinanceFeeCoverageClient.Fee> feeSources) { }
public record Selection(@NotNull @Positive Long financeFeeId, @Min(1) int sourceVersion,
@NotBlank @Size(max=128) String sourceHash, @NotEmpty @Size(max=3) List<@NotBlank @Pattern(regexp="DELIVERY_FEE|LOADING_FEE|APPOINTMENT_FEE") String> services) { }
public record Reference(FinanceFeeCoverageClient.Fee source, List<String> services) { }
public record Evidence(String cargoHash, List<Cargo> cargo, List<Reference> references, String operatorId, Instant confirmedAt) { }
}
@@ -6,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<FeeItem> fees,
@Size(max = 64) String externalFreightHash,
@Size(max = 64) String feeCoverageCargoHash,
@Size(max = 2000) List<@NotNull @Valid Selection> feeCoverage) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
@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,
@@ -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<TransferDelivery> 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,
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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<DeliveryFactsChanged> 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);
}
@@ -12,7 +12,7 @@ import java.util.List;
@Mapper
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
/** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */
/** 普通流水与原箱凭据证明退回,共享任务按真实扫码及逐票客户归属核验。 */
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " +
"l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
@@ -25,7 +25,7 @@ public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
"AND di.warehouse_pallet_id=oi.warehouse_pallet_id " +
"AND EXISTS (SELECT 1 FROM ll_dispatch_pda_pick p JOIN ll_dispatch_pda_pick_order a " +
"ON a.customer_id=p.customer_id AND a.pick_id=p.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 p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " +
"AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " +
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " +
@@ -9,6 +9,18 @@ import java.util.List;
@Mapper
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
/** 整车签收事实使用完整真实客户单据集合。 */
@Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id")
List<DeliveryOrder> 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<DeliveryOrder> lockAllByDispatch(@Param("dispatchId") long dispatchId);
/** 服务内签收信号按整车及派送号定位真实客户,不接受浏览器客户范围。 */
@Select("SELECT * FROM ll_delivery_order WHERE dispatch_no=#{dispatchNo} AND delivery_no=#{deliveryNo}")
List<DeliveryOrder> 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<DeliveryOrder> findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
@@ -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<Long> 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<Delivery> 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 " +
@@ -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);
}
@@ -17,7 +17,7 @@ public interface DispatchFeeCargoMapper {
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=di.customer_id
LEFT JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=di.customer_id
LEFT JOIN (SELECT pk.dispatch_item_id,pk.inventory_position_id,SUM(pk.packages) AS packages
FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id AND task.customer_id=pk.customer_id
FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id
WHERE task.dispatch_order_id=#{dispatchId} AND pk.customer_id=#{customerId}
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
@@ -9,6 +9,14 @@ import java.util.List;
@Mapper
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
/** 整车跨客户读取全部出库单,不用第一张代表全车。 */
@Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id")
List<OutboundOrder> 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<OutboundOrder> 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")
@@ -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<CargoLine> cargo,long packages,long palletCount) { }
/** 只接受完整真实拣货来源,不从展示入仓码或计划数补齐事实。 */
public List<Group> groups(Context context) {
if (context==null || context.counts()==null || context.cargo()==null || context.cargo().isEmpty())
throw conflict("排车单没有真实待派送货物");
var groups=new TreeMap<String,List<CargoLine>>();
var attributions=new HashSet<Long>();
var palletDestinations=new HashMap<Long,String>();
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<Group>();
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); }
}
@@ -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<NormalDeliveryFact> actual,Instant now) {
var reasons=new ArrayList<String>();
var expected=arrangement==null?List.<DeliveryReference>of():arrangement.deliveries();
var seen=new HashMap<String,NormalDeliveryFact>();
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.<String>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());
}
}
@@ -0,0 +1,103 @@
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 boolean restoreRelabelArrangement(RelabelApplication application,String outboundNo,String fulfillment,LocalDateTime now) {
return relabels.update(null,new LambdaUpdateWrapper<RelabelApplication>()
.eq(RelabelApplication::getId,application.getId()).eq(RelabelApplication::getCustomerId,application.getCustomerId())
.eq(RelabelApplication::getOutboundOrderNo,outboundNo).eq(RelabelApplication::getStatus,"READY_FOR_OUTBOUND")
.set(RelabelApplication::getOutboundOrderNo,null).set(RelabelApplication::getFulfillmentStatus,fulfillment)
.set(RelabelApplication::getUpdatedAt,now))==1;
}
/** 子明细始终按出库客户读取。 */
public List<OutboundPalletItem> items(OutboundOrder order) { return items.findByOutbound(order.getCustomerId(),order.getId()); }
/** 一次取得本出库全部派送锁,不边持来源锁边锁下一张派送。 */
public List<DeliveryOrder> 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<LastlegOrder> sources(String customer,String outboundNo) {
return sources.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId,customer)
.eq(LastlegOrder::getOutboundOrderNo,outboundNo).orderByAsc(LastlegOrder::getId));
}
/** 按真实转换历史恢复,而非统一写某个旧状态。 */
public LastlegOrderStatusHistory previous(String customer,long id,List<String> events) {
return histories.selectOne(new LambdaQueryWrapper<LastlegOrderStatusHistory>().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<LastlegOrder>().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); }
}
@@ -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<DeliveryDocumentRevision> history(String customerId,long id) { return revisions.history(customerId,id); }
/** 签收仍需拒绝未完成的真实退回流程。 */
public long activeReturns(String customerId,long id) { return operations.activeReturns(customerId,id); }
/** 只读取本派送的真实退回入账事实。 */
public List<DeliveryReturnReceiptFact> returns(String customerId,long id) { return operations.returnReceipts(customerId,id); }
/** 拣货来源按真实客户与整车读取,不拿当前订单指针替代历史。 */
public List<SourceDeliveryCompletionPolicy.Pick> picks(String customerId,long dispatchId) { return evidence.picks(customerId,dispatchId); }
/** 批量读取候选来源,再由Service复核客户归属。 */
public List<LastlegOrder> sourceOrders(Collection<Long> ids) { return ids.isEmpty()?List.of():sources.selectBatchIds(ids); }
/** 非头程本地单据仍读取其本客户的既有当前关系。 */
public List<LastlegOrder> locallyLinked(String customerId,String deliveryNo) {
return sources.selectList(new LambdaQueryWrapper<LastlegOrder>().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); }
}
@@ -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<DeliveryOrder> 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<DeliveryFactsChanged> 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);
}
}
@@ -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<OutboundOrder> outbounds(long id,boolean lock) { return lock?outbounds.lockAllByDispatch(id):outbounds.findAllByDispatch(id); }
/** 仅在取得全体出库锁之后取得全体派送锁。 */
public List<DeliveryOrder> 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<com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact> 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<LastlegOrder>().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); }
}
@@ -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> 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<Category> 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<LastlegOrder> orders, Map<String, SourceDeliveryCompletionPolicy.Result> proofs,
Map<Long,Long> sourcePackages) { }
/** 先根据真实拣货收齐来源,再按跨趟来源锁协议证明正常签收。 */
private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) {
var related = new TreeMap<Long, LastlegOrder>();
Map<Long,Long> 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<LastlegOrder>()
.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.<String,SourceDeliveryCompletionPolicy.Result>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<LastlegOrder>();
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); }
}
@@ -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<Signal> 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(); }
}
@@ -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<Long>();
if (reason == null) for (var row : cargo) if ("FIRSTLEG".equals(row.sourceRegion())) {
try { long id = Long.parseLong(row.sourceInboundId()); if (id < 1) throw new NumberFormatException(); inboundIds.add(id); }
catch (RuntimeException invalid) { reason = "货物缺少原头程入仓单关联,请先核对来源"; break; }
}
List<FinanceFeeCoverageClient.Fee> fees = List.of();
if (reason == null && !inboundIds.isEmpty()) fees = load(dispatch.getCustomerId(), List.copyOf(inboundIds));
try {
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
json.writeValueAsString(List.of(dispatch.getDispatchNo(), dispatch.getCustomerId(), cargo,
selected.stream().map(item -> Arrays.asList(item.getId(), item.getPackageCount(), item.getOverseasWarehouseId(),
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(),
item.getDeliveryAddress(), item.getPostalCode())).toList())).getBytes(StandardCharsets.UTF_8)));
return new DeliveryFeeCoverageModels.Preview(dispatch.getDispatchNo(), reason == null, reason, hash, cargo, fees);
} catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException(error); }
}
private List<FinanceFeeCoverageClient.Fee> load(String customerId, List<Long> inboundIds) {
try {
var result = finance.sources(token, new FinanceFeeCoverageClient.Query(Long.valueOf(customerId), inboundIds));
if (result == null || !result.successful() || result.data() == null) throw unavailable();
if (result.data().stream().anyMatch(fee -> fee.financeFeeId() == null || fee.financeFeeId() < 1 || fee.sourceFeeId() == null
|| !Objects.equals(fee.customerId(), Long.valueOf(customerId)) || fee.sourceVersion() < 1
|| !inboundIds.contains(fee.sourceInboundId()) || fee.sourceHash() == null || fee.sourceHash().isBlank()
|| fee.amount() == null || fee.amount().signum() < 0 || !"CNY".equals(fee.currency())
|| !Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(fee.sourceType())
|| !Set.of("AVAILABLE", "SETTLED").contains(fee.status()))) throw unavailable();
return result.data();
} catch (RuntimeException error) { throw unavailable(); }
}
private static String incomplete(List<DispatchItem> selected, List<DeliveryFeeCoverageModels.Cargo> cargo) {
if (selected.isEmpty()) return "排车单没有待发运货物";
var totals = cargo.stream().collect(Collectors.groupingBy(DeliveryFeeCoverageModels.Cargo::dispatchItemId,
Collectors.summingLong(DeliveryFeeCoverageModels.Cargo::packages)));
var ids = selected.stream().map(DispatchItem::getId).collect(Collectors.toSet());
if (!ids.equals(totals.keySet())) return "实际货物来源尚未齐全,请先完成收货并核对实物托盘";
for (var item : selected) if (item.getPackageCount() == null || totals.get(item.getId()).longValue() != item.getPackageCount())
return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
return null;
}
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<String> externalInboundCodes) {
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
if (evidence == null) return List.of();
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalInboundCodes)).toList();
}
}
/** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<String> 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<DeliveryFeeCoverageModels.Reference>();
var scopes = new HashSet<String>();
for (var selection : request.feeCoverage()) {
var fee = preview.feeSources().stream().filter(row -> Objects.equals(row.financeFeeId(), selection.financeFeeId())).findFirst()
.orElseThrow(() -> conflict("原费用不属于本次实际货物,或已审核状态/有效性发生变化"));
if (fee.sourceVersion() != selection.sourceVersion() || !Objects.equals(fee.sourceHash(), selection.sourceHash()))
throw conflict("原费用版本已变化,请重新确认服务覆盖范围");
var services = new TreeSet<>(selection.services());
if (services.isEmpty() || !Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE").containsAll(services))
throw conflict("请选择本票原费用明确包含的服务");
for (var service : services) if (!scopes.add(fee.sourceInboundId() + ":" + service))
throw conflict("同一票同一服务只能引用一项原费用");
references.add(new DeliveryFeeCoverageModels.Reference(fee, List.copyOf(services)));
}
var included = new HashSet<String>();
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, 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<DeliveryFeeCoverageModels.Reference> references, Set<String> 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, "原费用来源暂不可用,请稍后重试"); }
}
@@ -1,8 +1,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;
@@ -23,9 +21,9 @@ import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
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.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
@@ -94,7 +92,7 @@ import java.util.stream.Collectors;
@Service
public class DeliveryTransferService {
@Resource private DispatchOrderMapper dispatchOrders;
@Resource private DispatchAggregateRepository dispatches;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPallets;
@@ -129,283 +127,9 @@ public class DeliveryTransferService {
@Resource private RelabelApplicationService relabelService;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private FleetBookingFreight bookingFreight;
@Resource private DeliveryFeeCoverageService feeCoverage;
@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<DispatchItem> 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<DispatchItem> 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<String, List<DispatchItem>> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy(
this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<DeliveryOrder> deliveries = new ArrayList<>();
int groupIndex = 0;
for (Map.Entry<String, List<DispatchItem>> entry : destinationGroups.entrySet()) {
List<DispatchItem> 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<DeliveryRequests.FeeItem> 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.<FleetDispatchGateway.SourceCargo>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<String> covered) {
List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees();
Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE");
Set<String> 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) {
@@ -1164,65 +888,95 @@ 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("排车已开始执行,不能直接取消出库");
// 唯一Fleet资源记录随整车自动创建,不能把本车正常关系当作额外取消阻断。
}
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<OutboundPalletItem> outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId());
List<OutboundPalletItem> 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<DeliveryOrder> existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId());
boolean relabelArrangement = "RELABEL_OUTBOUND".equals(outbound.getOutboundType());
String pendingRelabelFulfillment = null;
List<DeliveryOrder> 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())
|| !outbound.getOutboundNo().equals(application.getOutboundOrderNo())
|| !Objects.equals(outbound.getOverseasWarehouseId(),application.getOverseasWarehouseId())
|| !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus()))
throw conflict("换标出库关联状态已变化,不能取消");
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(outbound.getOverseasWarehouseId(),carton.getOverseasWarehouseId())
|| !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,
new RelabelRequests.ChangeStatus("CANCELLED", request.reason().trim()));
// 原生箱和普通仓托均只撤销本次安排;整车分支仍须先通过父锁、阶段和零扫码门禁。
pendingRelabelFulfillment = "TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION";
if (!cancellations.restoreRelabelArrangement(application,outbound.getOutboundNo(),pendingRelabelFulfillment,now))
throw conflict("原换标出库关联已变化,未取消本次安排");
}
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<String,WarehouseTransferOrder>();
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<Long,LastlegOrder>();
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);
}
if (relabelArrangement) {
// 仅清本次出库/派送关联,来源主状态和换标完成事实保持原值,不能退回未换标库存。
for (var order : cancelSources.values()) {
if (!cancellations.restore(order,outboundNo,order.getCurrentStatus(),"RELABEL:"+pendingRelabelFulfillment,now))
throw conflict("原换标来源出库关联已变化,请刷新核对");
}
}
for (DeliveryOrder delivery : existingDeliveries) {
@@ -1236,61 +990,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<LastlegOrder>().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<LastlegOrderStatusHistory>()
.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<LastlegOrder>()
.eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo)
.set(LastlegOrder::getCurrentStatus, previous.getFromStatus())
.set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED")
.set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null)
.set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1)
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<DeliveryResponses.PdaTask> 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()
@@ -1326,7 +1077,7 @@ public class DeliveryTransferService {
OutboundOrder outbound = outboundOrders.findGlobalByNo(code);
if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo());
DispatchAggregate dispatch = dispatches.find(outbound.getDispatchNo());
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(),
dispatch.getDispatchNo());
@@ -1336,7 +1087,7 @@ public class DeliveryTransferService {
if (task != null) return outboundResolution(outbound, task);
}
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code);
DispatchAggregate dispatch = dispatches.find(code);
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo());
}
@@ -1590,47 +1341,6 @@ public class DeliveryTransferService {
} catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); }
}
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
String remark, LocalDateTime now) {
Set<Long> orderIds = new LinkedHashSet<>();
items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add);
List<Long> palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList();
if (!palletIds.isEmpty()) {
List<InboundPlanPalletAllocation> allocations = planAllocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>().in(InboundPlanPalletAllocation::getPalletId, palletIds));
List<Long> 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<LastlegOrder>()
.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<DeliveryOrder> requireDeliveries(String customerId, Long outboundId) {
List<DeliveryOrder> 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<DeliveryOrder> 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(), 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(),
@@ -1653,7 +1363,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<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
private String deliveryGroupKey(DispatchItem item) {
if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) {
return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim();
@@ -1663,16 +1372,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<DispatchItem> items) {
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
if (inboundCodes.isEmpty()) return "";
return tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.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<OutboundPalletItem> items = outboundPallets.findByOutbound(customerId, outbound.getId());
String nextStatus = !items.isEmpty() && items.stream().allMatch(item -> "PICKED".equals(item.getStatus()))
@@ -1,57 +0,0 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** 外接绑定和约定运费共同消费同一份逐来源货物。 */
@Service
public class DispatchCargoSourceQuery {
private final DispatchCargoSourceRepository repository;
/** 装配当前库存与本次拣货的真实来源入口。 */
public DispatchCargoSourceQuery(DispatchCargoSourceRepository repository) { this.repository = repository; }
/** 新增普通派送应收只绑定真实未约定来源;混合多票时保留派送归属。 */
public Set<Long> ordinaryOrderIds(DispatchOrder dispatch, Set<String> externalInboundCodes) {
return repository.sources(dispatch.getCustomerId(), dispatch.getId()).stream()
.filter(row -> !externalInboundCodes.contains(row.inboundCode()))
.map(row -> row.orderId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet());
}
/** 每条普通排车明细核平实际组成;箱级换标和退货保留其专用来源快照。 */
public List<FleetDispatchGateway.SourceCargo> sources(DispatchOrder dispatch, List<DispatchItem> items) {
if (items.isEmpty()) return List.of();
var rows = repository.sources(dispatch.getCustomerId(), dispatch.getId());
var result = new ArrayList<FleetDispatchGateway.SourceCargo>();
for (var item : items) {
String destination = FleetDispatchGateway.destinationKey(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 FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(),
item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), destination));
continue;
}
var selected = rows.stream().filter(row -> Objects.equals(row.dispatchItemId(), item.getId())).toList();
if (selected.isEmpty() || item.getPackageCount() == null || selected.stream().mapToLong(row -> row.packages()).sum() != item.getPackageCount())
throw conflict("实际货物组成与排车箱数不一致,请核对收货、托盘及本次拣货来源");
for (var row : selected) {
if (row.inboundCode() == null || row.inboundCode().isBlank() || row.packages() <= 0
|| !Objects.equals(row.warehousePalletId(), item.getWarehousePalletId()))
throw conflict("货物缺少真实入仓来源或实物托盘,不能识别为普通货跳过外接核对");
result.add(new FleetDispatchGateway.SourceCargo(row.inboundCode(), row.warehousePalletId().toString(),
row.palletNo(), Math.toIntExact(row.packages()), destination));
}
}
return List.copyOf(result);
}
/** 来源不足直接阻断,不回退展示字段。 */
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
}
@@ -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<String> 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<String,FleetBookingFreight.Preview> external(Context context) {
var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(CargoLine::customerId,TreeMap::new,Collectors.toList()));
var result=new LinkedHashMap<String,FleetBookingFreight.Preview>();
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<FinanceFeeCoverageClient.Fee> original(String customerId,List<CargoLine> cargo) {
var ids=new TreeSet<Long>();
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.<CoverageSelection>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<DeliveryFeeCoverageModels.Reference>(); var scopes=new HashSet<String>();
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<String>();
for (var fee:input.fees()==null?List.<FeeItem>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<String>(); 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.<FeeItem>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); }
}
@@ -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<String,List<com.baiyue.lastleg.client.FinanceFeeCoverageClient.Fee>>();
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<PreviewGroup>();
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.<String>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<String,ArrangeGroup>();
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<String,DeliveryFeeCoverageModels.Evidence>();
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<String,OutboundOrder>();
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<String,DeliveryOrder>();
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.<Group>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<NormalDeliveryFact>();
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<OutboundOrder> outbounds,List<DeliveryOrder> 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<Group> groups,Map<String,OutboundOrder> outbounds,Map<String,DeliveryOrder> deliveries,Instant now) {
var sources=new TreeMap<Long,List<CargoLine>>();
for (var row:context.cargo()) if(row.orderId()!=null)sources.computeIfAbsent(row.orderId(),ignored->new ArrayList<>()).add(row);
var locked=new TreeMap<Long,com.baiyue.lastleg.entity.LastlegOrder>();
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<OutboundReference> outboundRefs(Collection<OutboundOrder> rows,List<Group> 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<DeliveryReference> deliveryRefs(Collection<DeliveryOrder> rows,List<Group> 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<DeliveryFeeCoverageModels.Evidence> 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<ArrangeGroup>();
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.<CoverageSelection>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.<FeeItem>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<CargoLine> cargo,Function<CargoLine,BigDecimal> 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>T read(String value,Class<T> 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); }
}
@@ -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()));
}
}
@@ -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<CommandResult> 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<CommandResult> 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<CommandResult> 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<CommandResult> 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<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Items> 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<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Route> 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<CommandResult> 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<com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.LoadingPreview> 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<CommandResult> 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<CommandResult> 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<com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File> 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<com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File> 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<byte[]> 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<CommandResult> 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<CommandResult> 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<CommandResult> 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<CommandResult> 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<CommandResult> command(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.command(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey));
}
}
@@ -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<DispatchResponse> update(
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchContracts.CommandResult> 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<DispatchResponse> 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<Void> 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");
}
}
}
@@ -3,10 +3,10 @@ package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
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.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 +34,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<PageResult<DispatchResponse>> page(
public ApiResponse<DispatchViews.Page> 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<DispatchResponse> detail(
public ApiResponse<DispatchViews.Detail> 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)
@@ -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<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) List<Long> 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));
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
@RequestParam(required=false)List<Long> 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)));
}
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
fbaWarehouseCode, sourceStage, draftToken));
}
/** 只列出当前拥有者未失效草稿。 */
@GetMapping("/drafts")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
public ApiResponse<List<com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse>> drafts(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user)));
}
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
public ApiResponse<List<Draft>> drafts(@AuthenticationPrincipal AuthenticatedUser user){return ApiResponse.success(planning.drafts(DispatchActorFactory.platform(user)));}
/** 以持久命令键创建服务器token及零版本。 */
@PostMapping("/drafts")
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')")
public ApiResponse<Draft> 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<Void> 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<DispatchResponse> 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<Draft> 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<List<com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult>> 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<ConvertedDraft> 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<Void> 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<Draft> 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<com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse> 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<Draft> 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<Void> 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<Draft> 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<Void> 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<Draft> 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<Void> 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<Draft> releaseDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftRelease request){return ApiResponse.success(planning.release(DispatchActorFactory.platform(user),request));}
}
@@ -1,4 +0,0 @@
package com.baiyue.lastleg.dispatch.dto;
/** One independent dispatch for one server-owned loading container. */
public record DispatchDraftGroupResult(Long draftContainerId, DispatchResponse dispatch) { }
@@ -1,109 +0,0 @@
package com.baiyue.lastleg.dispatch.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation;
public final class DispatchRequests {
private DispatchRequests() {
}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
String vehiclePlate, String status, String fbaAppointmentNo, Stage stage) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public enum Stage {
BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED;
public List<String> statuses() {
return switch (this) {
case BEFORE_LOADING -> List.of("PENDING_DEPARTURE");
case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART");
case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED");
case CANCELLED -> List.of("CANCELLED");
};
}
}
public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
@Size(max = 64) String draftToken,
@Size(max = 64) String relabelNo,
@Size(max = 500) List<@NotNull Long> returnReceiptIds,
@Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) {
public PreArrange {
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations);
}
public PreArrange(List<Long> planPalletIds,String fbaAppointmentNo,String fbaAppointmentStatus,String vehicleAppointmentStatus,String draftToken,String relabelNo,List<Long> returnReceiptIds) {
this(planPalletIds,fbaAppointmentNo,fbaAppointmentStatus,vehicleAppointmentStatus,draftToken,relabelNo,returnReceiptIds,List.of());
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, String draftToken, String relabelNo) {
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, relabelNo, List.of());
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
}
public PreArrange(List<Long> planPalletIds, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) {
this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null);
}
}
public record DraftPallet(Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId,
Long returnReceiptId, String sourceType) {
public DraftPallet(Long planPalletId, String draftToken, Long draftContainerId) { this(planPalletId, draftToken, draftContainerId, null, "INBOUND_PLAN"); }
public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); }
@jakarta.validation.constraints.AssertTrue(message = "请选择唯一且匹配类型的实物来源")
public boolean isValidSource() {
return returnReceiptId != null ? planPalletId == null && "RETURN_RECEIPT".equals(sourceType)
: planPalletId != null && (sourceType == null || "INBOUND_PLAN".equals(sourceType));
}
}
public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {}
public record PreArrangeDraft(@NotBlank @Size(max = 64) String draftToken,
@NotEmpty @Size(max = 100) List<@NotNull @jakarta.validation.Valid DraftGroup> groups) {}
public record DraftGroup(@NotNull Long draftContainerId,
@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 500) List<@NotNull Long> returnReceiptIds,
@Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) {
public DraftGroup {
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations);
}
public DraftGroup(Long id,List<Long> pallets,List<Long> receipts) { this(id,pallets,receipts,List.of()); }
}
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
}
public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken,
@NotEmpty @Size(max = 500) List<@NotNull Long> planIds,
@NotBlank @Size(max = 20000) String containersJson) {
}
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
LocalDateTime departAt,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
LocalDateTime estimatedArrivalAt) {
}
}
@@ -1,35 +0,0 @@
package com.baiyue.lastleg.dispatch.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName,
String driverPhone, String carrierName, String orderNos, String route,
String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt,
String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches,
Actions actions) {
public record Actions(boolean canEdit, String editBlockReason,
boolean canCancel, String cancelBlockReason,
boolean canRemovePallet, String removePalletBlockReason,
boolean canTransfer, String transferBlockReason,
String outboundNo, String outboundStatus) { }
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
BigDecimal grossWeightKg, BigDecimal volumeWeightKg,
BigDecimal volumeCbm, String sourceStage) {
}
public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode,
String destinationWarehouseName, String warehouseNo, String fbaShipmentId,
String poReferenceNo, int packageCount) {
}
}
@@ -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;
}
@@ -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) { }
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/** 普通换标逐item/实际仓位的冻结贡献,原货源与换标履约单分开保留。 */
@Data
@TableName("ll_dispatch_item_source")
public class DispatchItemSource {
@TableId(type=IdType.INPUT) private Long id;
private Long dispatchOrderId;
private Long dispatchItemId;
private String customerId;
private Long relabelApplicationId;
private Long relabelItemId;
private String relabelRoundKey;
private Long warehousePalletId;
private Long inventoryPositionId;
private Long inventoryBalanceId;
private Long sourceInventoryBalanceId;
private Long sourceInboundOrderId;
private String sourceInboundOrderNo;
private String sourceInboundCode;
private Long notifiedPackageCount;
private Long packageCount;
private Long productQuantity;
private String bindingBasisHash;
private String unitFactsJson;
private LocalDateTime createdAt;
}
@@ -1,40 +0,0 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */
@Data
@TableName("ll_dispatch_order")
public class DispatchOrder {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String dispatchNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String vehiclePlate;
private String driverName;
private String driverPhone;
private String carrierName;
private String route;
private LocalDateTime departAt;
private String fbaAppointmentNo;
private String fbaAppointmentStatus;
private String vehicleAppointmentStatus;
private LocalDateTime estimatedArrivalAt;
private String status;
private Integer itemCount;
private Integer palletCount;
private Integer packageCount;
private Integer pickedPackageCount;
private Integer shippedPackageCount;
private String partialCompletionReason;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -9,15 +9,16 @@ import lombok.Data;
import java.time.LocalDateTime;
/** Prevents a source pallet from being added to more than one active dispatch order. */
/** 真实客户逐逻辑来源的活动占用,实际实托排他归独立物理占用行。 */
@Data
@TableName("ll_dispatch_pallet_lock")
public class DispatchPalletLock {
@TableId(type = IdType.INPUT)
private Long id;
private Long physicalLockId;
private String customerId;
private String sourcePalletKey;
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
/** 活动时为完整逻辑来源键,释放后为空;不同换标来源不再共用一个来源键。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeKey;
private Long planId;
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/** 实托排他占用归主单或草稿容器,不以任何逻辑来源或客户代表整托。 */
@Data
@TableName("ll_dispatch_physical_pallet_lock")
public class DispatchPhysicalPalletLock {
@TableId(type=IdType.INPUT) private Long id;
private String warehouseId;
private Long warehousePalletId;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private Long activeWarehousePalletId;
private Long dispatchOrderId;
private String draftToken;
private Long draftContainerId;
private String operatorId;
private String status;
private LocalDateTime expiresAt;
private LocalDateTime lockedAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -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<String,Workflow> workflows(List<String> numbers) {
if(numbers.size()>1000)throw conflict("整车审核批量读取超过上限");
Map<String,Workflow> 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<DispatchOrder>().eq(DispatchOrder::getId, dispatch.dispatchId())
.eq(DispatchOrder::getCustomerId, dispatch.customerId()).in(DispatchOrder::getStatus, "PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART")
.set(DispatchOrder::getVehiclePlate, assignment.vehiclePlate()).set(DispatchOrder::getDriverName, assignment.driverName())
.set(DispatchOrder::getDriverPhone, assignment.driverPhone()).set(DispatchOrder::getUpdatedAt, now)) != 1)
throw conflict("排车单已变化,请刷新后重试");
var outbound = outbounds.lockByDispatch(dispatch.customerId(), dispatch.dispatchId());
if (outbound == null) return;
for (var delivery : deliveries.lockByOutbound(dispatch.customerId(), outbound.getId())) {
if (!"WAIT_DELIVERY".equals(delivery.getStatus()) || delivery.getDispatchedAt() != null || delivery.getSignedAt() != null)
throw conflict("派送单 " + delivery.getDeliveryNo() + " 已执行,不能覆盖车辆司机");
if (deliveries.update(null, new LambdaUpdateWrapper<DeliveryOrder>().eq(DeliveryOrder::getId, delivery.getId())
.eq(DeliveryOrder::getCustomerId, dispatch.customerId()).eq(DeliveryOrder::getVersion, delivery.getVersion())
.set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName())
.set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, delivery.getVersion() + 1)
.set(DeliveryOrder::getUpdatedAt, now)) != 1) throw conflict("派送单已变化,请刷新后重试");
@Override public List<String> 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> 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<SourceCargo>();
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));
}
}
@Override
public FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock) {
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var task = lock ? tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : tasks.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
Map<Long, Long> picked = new HashMap<>();
if (task != null) for (var pick : picks.listTask(dispatch.getCustomerId(), task.getId())) picked.merge(pick.getDispatchItemId(), pick.getPackages(), Math::addExact);
var cargo = selected.stream().map(item -> new FleetExecutionModels.Cargo(dispatchNo, dispatch.getCustomerId(), item.getId().toString(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0),
destination(item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()),
"PRIVATE".equals(item.getDestinationType()) ? item.getDeliveryAddress() : first(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()))).toList();
String reason = null;
if (!"READY_TO_DEPART".equals(dispatch.getStatus()) || task == null || !"COMPLETED".equals(task.getStatus())) reason = "请完成拣货并核平全部箱数";
else if (!Objects.equals(dispatch.getOverseasWarehouseId(), task.getWarehouseId())) reason = "拣货任务所属仓不一致";
else if (selected.isEmpty() || selected.stream().anyMatch(item -> item.getPackageCount() == null || item.getPackageCount() <= 0
|| picked.getOrDefault(item.getId(), 0L).longValue() != item.getPackageCount().longValue())) reason = "每条货物须与实际拣货箱数核平";
else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物";
else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单";
else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认";
if (reason == null) reason = deliveryExecution.departureBlockReason(outbound, lock);
return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason);
return List.copyOf(result);
}
@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());
@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 static String destination(String type, String code, String fba, String address, String postalCode) {
return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode);
/** 先收齐所有客户出库锁,再锁所有派送;客户和仓归属从每行复验。 */
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<String,OutboundOrder>();
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<String,Long>();var numbers=new HashSet<String>();
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);
}
/** 用逐入仓组成核对外接承诺,不使用整托展示来源。 */
@Override public java.util.List<SourceCargo> 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()));
if(!counts.equals(expected))throw conflict("本车全部客户派送数量未核平");
return new Facts(row,deliveryRows);
}
@Override
public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) {
var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var rows = outbound == null ? java.util.List.<DeliveryOrder>of() : lock
? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId());
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);
private record Facts(DispatchAggregate root,List<DeliveryOrder> 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<FleetCompletionModels.Outcome>();
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()));
}
// 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<com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery>();
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());
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);
}
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 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<FleetTransitEvidence.Delivery>();
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())));
}
@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<DeliveryOrder>()
.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("派送交接保存失败,请刷新核对");
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<DeliveryOrder>().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("派送交接保存失败");
}
}
private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); }
private static DeliveryCargoQuantities quantities(DeliveryOrder row, java.util.List<com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact> 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<com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact> 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);}
}
@@ -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<DispatchCommands.CargoSelection>();
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)));
}
public PageResult<Candidate> 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());
if(!additions.isEmpty())workflow.addCargo(actor,number,new DispatchCommands.AddCargo(context.recordVersion(),key,additions,List.of()));
validateIncomingDispatch(number,customer,receipt);return number;
}
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<String,Integer>();
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<String,Integer>();for(var pallet:receipt.pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact);
var selected=new TreeMap<String,Integer>();
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);}
}
@@ -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({"<script>SELECT d.dispatch_no,d.customer_id,d.overseas_warehouse_id AS warehouse_id,d.package_count,d.route",
"FROM ll_dispatch_order d WHERE d.overseas_warehouse_id=#{warehouseId} AND d.status='PENDING_DEPARTURE'",
"AND d.package_count>0 AND COALESCE(d.picked_package_count,0)=0 AND COALESCE(d.shipped_package_count,0)=0",
"AND NOT EXISTS(SELECT 1 FROM ll_fleet_trip_dispatch b WHERE b.dispatch_id=d.id)",
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_fleet_external_booking eb ON eb.incoming_no=di.inbound_code",
"WHERE di.dispatch_order_id=d.id AND (eb.trip_no!=#{tripNo} OR eb.status!='RECEIVED'))",
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_task t WHERE t.dispatch_order_id=d.id AND t.status!='PDA_PENDING')",
"AND NOT EXISTS(SELECT 1 FROM ll_outbound_order o WHERE o.dispatch_order_id=d.id AND o.status!='WAIT_OUTBOUND')",
"<if test='keyword != null and keyword != &quot;&quot;'>AND (d.dispatch_no LIKE CONCAT('%',#{keyword},'%') OR d.route LIKE CONCAT('%',#{keyword},'%'))</if>",
"ORDER BY d.updated_at DESC,d.id DESC</script>"})
IPage<FleetDispatchPlanningGateway.Candidate> 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<String> 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<Long> 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")
@@ -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);
}
@@ -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<String> 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) { }
}
@@ -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("<script>SELECT * FROM ll_dispatch_order WHERE dispatch_no IN <foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach></script>")
java.util.List<com.baiyue.lastleg.dispatch.entity.DispatchAggregate> findMany(@Param("numbers") java.util.List<String> numbers);
String BASE_FILTER = """
<if test='q.dispatchNo != null'> AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%')</if>
<if test='q.vehiclePlate != null'> AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%')</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.customerId != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId})</if>
<if test='q.fbaAppointmentNo != null'> 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})</if>
""";
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 = """
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.reviewStatus != null and q.section == "DISPATCH_REVIEW"'> AND (
""" + REVIEW_DISPATCH + ")=#{q.reviewStatus}</if>"
+ "<if test='q.reviewStatus != null and q.section == \"LOADING_REVIEW\"'> AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}</if>";
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("<script>SELECT d.* FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER
+ " ORDER BY d.created_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<DispatchAggregate> page(@Param("q") Search query);
/** 分页总数复用完全相同筛选,不能用当前页长度替代。 */
@Select("<script>SELECT COUNT(*) FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER + "</script>")
long count(@Param("q") Search query);
/** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */
@Select("<script>SELECT d.status,d.stage,d.review_state AS reviewState,COUNT(*) AS quantity FROM ll_dispatch_order d WHERE 1=1 "
+ BASE_FILTER + " GROUP BY d.status,d.stage,d.review_state</script>")
List<Summary> 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);
}
@@ -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} " +
"GROUP BY di.id,l.id ORDER BY di.id,l.id")
List<DispatchResponse.BolLine> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
"WHERE di.dispatch_order_id=#{dispatchOrderId} " +
"GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id")
List<DispatchBolData.Line> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -40,7 +40,7 @@ public interface DispatchCandidateMapper {
"</script>")
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
@Select("<script>" +
String AVAILABLE_PLAN_BODY =
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
@@ -116,9 +116,8 @@ public interface DispatchCandidateMapper {
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
"AND (d.expires_at IS NULL OR d.expires_at &gt; CURRENT_TIMESTAMP(3)) AND (" +
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " +
"AND (d.expires_at IS NULL OR d.expires_at &gt; CURRENT_TIMESTAMP(3)) AND " +
"d.source_pallet_key=CONCAT('PLAN:',p.id) " +
"AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " +
// Incoming creation mode never grants ownership of a later transfer reservation.
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
@@ -134,8 +133,8 @@ public interface DispatchCandidateMapper {
"AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
"</script>")
"ORDER BY p.plan_id,p.sequence_no";
@Select("<script>" + AVAILABLE_PLAN_BODY + "</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
@Param("planIds") List<Long> planIds,
@Param("orderNo") String orderNo,
@@ -143,7 +142,13 @@ public interface DispatchCandidateMapper {
@Param("sourceStage") String sourceStage,
@Param("draftToken") String draftToken);
@Select("<script>" +
/** 公开整车候选先在数据库限制起运仓和数量,不按单客户缩小整车。 */
@Select("<script>SELECT * FROM (" + AVAILABLE_PLAN_BODY + ") selected WHERE selected.overseasWarehouseId=#{warehouseId} LIMIT 1001</script>")
List<DispatchPalletCandidate> wholeAvailable(@Param("warehouseId") String warehouseId,
@Param("customerId") String customerId,@Param("planIds") List<Long> planIds,@Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
String SELECTED_PLAN_SQL = "<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
@@ -222,12 +227,19 @@ public interface DispatchCandidateMapper {
"AND rla.status NOT IN ('REJECTED','CANCELLED','COMPLETED'))) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" +
"</script>")
"ORDER BY p.id";
/** 先普通读确定真实来源,再按来源订单→库存顺序取得写锁。 */
@Select(SELECTED_PLAN_SQL + "</script>")
List<DispatchPalletCandidate> readSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
/** 已有调用需要的明确锁定读取,投影与普通读保持同一事实。 */
@Select(SELECTED_PLAN_SQL + " FOR UPDATE</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
@Select("<script>" +
String RELABEL_BODY =
"SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,MAX(wtp.gross_weight_kg) AS grossWeightKg,NULL AS containerNo," +
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_code) AS overseasWarehouseCode,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
@@ -250,16 +262,21 @@ public interface DispatchCandidateMapper {
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
"AND pos.hold_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
"ON existing_dispatch.id=di.dispatch_order_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
"AND existing_dispatch.status!='CANCELLED') " +
"GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no" +
"</script>")
"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("<script>" + RELABEL_BODY + "</script>")
List<DispatchPalletCandidate> relabelAvailable(@Param("customerId") String customerId,
@Param("orderNo") String orderNo,
@Param("relabelNo") String relabelNo);
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," +
/** 换标来源也限定真实起运仓;超过上限必须明确缩小筛选。 */
@Select("<script>SELECT * FROM ("+RELABEL_BODY+") selected WHERE selected.overseasWarehouseId=#{warehouseId} LIMIT 1001</script>")
List<DispatchPalletCandidate> 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 +294,17 @@ public interface DispatchCandidateMapper {
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" +
"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</script>")
"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("<script>"+NATIVE_RELABEL_BODY+"</script>")
List<DispatchPalletCandidate> nativeReturnRelabelAvailable(@Param("customerId") String customerId,
@Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo);
/** 原生退货换标保持原账本,仓过滤与行数限制在数据库执行。 */
@Select("<script>SELECT * FROM ("+NATIVE_RELABEL_BODY+") selected WHERE selected.overseasWarehouseId=#{warehouseId} LIMIT 1001</script>")
List<DispatchPalletCandidate> 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);
@@ -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);
}
@@ -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,Long physicalLockId) { }
/** 来源已加锁后才读取本草稿活动占用;不跨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,physical_lock_id AS physicalLockId "
+ "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<Claim> owned(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at);
/** 每个逻辑来源独立保留客户和来源键,同容器同实托共享已取得的物理占用。 */
@Insert("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id,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},#{physical},#{customer},#{key},#{key},#{plan},#{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,@Param("physical")Long physical);
/** 布局移动仅匹配同草稿原容器,不能迁移他人新取得的占用。 */
@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,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);
}
@@ -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);
}
@@ -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<DispatchDraftContainer> {
/** 容器只在其草稿根锁内修改,普通读保持服务端顺序。 */
@Select("SELECT * FROM ll_dispatch_draft_container WHERE draft_token=#{draftToken} ORDER BY sequence_no,id")
List<DispatchDraftContainer> 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);
}
@@ -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<DispatchDraft> {
@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<DispatchDraft> findActiveByOperator(@Param("operatorId") String operatorId);
/** 草稿头只按拥有者隔离,不再依赖客户子排车或旧主单状态。 */
default List<DispatchDraft> 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<DispatchDraft> 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);
}
@@ -0,0 +1,81 @@
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,Long dispatchItemSourceId) { }
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(s.source_inbound_code,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,a.dispatch_item_source_id AS dispatchItemSourceId
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
LEFT JOIN ll_dispatch_item_source s ON s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id AND s.dispatch_item_id=i.id
WHERE i.dispatch_order_id=#{id} ORDER BY i.id,p.id,a.id
""")
List<PickSource> 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> itemPicked(@Param("id")long dispatchId);
/** 两端共同校验四种来源和逐贡献额度,避免同托来源互相补数量而放行。 */
String INVALID_PICK="""
(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)
OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id
AND NOT EXISTS(SELECT 1 FROM ll_order o WHERE o.id=a.order_id AND o.customer_id=a.customer_id))
OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item_source s WHERE s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id
AND s.dispatch_order_id=i.dispatch_order_id AND s.dispatch_item_id=i.id AND s.inventory_position_id=p.inventory_position_id
AND s.warehouse_pallet_id=i.warehouse_pallet_id AND i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER'
AND (SELECT COALESCE(SUM(x.packages),0) FROM ll_dispatch_pda_pick_order x WHERE x.dispatch_item_source_id=s.id AND x.customer_id=s.customer_id)<=s.package_count))
OR (i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER' AND EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a
WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NULL)))
""";
/** 每条扫码的不可变归属箱数与商品数必须完整,缺归属不能因总数相等而放行。 */
@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
"""+INVALID_PICK)
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=(SELECT SUM(x.package_count) FROM ll_dispatch_item x
WHERE x.dispatch_order_id=i.dispatch_order_id AND x.warehouse_pallet_id=i.warehouse_pallet_id) 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<ActualMeasurement> 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<RescanCandidate> rescan(@Param("limit")int limit);
}
@@ -11,6 +11,9 @@ import java.util.List;
@Mapper
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id")
List<DispatchItem> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItemSource;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 不可变来源快照只追加或随零扫码移货删除,不用聚合库存回推历史贡献。 */
@Mapper
public interface DispatchItemSourceMapper extends BaseMapper<DispatchItemSource> {
record PickedSource(Long sourceId,long packages,long productQuantity) { }
/** 读取整车完整贡献,客户和逻辑明细身份始终随行保留。 */
@Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} ORDER BY customer_id,dispatch_item_id,inventory_position_id,relabel_item_id,id")
List<DispatchItemSource> all(@Param("dispatch")long dispatch);
/** PDA列表一次读取所有本批主单贡献,不逐车逐来源查询。 */
@Select("<script>SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,dispatch_item_id,inventory_position_id,relabel_item_id,id</script>")
List<DispatchItemSource> batchAll(@Param("ids")List<Long> ids);
/** 一次核对冻结贡献的实际已消费箱件,不用同position其他申请的拣货抵消额度。 */
@Select("<script>SELECT s.id AS sourceId,COALESCE(SUM(a.packages),0) AS packages,COALESCE(SUM(a.product_quantity),0) AS productQuantity "
+ "FROM ll_dispatch_item_source s LEFT JOIN ll_dispatch_pda_pick_order a ON a.dispatch_item_source_id=s.id AND a.customer_id=s.customer_id "
+ "WHERE s.dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> GROUP BY s.id ORDER BY s.id</script>")
List<PickedSource> pickedAll(@Param("ids")List<Long> ids);
/** 扫码位置只能消费所选真实客户和逻辑明细的贡献。 */
@Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} AND customer_id=#{customer} "
+ "AND inventory_position_id=#{position} ORDER BY relabel_item_id,id")
List<DispatchItemSource> position(@Param("dispatch")long dispatch,@Param("item")long item,
@Param("customer")String customer,@Param("position")long position);
/** 额度按不可变真实拣货归属累计,不能以整仓位已拣数代替本来源已拣数。 */
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customer} AND dispatch_item_source_id=#{source}")
long picked(@Param("customer")String customer,@Param("source")long source);
/** 零扫码移货才允许删除;已有实际归属时由SQL再次阻断。 */
@Delete("DELETE FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} "
+ "AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.dispatch_item_source_id=ll_dispatch_item_source.id)")
int removeUnpicked(@Param("dispatch")long dispatch,@Param("item")long item);
}
@@ -1,22 +0,0 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE")
DispatchOrder lockGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -23,13 +23,6 @@ public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock>
List<DispatchPalletLock> lockActiveByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " +
"ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id " +
"WHERE l.draft_token=#{draftToken} AND l.draft_container_id=#{draftContainerId} AND l.status='ACTIVE' " +
"ORDER BY l.updated_at DESC,l.id DESC LIMIT 1")
Long findDispatchOrderIdByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
/** 物理层只保存实托与真正拥有者,逻辑来源份额仍由各自客户成员保存。 */
@Mapper
public interface DispatchPhysicalClaimMapper extends BaseMapper<DispatchPhysicalPalletLock> {
/** 到期但尚未清键的原行也必须先锁定,不能越过原拥有者领取。 */
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=#{pallet} FOR UPDATE")
DispatchPhysicalPalletLock lockActive(@Param("pallet")long pallet);
/** 释放按原物理占用主键取得,不能按实托号误释放新拥有者的后续占用。 */
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE id=#{id} FOR UPDATE")
DispatchPhysicalPalletLock lockOriginal(@Param("id")long id);
/** 只读原实托身份供服务排序,再逐主键取锁,避免filesort之前按主键先锁的执行计划。 */
@Select("<script>SELECT * FROM ll_dispatch_physical_pallet_lock WHERE id IN "
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY warehouse_pallet_id,id</script>")
List<DispatchPhysicalPalletLock> originals(@Param("ids")List<Long> ids);
/** 批量释放、转换与续约前按物理托盘顺序收齐本主单的原行。 */
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE dispatch_order_id=#{dispatch} AND status='ACTIVE' ORDER BY warehouse_pallet_id,id")
List<DispatchPhysicalPalletLock> lockDispatch(@Param("dispatch")long dispatch);
/** 草稿归属同时包含token与操作人,容器身份由每条原行保留。 */
@Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE draft_token=#{token} AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' ORDER BY warehouse_pallet_id,id")
List<DispatchPhysicalPalletLock> lockDraft(@Param("token")String token,@Param("operator")String operator);
/** 仅使该原物理占用下已到期的草稿成员失效,不释放正式来源或已经续期的成员。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,released_at=#{at},updated_at=#{at} "
+ "WHERE physical_lock_id=#{id} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} "
+ "AND EXISTS(SELECT 1 FROM ll_dispatch_physical_pallet_lock p WHERE p.id=#{id} AND p.status='ACTIVE' AND p.dispatch_order_id IS NULL "
+ "AND p.draft_token=ll_dispatch_pallet_lock.draft_token AND p.draft_container_id=ll_dispatch_pallet_lock.draft_container_id "
+ "AND p.operator_id=ll_dispatch_pallet_lock.operator_id AND p.expires_at<=#{at})")
int expireMembers(@Param("id")long id,@Param("at")LocalDateTime at);
/** 最后活动成员结束后才能清物理键;原id保证不会触碰同托后续的新占用。 */
@Update("UPDATE ll_dispatch_physical_pallet_lock SET status=#{status},active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} "
+ "WHERE id=#{id} AND status='ACTIVE' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} AND l.status='ACTIVE')")
int releaseUnused(@Param("id")long id,@Param("status")String status,@Param("at")LocalDateTime at);
/** 草稿成员已经在同事务全部移入目标容器后,才允许迁移物理拥有者。 */
@Update("UPDATE ll_dispatch_physical_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} "
+ "AND draft_token=#{token} AND operator_id=#{operator} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL "
+ "AND status='ACTIVE' AND expires_at>#{at} AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} "
+ "AND l.status='ACTIVE' AND (l.dispatch_order_id IS NOT NULL OR l.draft_token IS NULL OR l.draft_token<>#{token} "
+ "OR l.draft_container_id IS NULL OR l.draft_container_id<>#{next} OR l.operator_id IS NULL OR l.operator_id<>#{operator}))")
int moveDraft(@Param("id")long id,@Param("token")String token,@Param("operator")String operator,
@Param("previous")long previous,@Param("next")long next,@Param("at")LocalDateTime at);
/** 活动续约不复活已到期记录,期限与逻辑来源及草稿根一起提交。 */
@Update("UPDATE ll_dispatch_physical_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} "
+ "AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}")
int renewDraft(@Param("token")String token,@Param("operator")String operator,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires);
}
@@ -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<OutboundReference> 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<DeliveryRow> 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<BatchRow> 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);
}
@@ -0,0 +1,143 @@
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) { }
record NativeApplication(String customerId,String relabelNo) { }
record PlanReference(Long planPalletId,String customerId) { }
record ReturnReference(Long receiptId,String customerId) { }
record OrderReference(Long orderId,String customerId) { }
record ItemPicked(Long itemId,long packages) { }
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
@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=#{dispatch} GROUP BY i.id")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<ItemPicked> pickedItems(@Param("dispatch")long dispatch);
/** 组成查询不按占用可选性筛掉本草稿或隐藏客户的真实成员。 */
@Select("<script>SELECT DISTINCT p.id AS planPalletId,ip.customer_id AS customerId FROM ll_inbound_plan_pallet p "
+ "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
+ "JOIN ll_inventory_position s ON s.pallet_id=p.warehouse_pallet_id AND s.customer_id=ip.customer_id "
+ "AND s.overseas_warehouse_id=CAST(ip.overseas_warehouse_id AS CHAR) "
+ "WHERE s.overseas_warehouse_id=#{warehouse} AND p.warehouse_pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY ip.customer_id,p.id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<PlanReference> physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
/** 退回实收的事实身份不因当前草稿占用而消失。 */
@Select("<script>SELECT r.id AS receiptId,r.customer_id AS customerId 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 p.overseas_warehouse_id=#{warehouse} AND p.pallet_id IN "
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY r.customer_id,r.id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<ReturnReference> physicalReturns(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
/** 当前余额对应的真实实收订单参加共同锁序,不拿展示MIN或原货源快照ID猜订单表身份。 */
@Select("<script>SELECT DISTINCT x.order_id AS orderId,x.customer_id AS customerId FROM ("
+ "SELECT r.order_id,r.customer_id FROM ll_inventory_position c JOIN ll_inventory_position p ON p.balance_id=c.balance_id AND p.customer_id=c.customer_id "
+ "JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=p.id AND r.customer_id=p.customer_id WHERE c.overseas_warehouse_id=#{warehouse} AND c.pallet_id IN "
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> UNION "
+ "SELECT r.order_id,r.customer_id FROM ll_inventory_position c JOIN ll_return_stock_receipt r ON r.inventory_balance_id=c.balance_id AND r.customer_id=c.customer_id "
+ "WHERE c.overseas_warehouse_id=#{warehouse} AND c.pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>) x ORDER BY x.order_id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<OrderReference> physicalOrders(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
/** 共同串行只补齐真实身份锁,不给额外原货源或仓内操作新增暂扣资格。 */
@Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} FOR UPDATE")
Long lockOrderIdentity(@Param("customer")String customer,@Param("id")long id);
/** 原生退货实托仍由真实箱唛与申请号关联,不借普通库存仓位推断。 */
@Select("<script>SELECT DISTINCT customer_id AS customerId,relabel_no AS relabelNo FROM ll_return_inventory_carton "
+ "WHERE overseas_warehouse_id=#{warehouse} AND status='RELABEL_PENDING' AND relabel_no IS NOT NULL "
+ "AND pallet_code IN <foreach collection='codes' item='code' open='(' separator=',' close=')'>#{code}</foreach> ORDER BY relabel_no</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<NativeApplication> nativeApplications(@Param("warehouse")String warehouse,@Param("codes")List<String> codes);
/** 换标单客户由服务端业务单号查得,不接受客户端指定。 */
@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<Long> 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("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,available_packages AS availablePackages,"
+ "hold_packages AS holdPackages,allocated_packages AS allocatedPackages,picked_packages AS pickedPackages,version "
+ "FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id=#{pallet} ORDER BY id"
+ "<if test='lock'> FOR UPDATE</if></script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<Stock> 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<Receipt> 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<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=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}")
int expire(@Param("customer") String customer,@Param("key") String key,@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} "
+ "AND (expires_at IS NULL OR expires_at>#{at}) ORDER BY id FOR UPDATE")
List<Claim> claims(@Param("customer") String customer,@Param("key") String key,@Param("at") LocalDateTime at);
/** 精确释放本车本客户完整来源键,不能按共享实托一起释放另一申请。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND source_pallet_key=#{key} AND status='ACTIVE'")
int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key,@Param("at")LocalDateTime at);
/** 原计划收货只补齐本逻辑成员的实际实托和已取得的物理占用引用。 */
@Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},physical_lock_id=#{physical},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 AND physical_lock_id IS NULL")
int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("physical")long physical,@Param("at")LocalDateTime at);
/** 本来源活动唯一键独立保存,实际实托必须关联本拥有者的物理占用。 */
@Insert("""
INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id,
warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at)
VALUES(#{id},#{physical},#{customer},#{key},#{key},#{planPallet},#{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("physical")Long physical,
@Param("dispatch")long dispatch,@Param("operator")String operator,@Param("at")LocalDateTime at);
}
@@ -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("<script>" + SELECT + "WHERE 1=1 " + ELIGIBLE +
String AVAILABLE_BODY = SELECT + "WHERE 1=1 " + ELIGIBLE +
"<if test='customerId != null'>AND r.customer_id=#{customerId} </if>" +
"<if test='orderNo != null'>AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR b.inbound_code LIKE CONCAT('%',#{orderNo},'%') OR r.receipt_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
"<if test='fba != null'>AND d.destination_warehouse_code=#{fba} </if>" +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.customer_id=r.customer_id " +
"AND k.source_pallet_key=CONCAT('RECEIVED:',p.pallet_id) AND k.status='ACTIVE' " +
"AND k.source_pallet_key=CONCAT('RETURN:',r.id) AND k.status='ACTIVE' " +
"AND (k.expires_at IS NULL OR k.expires_at>CURRENT_TIMESTAMP) AND (#{draftToken} IS NULL OR k.draft_token IS NULL OR k.draft_token != #{draftToken})) " +
"ORDER BY r.received_at,r.id</script>")
"ORDER BY r.received_at,r.id";
@Select("<script>" + AVAILABLE_BODY + "</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId, @Param("orderNo") String orderNo,
@Param("fba") String fba, @Param("draftToken") String draftToken);
/** 退回候选按真实库存仓过滤,保持多客户读取。 */
@Select("<script>SELECT * FROM ("+AVAILABLE_BODY+") selected WHERE selected.overseasWarehouseId=#{warehouseId} LIMIT 1001</script>")
List<DispatchPalletCandidate> wholeAvailable(@Param("warehouseId") String warehouseId,
@Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("fba") String fba,@Param("draftToken") String draftToken);
/** 只读解析选定退回来源,避免在来源订单前抢占库存锁。 */
@Select("<script>" + SELECT + "WHERE r.customer_id=#{customerId} AND r.id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>" + ELIGIBLE + " ORDER BY r.id</script>")
List<DispatchPalletCandidate> readSelected(@Param("customerId") String customerId, @Param("ids") List<Long> ids);
@Select("<script>" + SELECT + "WHERE r.customer_id=#{customerId} AND r.id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>" + ELIGIBLE + " ORDER BY r.id FOR UPDATE</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId, @Param("ids") List<Long> ids);
@@ -0,0 +1,15 @@
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<Line> bolLines,Map<String,String> 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 destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;}
public String groupKey(){return customerId+"\u0000"+destinationKey();}
}
public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());}
}
@@ -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<DispatchPdaResponses.Task>> 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<DispatchViews.PdaTask>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false)DispatchTypes.Stage stage) {
return ApiResponse.success(queries.list(DispatchActorFactory.pda(user),stage));
}
/** 使用排车业务号读取当前仓真实货物和扫码进度。 */
@GetMapping("/{dispatchNo}")
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
public ApiResponse<DispatchViews.PdaTask> 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<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
public ApiResponse<DispatchContracts.CommandResult> 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<DispatchPdaResponses.PickingCompleted> 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<DispatchPlanningModels.LoadingPreview> loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo));
}
/** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */
@PostMapping("/{dispatchNo}/loading-complete")
public ApiResponse<DispatchContracts.CommandResult> 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<FleetExecutionModels.Departed> depart(@AuthenticationPrincipal AuthenticatedUser user,
public ApiResponse<DispatchContracts.CommandResult> 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<DispatchContracts.CommandResult> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(workflow.command(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey));
}
}
@@ -1,19 +0,0 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class DispatchPdaRequests {
private DispatchPdaRequests() {}
public record Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) {
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
}
}
public record Depart(@NotBlank String idempotencyKey, String remark) {}
public record CompletePicking(@NotBlank String idempotencyKey) {}
}
@@ -1,43 +1,6 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import java.util.List;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
/** 原生退货换标箱的内部读取投影。 */
public final class DispatchPdaResponses {
private DispatchPdaResponses() {}
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
boolean canDepart, String departBlockReason, List<Item> items, String tripNo, Long tripRecordVersion, int tripDispatchCount, String driverWarning,
boolean canPick, String pickBlockReason) {
public Task withGate(FleetExecutionModels.Gate gate) {
return new Task(id, taskNo, dispatchNo, vehiclePlate, warehouseId, status, gate.canDepart(), gate.blockReason(), items,
gate.tripNo(), gate.recordVersion(), gate.dispatchCount(), gate.driverWarning(), gate.canPick(), gate.pickBlockReason());
}
}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages,
long pickedPackages, List<Position> positions, List<ReturnCarton> returnCartons) {
public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages, long pickedPackages, List<Position> positions) {
this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode,
warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of());
}
}
public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
boolean palletScrapped, boolean replayed) {}
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
}
public record PickingCompleted(String dispatchNo, String status, long plannedPackages,
long pickedPackages, long remainingPackages, boolean partial,
boolean replayed) {
public PickingCompleted withReplay() {
return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages,
remainingPackages, partial, true);
}
}
private DispatchPdaResponses() { }
public record ReturnCarton(Long id,String cartonMark,String status,long productQuantity) { }
}
@@ -17,6 +17,7 @@ public class DispatchPdaPickOrder {
private Long receiptId;
private Long returnReceiptId;
private Long returnCartonId;
private Long dispatchItemSourceId;
private Long orderId;
private Long packages;
private Long productQuantity;
@@ -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;
@@ -7,6 +7,9 @@ import java.util.List;
@Mapper
public interface DispatchPdaPickMapper extends BaseMapper<DispatchPdaPick> {
/** 按真实明细客户读取全车归属,不要求整车主单存在客户列。 */
@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<DispatchPdaPick> 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<DispatchPdaPick> 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}")
@@ -18,12 +18,14 @@ public interface DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOr
CASE WHEN a.receipt_id IS NOT NULL THEN i.product_name_zh ELSE b.product_name_zh END AS productName,
COALESCE(a.packages,p.packages) AS packages
FROM ll_dispatch_pda_task t
JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND BINARY p.customer_id=BINARY t.customer_id
JOIN ll_dispatch_pda_pick p ON p.task_id=t.id
JOIN ll_dispatch_item di ON di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id
AND BINARY di.customer_id=BINARY p.customer_id
LEFT JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND BINARY a.customer_id=BINARY p.customer_id
LEFT JOIN ll_inbound_stock_receipt i ON i.id=a.receipt_id AND BINARY i.customer_id=BINARY a.customer_id
LEFT JOIN ll_inventory_position ip ON ip.id=p.inventory_position_id AND BINARY ip.customer_id=BINARY p.customer_id
LEFT JOIN ll_inventory_balance b ON b.id=ip.balance_id AND BINARY b.customer_id=BINARY ip.customer_id
WHERE BINARY t.customer_id=BINARY #{customerId} AND t.dispatch_order_id=#{dispatchId}
WHERE BINARY p.customer_id=BINARY #{customerId} AND t.dispatch_order_id=#{dispatchId}
ORDER BY p.id,a.id
""")
List<DeliveryPickedSku> deliveryPickedSkus(@Param("customerId") String customerId,
@@ -8,11 +8,11 @@ import java.util.List;
@Mapper
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
@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")
@@ -0,0 +1,36 @@
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("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
+"<otherwise>AND stage IN('LOADING','AFTER_LOADING','DELIVERING') </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
String ROOTS="<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
/** 一次读取选中整车所有真实客户货物。 */
@Select("<script>SELECT * FROM ll_dispatch_item WHERE dispatch_order_id IN "+ROOTS+" ORDER BY dispatch_order_id,customer_id,id</script>")
List<DispatchItem> items(@Param("ids")List<Long> ids);
/** 实际扫码按真实明细客户合计,不依赖客户任务头的假归属。 */
@Select("<script>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 IN "+ROOTS+" GROUP BY i.id</script>")
List<Picked> picked(@Param("ids")List<Long> ids);
/** 每条扫码的来源订单、箱数与商品数均须真实完整。 */
@Select("<script>SELECT i.dispatch_order_id AS dispatchId,COUNT(*) AS quantity 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 IN "+ROOTS
+" AND <![CDATA["+com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper.INVALID_PICK+"]]> GROUP BY i.dispatch_order_id</script>")
List<Invalid> invalid(@Param("ids")List<Long> ids);
/** 仓位先按本批明细客户、实托及绑定仓一次读取。 */
@Select("<script>SELECT DISTINCT p.* FROM ll_dispatch_item i JOIN ll_inventory_position p ON p.customer_id=i.customer_id AND p.pallet_id=i.warehouse_pallet_id WHERE i.dispatch_order_id IN "+ROOTS+" AND p.overseas_warehouse_id=#{warehouse} ORDER BY p.id</script>")
List<InventoryPosition> positions(@Param("ids")List<Long> ids,@Param("warehouse")String warehouse);
/** 原生退货箱仍从原账本取真实箱号,精确选中集合由业务层再比对。 */
@Select("<script>SELECT i.id AS itemId,c.id AS cartonId,c.carton_mark AS cartonMark,c.status,COALESCE(c.total_quantity,0) AS productQuantity FROM ll_dispatch_item i JOIN ll_return_inventory_carton c ON c.customer_id=i.customer_id AND c.pallet_code=i.pallet_no AND c.relabel_no=i.source_business_no WHERE i.dispatch_order_id IN "+ROOTS+" AND i.source_stage='RETURN_RELABEL' AND c.overseas_warehouse_id=#{warehouse} ORDER BY i.id,c.id</script>")
List<Carton> cartons(@Param("ids")List<Long> ids,@Param("warehouse")String warehouse);
}

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