Compare commits

..
Author SHA1 Message Date
ouyangxq a13586aff0 Group BOL cargo by destination and actual appointment allocations 2026-10-20 12:08:24 +08:00
ouyangxq 6c59525dec Align submit review action eligibility with persisted review state 2026-10-20 11:24:19 +08:00
ouyangxq c76b9888f5 Read frozen dispatch driver license through its exact driver owner 2026-10-20 10:38:20 +08:00
ouyangxq 135ffa662d Fix dispatch physical warehouse binding and empty cancellation 2026-10-20 09:32:13 +08:00
ouyangxq a8037fd97d Fix native dispatch warehouse text projection and TEST receipt setup 2026-10-20 09:16:16 +08:00
ouyangxq d7616899d8 Unify dispatch operations with matched published transfer fixture 2026-10-20 00:29:25 +08:00
ouyangxq a563f63857 feat(appointments): expose destination warehouse options 2026-10-19 17:02:25 +08:00
ouyangxq eb167347f6 test(transfer): prepare source snapshot before foreign relabel ownership check 2026-10-19 15:19:09 +08:00
ouyangxq c8608d50ba merge: retain current overseas inventory fixes with looseboard demand 2026-10-19 15:11:45 +08:00
ouyangxq dff040b509 feat(looseboard): require route contacts and pickup delivery times 2026-10-19 15:11:15 +08:00
liuyihe 26a203b4eb 提交 2026-10-19 15:00:25 +08:00
ouyangxq 21128b895f feat(trucking): refine truck demand contacts vehicle and service times 2026-10-19 14:34:33 +08:00
ouyangxq fafcf75014 fix(appointments): exclude pickup trips from delivery calendar 2026-10-19 12:21:57 +08:00
ouyangxq aa195cef66 feat(appointments): add four-type calendar sources and align release contracts 2026-10-19 12:15:43 +08:00
liuyihe fe1b2a6a46 提交 2026-10-19 11:54:29 +08:00
liuyihe e8f6bb32dc 提交 2026-10-19 10:23:09 +08:00
ouyangxq 45fbd6eff8 fix(lastleg): reconfirm received bookings after releasing all own cargo 2026-10-18 19:53:35 +08:00
ouyangxq 2c330bf9df test(lastleg): derive merchant relabel scope from profile receipt 2026-10-18 19:29:35 +08:00
ouyangxq 4bef8577fa test(lastleg): verify dispatch repair with current-run HTTP evidence 2026-10-18 19:21:12 +08:00
ouyangxq db4354bda8 fix(lastleg): align return receipt fixtures and schema totals 2026-10-18 18:45:47 +08:00
ouyangxq aa33514ec6 fix(lastleg): repair dispatch cancellation and return execution contracts 2026-10-18 16:34:14 +08:00
ouyangxq 66a0beb653 test(lastleg): mark diagnostic fixture mapper for registration contract 2026-10-18 11:49:47 +08:00
ouyangxq 5cd108b39a chore(lastleg): instrument appointment latency from restored 662dead baseline 2026-10-18 11:43:10 +08:00
ouyangxq b0acbfbb34 perf(lastleg): batch appointment requirement generation reads 2026-10-17 12:54:39 +08:00
ouyangxq 662dead053 chore(lastleg): diagnose work fact synchronization phases 2026-10-17 11:37:57 +08:00
ouyangxq 2971d6f7f0 perf(lastleg): reuse warehouse directory within sync generation 2026-10-17 09:57:54 +08:00
ouyangxq 81e4123b10 perf(lastleg): limit transport reconciliation facts and trace sync phases 2026-10-17 09:11:00 +08:00
ouyangxq 732cf99007 perf(lastleg): reuse work items within synchronization batch 2026-10-17 08:30:11 +08:00
ouyangxq 5fd74e9c8c perf(lastleg): batch receiving candidates and trace slow work invalidation 2026-10-17 07:49:56 +08:00
ouyangxq af97cd17d0 Merge production prebuild test fixes into frozen release 2026-10-16 18:44:42 +08:00
ouyangxq 5f5133bd68 test(lastleg): separate stable carton marks from return numbers 2026-10-16 18:44:32 +08:00
ouyangxq 339c70bb56 test(lastleg): isolate SQL semantic tests in bounded CI 2026-10-16 18:41:25 +08:00
ouyangxq e4bf573146 test: integrate production preflight contract correction 2026-10-16 18:14:49 +08:00
ouyangxq 4348296daf test(lastleg): validate shared appointment contract references 2026-10-16 18:14:37 +08:00
liuyihe 9ea03eb540 提交 2026-10-16 17:43:07 +08:00
liuyihe c9186c37a5 提交 2026-10-16 16:56:33 +08:00
liuyihe 6211d4ae37 提交 2026-10-16 16:53:32 +08:00
ouyangxq fee0268391 fix(appointment): link customer work to original batch requirement 2026-10-16 16:38:33 +08:00
ouyangxq 38a1f1079a Merge remote-tracking branch 'origin/test' into test 2026-10-16 16:21:51 +08:00
ouyangxq 58685945b1 fix(appointment): exclude cancelled batches without active allocations 2026-10-16 16:21:51 +08:00
510 changed files with 38324 additions and 12788 deletions
+7
View File
@@ -285,9 +285,14 @@ services:
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
expose: ["9303"]
# 仅限制尾程服务,避免 JVM 异常时拖垮测试服务器.
mem_limit: 3g
memswap_limit: 3g
cpus: "2.0"
environment:
<<: *common-environment
SERVER_PORT: 9303
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
@@ -333,3 +338,5 @@ networks:
infra-network:
external: true
name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network}
+10 -4
View File
@@ -1,5 +1,11 @@
# 单测试环境数据库空库初始化
排车整车作业结构同步于 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 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
@@ -45,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` | 214 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -55,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`,服务启动不执行这些文件。
@@ -68,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/214`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -84,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`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -0,0 +1,2 @@
-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。
ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空';
@@ -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));
+385 -124
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',
@@ -754,13 +860,17 @@ CREATE TABLE ll_exception_return_item (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
exception_order_id bigint NOT NULL COMMENT '异常订单ID',
outbound_pallet_item_id bigint NOT NULL COMMENT '原出库托盘明细ID',
inventory_position_id bigint NOT NULL COMMENT '原出库库存位置ID',
inventory_position_id bigint DEFAULT NULL COMMENT '普通原出库库存位置;与退货原箱互斥',
return_carton_id bigint DEFAULT NULL COMMENT '原生退货箱标识;与普通库存位置互斥',
return_carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原生退货箱业务箱码快照',
pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照',
max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages bigint NOT NULL COMMENT '批准退回箱数',
created_at datetime(3) NOT NULL COMMENT '创建时间',
received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数',
CONSTRAINT chk_exception_return_source CHECK((inventory_position_id IS NOT NULL AND return_carton_id IS NULL) OR (inventory_position_id IS NULL AND return_carton_id IS NOT NULL)),
UNIQUE KEY uk_ll_exception_return_carton(customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id),
CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages),
PRIMARY KEY (id),
@@ -768,6 +878,38 @@ CREATE TABLE ll_exception_return_item (
KEY idx_ll_exception_return_order (customer_id,exception_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细';
CREATE TABLE ll_return_carton_stock_receipt (
id BIGINT NOT NULL COMMENT '原箱回仓凭据明细标识',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号',
exception_order_id BIGINT NOT NULL COMMENT '本次异常退回订单',
exception_return_item_id BIGINT NOT NULL COMMENT '本次冻结的原箱来源明细',
delivery_order_id BIGINT NOT NULL COMMENT '原运输派送单',
source_pick_id BIGINT NOT NULL COMMENT '原箱实际出库拣货凭据',
source_outbound_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号',
source_dispatch_item_id BIGINT NOT NULL COMMENT '原排车货物明细',
source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细',
return_carton_id BIGINT NOT NULL COMMENT '真实退货原箱标识',
carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照',
overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识',
target_pallet_id BIGINT NOT NULL COMMENT '实际回仓目标实物托盘',
target_pallet_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码',
target_location_id BIGINT NOT NULL COMMENT '实际回仓目标库位',
target_location_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码',
sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码',
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '原出库快照中的中文品名',
quantity BIGINT NOT NULL COMMENT '本次原SKU实际恢复件数',
packages BIGINT NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零',
evidence_file_ids_json JSON NOT NULL COMMENT '本次实收证据文件引用',
received_by VARCHAR(128) NOT NULL COMMENT '本次原箱实收确认操作人',
received_at DATETIME(3) NOT NULL COMMENT '实际原箱回仓入账时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_carton_receipt_source(customer_id,source_pick_id,sku_code),
KEY idx_return_carton_receipt_exception(customer_id,exception_order_id,received_at),
KEY idx_return_carton_receipt_delivery(customer_id,delivery_order_id),
CONSTRAINT chk_return_carton_receipt_quantity CHECK(quantity>0 AND packages IN (0,1))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据';
CREATE TABLE ll_finance_fee_command (
id bigint NOT NULL COMMENT '幂等命令ID',
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
@@ -1451,22 +1593,21 @@ CREATE TABLE ll_local_looseboard_order (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货',
package_count int NOT NULL DEFAULT '0' COMMENT '货物件数',
total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)',
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式',
requested_date date DEFAULT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
@@ -1480,7 +1621,7 @@ CREATE TABLE ll_local_looseboard_order (
UNIQUE KEY uk_ll_local_looseboard_key (customer_id,idempotency_key),
UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id),
KEY idx_ll_local_looseboard_list (customer_id,status,created_at),
KEY idx_ll_local_looseboard_date (customer_id,requested_date,created_at)
KEY idx_ll_local_looseboard_date (customer_id,pickup_at,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单';
CREATE TABLE ll_monthly_exchange_rate (
@@ -1792,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',
@@ -1813,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='出库托盘明细';
@@ -2689,13 +2895,12 @@ CREATE TABLE ll_us_truck_order (
id bigint NOT NULL COMMENT '主键ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '订单号',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报',
package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效',
received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数',
allocated_pallet_count int NOT NULL DEFAULT '0' COMMENT '已分配到合车行程的托盘数',
@@ -2703,9 +2908,12 @@ CREATE TABLE ll_us_truck_order (
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算金额',
currency varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人联系方式',
requested_date date NOT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人',
origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人',
destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区',
delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '美国卡车订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
@@ -2718,7 +2926,7 @@ CREATE TABLE ll_us_truck_order (
UNIQUE KEY uk_ll_us_truck_order_no (customer_id,order_no),
UNIQUE KEY uk_ll_us_truck_order_key (customer_id,idempotency_key),
KEY idx_ll_us_truck_order_list (customer_id,status,created_at),
KEY idx_ll_us_truck_order_date (customer_id,requested_date,created_at)
KEY idx_ll_us_truck_order_date (customer_id,pickup_at,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车运输订单';
CREATE TABLE ll_us_truck_order_operation (
@@ -2892,6 +3100,7 @@ CREATE TABLE ll_fleet_vehicle_record_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
CREATE TABLE ll_fleet_trip (
trip_purpose VARCHAR(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓',
id BIGINT NOT NULL COMMENT '内部主键',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
@@ -2908,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 '按顺序保存的路线站点',
@@ -3081,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 '出车实测历史主键',
@@ -3270,7 +3477,7 @@ CREATE TABLE ll_warehouse_transfer_carton (
sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细',
id bigint NOT NULL COMMENT '预报箱主键',
transfer_order_id bigint NOT NULL COMMENT '转运单ID',
transfer_item_id bigint NOT NULL COMMENT '转运申报明细ID',
transfer_item_id bigint DEFAULT NULL COMMENT '转运申报明细ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛',
customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定',
@@ -4621,4 +4828,58 @@ CREATE TABLE ll_compact_number_clock (
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 214 AS initialized_tables;
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
USE baiyue_lastleg;
CREATE TABLE ll_fleet_pickup_plan (
trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号',
request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要',
PRIMARY KEY(trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划';
CREATE TABLE ll_fleet_pickup_task (
transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号',
trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空',
status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓',
actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收',
picked_at DATETIME NULL COMMENT '实际确认取货UTC时间',
arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间',
evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据',
remark VARCHAR(1000) NULL COMMENT '取货备注',
updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间',
PRIMARY KEY(transfer_order_id),
UNIQUE KEY uk_pickup_source(customer_id,transfer_no),
KEY idx_pickup_trip(trip_no,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行';
CREATE TABLE ll_fleet_pickup_command (
trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号',
request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要',
PRIMARY KEY(trip_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录';
CREATE TABLE ll_fleet_pickup_upload (
transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单',
trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次',
file_id BIGINT NOT NULL COMMENT '平台归属文件ID',
PRIMARY KEY(file_id),
KEY idx_pickup_upload_source(transfer_order_id,trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属';
CREATE TABLE ll_fleet_pickup_history (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号',
trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号',
action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME NOT NULL COMMENT '操作UTC时间',
detail VARCHAR(2000) NULL COMMENT '操作说明',
PRIMARY KEY(id),
KEY idx_pickup_history_source(customer_id,transfer_no,id),
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

+90
View File
@@ -0,0 +1,90 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1280" viewBox="0 0 1800 1280">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#335c81"/></marker>
<marker id="greenArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#18865b"/></marker>
<marker id="redArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#c94d4d"/></marker>
<style>
.title{font:700 34px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.sub{font:400 17px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
.lane{font:700 20px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.boxTitle{font:700 19px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.boxText{font:400 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#36566d}
.small{font:400 14px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
.assert{font:500 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#23445a}
.line{stroke:#335c81;stroke-width:3;fill:none;marker-end:url(#arrow)}
.gline{stroke:#18865b;stroke-width:3;fill:none;marker-end:url(#greenArrow)}
.rline{stroke:#c94d4d;stroke-width:3;fill:none;marker-end:url(#redArrow)}
</style>
</defs>
<rect width="1800" height="1280" fill="#f6f9fc"/>
<text x="70" y="62" class="title">海外业务|库存变动测试总流程图</text>
<text x="70" y="94" class="sub">测试看库存:先看库存状态,再看数量、位置、流水和幂等;灰色节点只改状态/资源,不直接改可用库存。</text>
<g transform="translate(70,125)">
<rect width="1660" height="58" rx="12" fill="#e9f1f8"/>
<circle cx="28" cy="29" r="10" fill="#18865b"/><text x="50" y="35" class="small">绿色:库存增加/恢复</text>
<circle cx="250" cy="29" r="10" fill="#c94d4d"/><text x="272" y="35" class="small">红色:库存扣减</text>
<circle cx="450" cy="29" r="10" fill="#e19a28"/><text x="472" y="35" class="small">橙色:冻结/释放</text>
<circle cx="690" cy="29" r="10" fill="#7657a8"/><text x="712" y="35" class="small">紫色:SKU/箱唛变化</text>
<circle cx="960" cy="29" r="10" fill="#78909c"/><text x="982" y="35" class="small">灰色:状态/资源变化</text>
<text x="1260" y="35" class="small">所有写操作:租户隔离 + 版本/锁 + 幂等键</text>
</g>
<g transform="translate(70,220)">
<rect width="1660" height="196" rx="16" fill="#edf5ee" stroke="#b7d9c2" stroke-width="2"/>
<text x="24" y="34" class="lane">A. 库存来源与入仓</text>
<rect x="24" y="58" width="240" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">头程装柜完成事件</text><text x="44" y="116" class="boxText">生成尾程单 / 冻结明细</text><text x="44" y="140" class="small">在途:不可用库存</text>
<path d="M264 106 H330" class="line"/>
<rect x="330" y="58" width="250" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
<text x="350" y="88" class="boxTitle">平台转换入仓计划</text><text x="350" y="116" class="boxText">按入仓单 / 推荐托数建计划</text><text x="350" y="140" class="small">仍未形成可用库存</text>
<path d="M580 106 H646" class="line"/>
<rect x="646" y="58" width="290" height="96" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
<text x="666" y="88" class="boxTitle">PDA 签收 → 实收 → 上架</text><text x="666" y="116" class="boxText">完成后入仓库存入账</text><text x="666" y="140" class="small">AVAILABLE + 位置 + 库存流水</text>
<path d="M936 106 H1002" class="gline"/>
<rect x="1002" y="58" width="280" height="96" rx="12" fill="#e6f6ed" stroke="#18865b" stroke-width="2"/>
<text x="1022" y="88" class="boxTitle">可用库存池</text><text x="1022" y="116" class="boxText">客户 × 海外仓 × 目的仓</text><text x="1022" y="140" class="small">入仓单汇总;托盘只做作业维度</text>
<rect x="1316" y="58" width="310" height="96" rx="12" fill="#f0f2f4" stroke="#9baab4" stroke-width="2"/>
<text x="1336" y="88" class="boxTitle">门禁与失败</text><text x="1336" y="116" class="boxText">照片、仓绑定、客户/柜号</text><text x="1336" y="140" class="small">任一失败:库存与状态整体回滚</text>
</g>
<g transform="translate(70,450)">
<rect width="1660" height="222" rx="16" fill="#fff8ec" stroke="#efd39a" stroke-width="2"/>
<text x="24" y="34" class="lane">B. 从可用库存出发的四类作业</text>
<rect x="24" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">排车 / 一件代发拣货</text><text x="44" y="116" class="boxText">AVAILABLE → 冻结 → PICKED</text><text x="44" y="144" class="boxText">发车/出库 → 扣减</text><text x="44" y="170" class="small">整托扫描也按入仓单组成核平</text>
<rect x="430" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#7657a8" stroke-width="2"/>
<text x="450" y="88" class="boxTitle">换标</text><text x="450" y="116" class="boxText">冻结 hold_packages</text><text x="450" y="144" class="boxText">旧 SKU 转出 → 新 SKU 转入</text><text x="450" y="170" class="small">箱唛/托盘绑定变化,箱数守恒</text>
<rect x="836" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
<text x="856" y="88" class="boxTitle">仓储转运</text><text x="856" y="116" class="boxText">来源仓冻结/扣减</text><text x="856" y="144" class="boxText">目标仓收货 → 增加</text><text x="856" y="170" class="small">可联动换标;来源/目标核对</text>
<rect x="1242" y="60" width="384" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
<text x="1262" y="88" class="boxTitle">废弃</text><text x="1262" y="116" class="boxText">申请仅锁箱:DISCARD_PENDING</text><text x="1262" y="144" class="boxText">PDA 实际确认 → 扣减 → DISCARDED</text><text x="1262" y="170" class="small">驳回释放;平台结案不再扣一次</text>
</g>
<g transform="translate(70,706)">
<rect width="1660" height="222" rx="16" fill="#eef3fa" stroke="#b7cae2" stroke-width="2"/>
<text x="24" y="34" class="lane">C. 出库后的结果与恢复</text>
<rect x="24" y="60" width="350" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">OUTBOUNDED / 派送中</text><text x="44" y="116" class="boxText">库存已扣减,保留出库事实</text><text x="44" y="144" class="boxText">签收:不再二次扣库存</text><text x="44" y="170" class="small">派送费用与库存动作分开核对</text>
<path d="M374 123 H446" class="line"/>
<rect x="446" y="60" width="380" height="126" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
<text x="466" y="88" class="boxTitle">异常退回入仓</text><text x="466" y="116" class="boxText">只能选原出库拣货事实</text><text x="466" y="144" class="boxText">RETURN_STOCK_IN → 目标托盘/库位</text><text x="466" y="170" class="small">库存恢复 + 异常完成 + 派送 RETURNED</text>
<rect x="900" y="60" width="330" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
<text x="920" y="88" class="boxTitle">盘点调整</text><text x="920" y="116" class="boxText">实盘数量 − 账面数量 = 差异</text><text x="920" y="144" class="boxText">按正/负生成调整流水</text><text x="920" y="170" class="small">无差异不造流水;需 adjust 权限</text>
<rect x="1270" y="60" width="356" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
<text x="1290" y="88" class="boxTitle">移库 / 拆合托 / 换绑</text><text x="1290" y="116" class="boxText">同仓位置或托盘组成变化</text><text x="1290" y="144" class="boxText">数量净额 = 0</text><text x="1290" y="170" class="small">记录位置流水;资源锁防并发</text>
</g>
<path d="M1142 416 V450" class="gline"/><path d="M120 638 V706" class="rline"/><path d="M610 638 V706" class="gline"/>
<path d="M1020 638 V706" class="line"/><path d="M1435 638 V706" class="rline"/>
<g transform="translate(70,965)">
<rect width="1660" height="250" rx="16" fill="#ffffff" stroke="#d5e0e8" stroke-width="2"/>
<text x="24" y="36" class="lane">D. 测试统一断言(每个库存场景都要检查)</text>
<text x="42" y="78" class="assert">① 数量守恒:客户 / 海外仓 / 目的仓 / 入仓单 / 托盘组成逐层核平</text>
<text x="42" y="114" class="assert">② 三账一致:库存余额 = 位置明细汇总 = 库存流水结果</text>
<text x="42" y="150" class="assert">③ 幂等并发:重复事件、重复请求、并发锁定不得产生第二笔变动</text>
<text x="42" y="186" class="assert">④ 原子回滚:权限、门禁、资源锁、跨服务失败时,库存/单据/流水一起回滚</text>
<text x="42" y="222" class="assert">⑤ 隔离校验:客户、仓、来源入仓单、原出库事实不可伪造或串用</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,40 @@
# 海外业务库存变动测试整理
范围依据:`lastleg-service/AGENTS.md`、`docs/backend-business-index.md` 及当前海外仓库存、入仓、出库、换标、转运、废弃、异常退回和 PDA 作业入口。
## 先统一库存口径
- **可用库存**:按客户、海外仓、目的仓、入仓单汇总箱数;SKU、箱唛和托盘是明细快照/作业维度。
- **冻结库存**:拣货、换标、废弃、转运等处理中暂时不可重复使用;冻结不等于已经扣减。
- **库存流水**:每一次入账、冻结、释放、扣减、恢复、SKU 转换和位置变化都应能按业务单号、幂等键追溯。
- **托盘不是库存归属**:合托后一个物理托盘可以包含多个入仓单,出库、换标和退回必须按托盘组成明细核平。
## 测试场景矩阵
| 场景 | 触发与前置 | 库存变动 | 测试重点 |
|---|---|---|---|
| 头程接柜/海外入仓 | 装柜完成事件 → 生成尾程单/入仓计划;PDA 签收、实收、上架、完成 | 在途冻结 → 入仓后 `AVAILABLE` 增加;写库存位置和流水 | 柜号/客户/入仓单隔离;卸柜照片门禁;重复事件不重复入账;失败整事务回滚 |
| 普通排车出库/派送 | 计划已入仓;排车审核、拣货、箱数核平、发车 | `AVAILABLE` → 冻结/已拣;原子出库后可用库存扣减 | 整托/部分托按组成明细核平;未审核、数量不平、车辆门禁时不得出库;同键重放幂等 |
| 一件代发 | 已完成入库且有可用箱唛/SKU;创建 `WAIT_PICK` 出库单 | SKU/箱唛库存冻结 → PDA 拣货 → `OUTBOUNDED` 扣减 | 候选只能来自真实可用库存;一单不得跨客户/跨仓;整托扫描任一明细失败必须整体回滚 |
| 客户换标 | 历史入仓库存可用;客户申请、PDA 扫旧 SKU/箱唛、完工、客户确认 | 旧 SKU/箱唛冻结;换标完成后旧库存转出、新 SKU/箱唛转入;库存总箱数守恒 | 同箱多 SKU 不重复冻结;只移动本单 `hold_packages`;已生成出库单禁止再次拆托;照片/复核/客户确认门禁 |
| 仓储转运 | 来源库存可用;创建转运、测量、换标(可选)、出库、目标仓收货 | 来源仓冻结/扣减 → 目标仓入账;换标时 SKU 转换 | 来源客户、入仓单和箱数核对;部分转运数量守恒;目标仓与来源仓不能串;重复完成不重复入账 |
| 货物废弃 | 一件代发/库存货物申请废弃;审批后由绑定海外 PDA 实际确认 | `AVAILABLE` → `DISCARD_PENDING`;实际确认后 SKU、余额、位置扣减并记 `DISCARDED` | 申请阶段只锁箱不扣库存;驳回释放;批准不是实际扣减;实际数量与逐箱流水一致;失败整批回滚 |
| 异常退回入仓 | 只能选择原出库拣货事实;异常单受理并完成退回 | 原出库来源核验 → `RETURN_STOCK_IN` 恢复到真实目标托盘/库位 | 不能伪造出库来源、借用其他客户主单;退回数量与原实发核平;异常单完成与派送 `RETURNED` 同事务 |
| PDA 盘点调整 | 绑定海外仓;盘点锁定库存位置并提交实盘数量 | 按差异产生增加/减少调整流水;无差异不产生虚假变动 | 需要 `inventory:adjust`;并发版本校验;差异正负方向正确;盘点重复提交幂等 |
| PDA 移库/托盘拆合/换绑 | 同仓有效托盘/库位;资源锁成功 | 数量净额不变,仅位置/托盘组成变化,写位置流水 | 不跨仓;托盘组成明细保留;资源锁释放;出库/换标占用时禁止绕过协调单独移动 |
## 不应误判为库存变动
- 目的仓修改、暂扣/解除、报价、排车预排、外接预约、车辆/司机资料:只改变状态、资格、资源占用或费用快照。
- 海外采购样品履约:按当前契约走 Admin 台账,**不接入 PDA,也不写 WMS 可用库存**。
- 退货申请/废弃申请/转派送建单:申请或建单阶段只形成冻结/候选/出库单,必须到实际收货、实际扣减或实际出库节点才确认数量变化。
## 每条库存用例的统一断言
1. 变动前后按客户、海外仓、目的仓、入仓单、托盘组成核对数量守恒。
2. 查询余额、位置明细和库存流水三处结果一致,且业务单号/幂等键可追溯。
3. 重复请求、重复事件、并发操作不得新增第二笔库存变动。
4. 任一校验、权限、资源锁或跨服务依赖失败时,库存、单据状态和流水一起回滚。
5. 客户越权、跨仓、跨客户、伪造来源入仓单/出库单均应拒绝。
流程图:`overseas-inventory-mutation-flow.svg` / `overseas-inventory-mutation-flow.png`
@@ -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();
}
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.service.ContainerCalendarSourceService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/platform/v1/terminal-drayages")
public class ContainerCalendarSourceController {
private final ContainerCalendarSourceService service;
/** 装配只读日历业务服务。 */
public ContainerCalendarSourceController(ContainerCalendarSourceService service) { this.service = service; }
/** 预约列表可读基础柜信息,还柜事实继续核验原业务读取权限。 */
@GetMapping("/calendar-sources")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public ApiResponse<ContainerCalendarSourceService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.calendar(from, to, user.permissions()));
}
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.terminaldrayage.dto;
import java.time.LocalDateTime;
/** 四类日历使用的柜级来源,日期均保留原业务当地值。 */
public record ContainerCalendarSource(Long containerBookingId, String cabinetNo, String containerNo,
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
LocalDateTime arrivalAt, String arrivalType) { }
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface ContainerCalendarSourceMapper {
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
@Select("""
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
FROM fl_container_booking c
LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
WHERE c.record_status='ACTIVE'
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id
AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
AND (
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) IS NULL
OR (COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{arrivalFrom}
AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to})
OR (t.actual_empty_return_at>=#{from} AND t.actual_empty_return_at<#{to})
OR (c.unloading_completed_at>=#{returnFrom} AND c.unloading_completed_at<#{to})
OR EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact r WHERE r.container_booking_id=c.id
AND r.standard_node='EMPTY_RETURNED' AND r.actual_at>=#{from} AND r.actual_at<#{to})
OR EXISTS(SELECT 1 FROM fl_direct_delivery_unloading u WHERE u.container_booking_id=c.id
AND u.actual_at>=#{returnFrom} AND u.actual_at<#{to})
)
ORDER BY c.id LIMIT 2001
""")
List<ContainerCalendarSource> sources(@Param("from") LocalDateTime from,
@Param("arrivalFrom") LocalDateTime arrivalFrom, @Param("returnFrom") LocalDateTime returnFrom,
@Param("to") LocalDateTime to);
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.terminaldrayage.repository;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.mapper.ContainerCalendarSourceMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
@Repository
public class ContainerCalendarSourceRepository {
private final ContainerCalendarSourceMapper mapper;
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
from.minusDays(4).atStartOfDay(), to.atStartOfDay());
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.springframework.web.server.ResponseStatusException;
@Service
public class ContainerCalendarSourceService {
public record Result(List<ContainerCalendarSource> containers, List<View> emptyReturns,
boolean emptyReturnsReadable) { }
private final ContainerCalendarSourceRepository repository;
private final TerminalDrayageService drayages;
/** 复用真实还柜业务的截止时间和完成事实,不在日历另算还柜状态。 */
public ContainerCalendarSourceService(ContainerCalendarSourceRepository repository, TerminalDrayageService drayages) {
this.repository = repository; this.drayages = drayages;
}
/** 按日期范围读取到港、默认到仓来源及可见的还柜记录。 */
public Result calendar(LocalDate from, LocalDate to, Set<String> permissions) {
if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为1至42天");
var sources = repository.sources(from, to);
if (sources.size() > 2000)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历来源超过2000柜,请缩小范围或补齐未定日期");
boolean readable = permissions != null && permissions.contains("platform:firstleg:terminal-drayage:list");
var returns = new ArrayList<View>();
if (readable) {
var ids = sources.stream().map(ContainerCalendarSource::containerBookingId).toList();
for (int start = 0; start < ids.size(); start += 200) {
for (var row : drayages.authorize(drayages.views(ids.subList(start, Math.min(start + 200, ids.size()))), permissions)) {
var at = row.actualEmptyReturnAt() != null ? row.actualEmptyReturnAt() : row.emptyReturnWarning().deadlineAt();
if (at != null && !at.toLocalDate().isBefore(from) && at.toLocalDate().isBefore(to)) returns.add(row);
}
}
}
// 未授权还柜读取时,也不能通过仅因还柜日期命中的旧柜候选推断受保护事实。
var visibleSources = readable ? sources : sources.stream().filter(row -> row.arrivalAt() == null
|| !row.arrivalAt().toLocalDate().isBefore(from.minusDays(2)) && row.arrivalAt().toLocalDate().isBefore(to)).toList();
return new Result(List.copyOf(visibleSources), List.copyOf(returns), readable);
}
}
@@ -0,0 +1,55 @@
openapi: 3.0.3
info: {title: 四类日历柜级来源, version: 1.0.0}
security: [{bearerAuth: []}]
paths:
/api/platform/v1/terminal-drayages/calendar-sources:
get:
summary: 查询日历到港、默认到仓来源和还柜事实
description: >-
仅平台,platform:appointment:list。返回已确认国内出仓、未撤销且有效的柜次。
到港取最新已观测实际到港节点、柜次实际到港、预计到港,按此顺序选一个日期。
到港候选前移两天覆盖计划到仓默认日期;缺到港日期的柜次同时返回,不能伪造日期。
还柜候选含原截止日期与真实完成日期,最终由原码头拖车业务复核,完成后只按实际还柜日期归日历。
没有platform:firstleg:terminal-drayage:list时emptyReturns为空且emptyReturnsReadable=false;不把缺权限当没有安排。
最多2000个候选,超出返回400;来源读取失败返回错误,不以空列表代替。
x-permissions: [platform:appointment:list]
parameters:
- {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期}
- {name: to, in: query, required: true, schema: {type: string, format: date}, description: 不包含的原业务当地日期,范围最多42天}
responses:
'200':
description: 成功业务响应,code为字符串200
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
required: [containers, emptyReturns, emptyReturnsReadable]
properties:
emptyReturnsReadable: {type: boolean}
containers:
type: array
items:
type: object
properties:
containerBookingId: {type: string}
cabinetNo: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
arrivalAt: {type: string, format: date-time, nullable: true}
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
emptyReturns:
type: array
description: 沿terminal-drayage-v1的View结构,allowedActions按当前权限过滤;含actualEmptyReturnAt及emptyReturnWarning.deadlineAt
items: {$ref: './terminal-drayage-v1.yaml#/components/schemas/TerminalDrayageView'}
'400': {description: 范围无效或候选过多}
'403': {description: 非平台或没有预约读取权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class ContainerCalendarSourceMapperTest {
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
@Test
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " +
"(1,'D1','2026-10-30 12:00:00','ACTIVE'),(2,'D2','2026-11-10 12:00:00','ACTIVE')," +
"(3,'D3',NULL,'ACTIVE'),(4,'D4','2026-01-01 12:00:00','ACTIVE')," +
"(5,'D5','2026-11-10 12:00:00','CANCELLED'),(6,'D6','2026-11-10 12:00:00','ACTIVE')");
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-01-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking WHERE id<>6");
sql.execute("INSERT INTO fl_terminal_drayage VALUES(4,'2026-11-05 12:00:00')");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,2,'ARRIVED_DESTINATION_PORT','2026-10-25 12:00:00','2026-10-26 12:00:00')");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); config.addMapper(ContainerCalendarSourceMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var from = LocalDateTime.parse("2026-11-01T00:00:00");
var rows = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusDays(30));
assertEquals(java.util.List.of(1L, 3L, 4L), rows.stream().map(row -> row.containerBookingId()).toList());
assertEquals("2026-10-30T12:00", rows.get(0).arrivalAt().toString());
assertNull(rows.get(1).arrivalAt());
}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.*;
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ContainerCalendarSourceServiceTest {
private final ContainerCalendarSourceRepository repository = mock(ContainerCalendarSourceRepository.class);
private final TerminalDrayageService drayages = mock(TerminalDrayageService.class);
private final ContainerCalendarSourceService service = new ContainerCalendarSourceService(repository, drayages);
private final LocalDate from = LocalDate.of(2026, 11, 1), to = from.plusDays(30);
/** 缺还柜读取权限时保留范围说明,不读取受保护事实。 */
@Test void keepsMissingReturnPermissionExplicitWithoutReadingProtectedFacts() {
when(repository.sources(from, to)).thenReturn(List.of(
new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED"),
new ContainerCalendarSource(2L,"D2",null,null,null,null,null,from.minusMonths(2).atStartOfDay(),"ACTUAL")));
var result = service.calendar(from, to, Set.of("platform:appointment:list"));
assertEquals(1, result.containers().size()); assertFalse(result.emptyReturnsReadable());
verifyNoInteractions(drayages);
}
/** 实际还柜日期优先于截止日期,范围外完成事实不回落到原提醒日。 */
@Test void usesOriginalDeadlineButActualReturnSupersedesTheDeadlineDate() {
when(repository.sources(from, to)).thenReturn(List.of(new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED")));
var row = mock(View.class);
when(row.actualEmptyReturnAt()).thenReturn(to.atStartOfDay());
when(row.emptyReturnWarning()).thenReturn(new Warning("RESOLVED", from.atStartOfDay(),0,0));
when(drayages.views(List.of(1L))).thenReturn(List.of(row));
when(drayages.authorize(anyList(), anySet())).thenAnswer(call -> call.getArgument(0));
assertTrue(service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns().isEmpty());
when(row.actualEmptyReturnAt()).thenReturn(null);
assertEquals(List.of(row), service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns());
}
/** 非法日期范围在访问持久化层前拒绝。 */
@Test void rejectsInvalidRangesBeforeDatabaseAccess() {
assertThrows(ResponseStatusException.class, () -> service.calendar(from,from,Set.of()));
assertThrows(ResponseStatusException.class, () -> service.calendar(from,from.plusDays(43),Set.of()));
verifyNoInteractions(repository);
}
}
@@ -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`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
@@ -0,0 +1,15 @@
# 仓储转运上门取货与车辆排车
客户选择仓库上门取货并提交,平台审核通过后,原转运单进入车辆排车的“待取货”。一个取货车次可取多名客户的多票货,目的海外仓必须相同。客户自送、未审核、库存转运和测量已完成的订单不进入候选。
业务入口为 `fleet/pickup/PlatformFleetPickupController`,按 Controller → Service → Repository → Mapper 持久化,复用原车次编号、车辆司机分配和资源占用。`tripPurpose=PICKUP` 区别于原 `DELIVERY`,取货车次不关联出库排车、不发布拼车余位、不执行原出库装载和扣库存。
车次按 `PLANNED → IN_TRANSIT → COMPLETED` 执行。逐票取货任务为 `ASSIGNED → PICKED → ARRIVED`:发车复验车辆司机资格和实际占用;取货必须填写实提箱数并上传本票凭证;到仓后才开放原 PDA 清点测量。实提箱数独立保存,不覆盖客户预报箱数、SKU件数和箱唛。全车送仓可结束运输,车辆与司机任务分别沿原入口释放,不需要等仓内测量。
计划阶段取消车次会保留操作历史、释放资源和原单关联,使原单重新可安排;发车后禁止取消。原车次分配入口支持发车前改派。所有取货命令在车次行锁、版本校验和持久幂等摘要下执行;来源安排同时锁原单,避免一单多车。前端遇到结果未知保留原请求和请求键,阻止刷新卸载或改派后重新生成命令。
平台待取货使用服务端分页;客户详情、平台尾程详情按原转运单查询同一运输进度。凭证只允许当前单已确认引用,本车其他客户文件不能借用或下载。文件数据归 File 服务,Lastleg仅持有文件归属和引用。
公开接口与请求字段见 `src/main/resources/openapi/fleet-transfer-pickup-v1.yaml`。数据库增量为 `../../scripts/db/fleet-transfer-pickup-20261017.sql`,目标 `baiyue_lastleg`,可重复执行,无业务 DML。
本机验收入口 `../../scripts/local-transfer-pickup-acceptance.mjs` 创建带 TEST 标记的新业务记录并保存报告。覆盖待取货、未审核、自送、异仓拒绝、共车、取消改派、资源冲突、同键重放与异载荷拒绝、跨客户文件隔离、未送仓禁止PDA和送仓后完整测量。合成图片仅验证上传与文件归属,不证明物理取货。本机构建、单测和HTTP验收与远端部署、真实页面视觉验收分别记录。
+25
View File
@@ -25,4 +25,29 @@
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
<profiles>
<profile>
<id>ci-bounded</id>
<build><plugins><plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<executions>
<execution>
<id>default-test</id>
<configuration><reuseForks>false</reuseForks><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
</execution>
<!-- H2复杂查询规划与普通单测分进程,仍沿用父级1GB上限并执行全部断言。 -->
<execution>
<id>sql-semantic-tests</id>
<phase>test</phase>
<goals><goal>test</goal></goals>
<configuration>
<includes><include>**/*SqlSemanticTest.java</include></includes>
<reuseForks>false</reuseForks>
</configuration>
</execution>
</executions>
</plugin></plugins></build>
</profile>
</profiles>
</project>
@@ -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,
@@ -35,6 +35,11 @@ public class AppointmentCargoService {
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
}
/** 查询下拉框与货物分页共用权威目录范围,不从当前页猜测目的仓。 */
public List<String> destinations() {
return scope().destinations().stream().distinct().sorted().toList();
}
private record Scope(List<String> origins, List<String> destinations) { }
private Scope scope() {
final List<AppointmentWarehouseClient.Warehouse> catalog;
@@ -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);
}
@@ -53,6 +53,14 @@ public class AppointmentPlanningController {
return ApiResponse.success(new Resolved(source,result.get(0)));
}
/** 目的仓选项沿用货物查询权限,只返回同范围的美国FBA仓码。 */
@GetMapping("/cargo/destinations")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<java.util.List<String>> destinations(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
return ApiResponse.success(service.destinations());
}
@GetMapping("/cargo")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
@@ -58,18 +58,36 @@ public class AppointmentSourceService {
return response.data().warehouse().timezone();
} catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目的仓时区读取失败,请稍后核对",error); }
}
/** One catalog request and batched source reads, including mixed first-leg and external bookings. */
/** 普通批量读取创建独立目录作用域,同时读取头程和外接登记来源。 */
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
return resolveBatch(requested,true).stream().map(Resolution::requireSource).toList();
return openReadScope().resolveBatch(requested);
}
/** 创建显式单次读取作用域;仅成功的目录结果可在本次生成中复用。 */
public ReadScope openReadScope() { return new ReadScope(); }
public final class ReadScope {
private List<AppointmentWarehouseClient.Warehouse> directory;
/** 只能由所属来源服务创建,不允许传入未经校验的目录。 */
private ReadScope() { }
/** 每次仍读取原订单和权限事实,仅目录共享;不保存来源解析结果。 */
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList();
}
/** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */
private List<AppointmentWarehouseClient.Warehouse> catalog() {
if(directory==null) directory=AppointmentSourceService.this.catalog();
return directory;
}
}
/** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */
public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) {
public Source requireSource() { if(error!=null) throw error; return source; }
}
/** 每次检查独立读取目录,保留逐来源前置条件失败与基础设施异常边界。 */
public List<Resolution> inspectBatch(List<AppointmentCoverageService.SourceRef> requested) {
return resolveBatch(requested,false);
return resolveBatch(requested,false,openReadScope());
}
private List<Resolution> resolveBatch(List<AppointmentCoverageService.SourceRef> requested,boolean strict) {
/** 保留原批量校验与错误边界,目录只从调用方的局部作用域读取。 */
private List<Resolution> resolveBatch(List<AppointmentCoverageService.SourceRef> requested,boolean strict,ReadScope scope) {
if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源");
var keys=new LinkedHashSet<String>(); var orderIds=new LinkedHashSet<Long>();
var bookingRefs=new java.util.ArrayList<AppointmentExternalSourceGateway.SourceRef>();
@@ -96,7 +114,7 @@ public class AppointmentSourceService {
var destinations=transferRefs.isEmpty()?Map.<Long,AppointmentCoverageMapper.Source>of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity()));
var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList();
var gates=containerIds.isEmpty()?Map.<String,Boolean>of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40));
var catalog=catalog();
var catalog=scope.catalog();
var result=requested.stream().map(ref->{
try {
if(ref.sourceKey().startsWith("ORDER:") && externalOrders.contains(new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))))
@@ -4,13 +4,15 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** Original business commands invalidate the manifest atomically with their business write. */
/** 原业务写入与工作清单失效保持在同一事务,不延后或绕过版本门禁。 */
@Component
public class AppointmentWorkChanges {
private final AppointmentWorkMapper mapper;
public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; }
private final AppointmentWorkStore store;
/** 复用工作事项持久层,统一记录守卫更新耗时。 */
public AppointmentWorkChanges(AppointmentWorkStore store) { this.store=store; }
/** 只标记需求变化,保留调用方原事务和异常语义。 */
@Transactional(propagation=Propagation.MANDATORY)
public void requirementsChanged() {
if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化");
store.invalidateRequirements();
}
}
@@ -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);
@@ -4,12 +4,14 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(AppointmentWorkFactAdapter.class);
private final AppointmentWorkFirstlegClient firstleg;
private final List<BusinessProvider> providers;
private final ObjectProvider<OwnerDirectory> directory;
@@ -38,7 +40,10 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
return response.data();
} catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); }
}
/** 原顺序读取全部事实;慢调用和失败仅附加无业务内容的局部统计。 */
public Map<String,Fact> facts(List<AppointmentWorkState> states) {
var timing=new FactTiming();boolean success=false;
try {
var result=new LinkedHashMap<String,Fact>();
var remote=new ArrayList<FactRequest>();
var groups=new LinkedHashMap<BusinessProvider,List<FactRequest>>();
@@ -53,27 +58,86 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
}
}
for(int start=0; start<remote.size(); start+=200) {
var requests=remote.subList(start,Math.min(start+200,remote.size())); requireToken();
try { var response=firstleg.facts(token,requests);
var requests=remote.subList(start,Math.min(start+200,remote.size())); timing.stage="FIRSTLEG"; requireToken();
try { var response=timing.measure(timing.firstleg,requests.size(),()->firstleg.facts(token,requests));
if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败");
append(result,requests,response.data());
run(Stage.FACTS_VALIDATE,()->append(result,requests,response.data()));
} catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); }
}
groups.forEach((provider,requests)->{
for(int start=0; start<requests.size(); start+=200) {
var batch=requests.subList(start,Math.min(start+200,requests.size()));
try { append(result,batch,provider.read(batch)); }
var statistics=timing.provider(provider);
try { var response=timing.measure(statistics,batch.size(),()->provider.read(batch));run(Stage.FACTS_VALIDATE,()->append(result,batch,response)); }
catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); }
}
});
return result;
success=true;return result;
} finally { timing.finish(success); }
}
public List<Requirement> requirements(ContainerSource source,Flow effectiveFlow) {
var provider=requirements.getIfAvailable();
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
var result=provider.requirements(source,effectiveFlow);
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
return List.copyOf(result);
/** 一次 facts 调用的计时,不保存原来源或返回值,不跨调用缓存。 */
private static final class FactTiming {
private final Span prepare=span(Stage.FACTS_PREPARE);
private final long started=System.nanoTime();
private final CallStatistics firstleg=new CallStatistics();
private final Map<String,CallStatistics> providers=new LinkedHashMap<>();
private String stage="PREPARE";
/** 固定诊断桶不使用代理名、业务参数或异常文本。 */
private CallStatistics provider(BusinessProvider provider) {
String name=org.springframework.util.ClassUtils.getUserClass(provider).getSimpleName();
name=switch(name) {
case "AppointmentWorkReceivingProvider","AppointmentWorkDeliveryProvider","AppointmentWorkTransportProvider","AppointmentWorkDirectTransportProvider" -> name;
default -> "OTHER";
};
stage=name;
return providers.computeIfAbsent(name,ignored->new CallStatistics());
}
/** 只统计真正尝试的提供端调用;校验及组装耗时保留在 totalMs 中。 */
private <T> T measure(CallStatistics statistics,int requests,java.util.function.Supplier<T> operation) {
prepare.close();statistics.calls++;statistics.requests+=requests;long begin=System.nanoTime();
try(var span=span(switch(stage){case "FIRSTLEG"->Stage.FACTS_FIRSTLEG;case "AppointmentWorkReceivingProvider"->Stage.FACTS_RECEIVING;
case "AppointmentWorkDeliveryProvider"->Stage.FACTS_DELIVERY;case "AppointmentWorkTransportProvider"->Stage.FACTS_TRANSPORT;
case "AppointmentWorkDirectTransportProvider"->Stage.FACTS_DIRECT_TRANSPORT;default->Stage.FACTS_OTHER;})){
try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;}
}finally{statistics.elapsed+=System.nanoTime()-begin;}
}
/** 成功慢调用或任意失败只输出一次汇总,日志故障不得遮蔽原异常。 */
private void finish(boolean success) {
if(!success)prepare.failed();prepare.close();
long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started);
if(success && totalMs<250) return;
try {
var summaries=new LinkedHashMap<String,String>();
providers.forEach((name,value)->summaries.put(name,value.summary()));
LOG.warn("Appointment work facts diagnostic: totalMs={}, firstlegCalls={}, firstlegRequests={}, firstlegMs={}, providers={}, failedStage={}, requestId={}, traceId={}, scopeId={}",
totalMs,firstleg.calls,firstleg.requests,firstleg.millis(),summaries,success?"NONE":stage,org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId());
} catch(Throwable diagnosticFailure) { /* 仅忽略诊断故障,原事实错误仍沿原路径抛出。 */ }
}
}
/** 仅包含数量和单调时钟耗时的累加器。 */
private static final class CallStatistics {
private int calls,requests;private long elapsed;
/** 输出整毫秒,未计入网络之后的事实完整性校验。 */
private long millis() { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed); }
/** 固定字段格式便于日志汇总,不包含任何请求或响应内容。 */
private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); }
}
/** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */
public RequirementReader openRequirements() {
return new RequirementReader() {
private RequirementReader reader;
/** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */
public List<Requirement> read(ContainerSource source,Flow effectiveFlow) {
if(reader==null) {
var provider=requirements.getIfAvailable();
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
reader=provider.openRequirements();
}
var result=reader.read(source,effectiveFlow);
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
return List.copyOf(result);
}
};
}
static void append(Map<String,Fact> target,List<FactRequest> requests,List<Fact> facts) {
if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整");
@@ -27,8 +27,8 @@ public interface AppointmentWorkFactGateway {
}
SourcePage discover(String after, int size);
ContainerSource container(String containerBookingId);
/** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */
List<Requirement> requirements(ContainerSource source,Flow effectiveFlow);
/** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */
RequirementReader openRequirements();
Map<String, Fact> facts(List<AppointmentWorkState> states);
Owner owner(String id, Actor actor);
Responsibility supplier(String id);
@@ -41,5 +41,7 @@ public interface AppointmentWorkFactGateway {
/** IAM-backed active platform operators; display names supplied by callers are never trusted. */
interface OwnerDirectory { Owner activePlatformOwner(String id); }
interface SupplierDirectory { Responsibility activeSupplier(String id); }
interface RequirementProvider { List<Requirement> requirements(ContainerSource source,Flow effectiveFlow); }
/** 单次生成显式复用目录,不跨请求保存来源事实。 */
interface RequirementReader { List<Requirement> read(ContainerSource source,Flow effectiveFlow); }
interface RequirementProvider { RequirementReader openRequirements(); }
}
@@ -9,6 +9,7 @@ import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@@ -26,10 +27,16 @@ public class AppointmentWorkQueryService {
Snapshot snapshot(Actor actor) {
return snapshot(actor,s->true,true);
}
/** 按原顺序构造完整快照,计时只分离读取、筛选、事实及投影。 */
private Snapshot snapshot(Actor actor,Predicate<AppointmentWorkState> selected,boolean includeNext) {
observeTransaction();var timing=span(Stage.QUERY_GUARD);
try {
String scopeVersion=store.requireInitialized();
timing.close();timing=span(Stage.QUERY_DISCOVER);
if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
timing.close();timing=span(Stage.QUERY_STORE);
var all=store.all();
timing.close();timing=span(Stage.QUERY_SELECT);
var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
var selectedKeys=all.stream().filter(s->!s.isRetired() && selected.test(s)).map(AppointmentWorkState::getWorkItemKey).collect(Collectors.toSet());
var needed=new HashSet<>(selectedKeys);
@@ -41,12 +48,16 @@ public class AppointmentWorkQueryService {
}
}
var states=all.stream().filter(s->!s.isRetired() && needed.contains(s.getWorkItemKey())).toList();
timing.close();timing=span(Stage.QUERY_FACTS);
var current=facts.facts(states);
timing.close();timing=span(Stage.QUERY_PROJECT);
var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList();
var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList();
var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity()));
timing.close();timing=span(Stage.QUERY_NEXT);
return new Snapshot(all,current,items.stream().filter(i->selectedKeys.contains(i.workItemKey()))
.map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList());
} catch(RuntimeException|Error error){timing.failed();throw error;} finally{timing.close();}
}
private Action next(WorkItem item,Map<String,WorkItem> items,Map<String,AppointmentWorkState> states,Set<String> visited) {
if(!visited.add(item.workItemKey()) || item.reviewRequired()) return item.nextAction();
@@ -73,23 +84,34 @@ public class AppointmentWorkQueryService {
}
return item.nextAction();
}
/** 完整快照之后单独统计汇总耗时,不改变待办筛选语义。 */
public Summary summary(Actor actor) {
var items=snapshot(actor,s->true,false).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList();
var snapshotItems=snapshot(actor,s->true,false).items();
return measure(Stage.QUERY_FINAL,()->{
var items=snapshotItems.stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList();
return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count());
});
}
/** 日历最终分组保持原顺序,单独记录快照后处理。 */
public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set<Type> types,Actor actor) {
if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天");
if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看");
var items=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to))
var snapshotItems=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items();
return measure(Stage.QUERY_FINAL,()->{
var items=snapshotItems.stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to))
.filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList();
var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList()));
var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList();
return new CalendarResult(from,to,items.size(),counts(items),days);
});
}
/** 保留事实核对之后的最终过滤排序分页,仅拆出其耗时。 */
public PageResult<WorkItem> items(View view,int page,int size,LocalDate date,Flow flow,Set<Type> types,String containerId,Bucket bucket,String keyword,Actor actor) {
if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效");
var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT);
var items=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items().stream()
var snapshotItems=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items();
return measure(Stage.QUERY_FINAL,()->{
var items=snapshotItems.stream()
.filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED)
.filter(i->date==null || date.equals(i.calendarDate()))
.filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type()))
@@ -100,6 +122,7 @@ public class AppointmentWorkQueryService {
.sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList();
long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size());
return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size);
});
}
public BatchWorkResolution resolveBatch(String batchKey,String customerId,Actor actor) {
if(batchKey==null || !batchKey.matches("(?:FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*")) throw bad("请提供原业务返回的完整批次键");
@@ -117,10 +140,12 @@ public class AppointmentWorkQueryService {
var selected=candidates.get(0);
return new BatchWorkResolution(batchKey,selected.customerId(),detail(selected.workItemKey(),actor),candidates);
}
/** 保留详情完整快照与退休事实读取,历史查询单独观察。 */
public WorkDetail detail(String key,Actor actor) {
var snapshot=snapshot(actor); var state=store.get(key);
var snapshot=snapshot(actor); var state=measure(Stage.DETAIL_STORE,()->store.get(key));
var fact=snapshot.facts().get(key);
if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key);
if(state.isRetired() && state.getType()!=Type.ROUTE) fact=measure(Stage.DETAIL_FACTS,()->facts.facts(List.of(state))).get(key);
try(var timing=span(Stage.DETAIL_PROJECT)) {
if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源");
var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在"));
var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys());
@@ -128,9 +153,10 @@ public class AppointmentWorkQueryService {
relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey));
snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add);
var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references());
return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key),
return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),measure(Stage.DETAIL_HISTORY,()->store.history(key)),
state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null,
requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff());
}
}
WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); }
private boolean matches(AppointmentWorkState state,Flow flow,Set<Type> types,String containerId) {
@@ -1,23 +1,59 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkStore {
private static final Logger LOG = LoggerFactory.getLogger(AppointmentWorkStore.class);
public static final int MAX_ITEMS=10000;
private final AppointmentWorkMapper mapper;
private final ObjectMapper json;
public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; }
public AppointmentWorkMapper.Guard lock() {
var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard;
/** 只测量守卫更新语句,不改变锁、事务或版本,用于区分慢写是否卡在该语句。 */
public void invalidateRequirements() {
long started = System.nanoTime();
try {
if (measure(Stage.GUARD_UPDATE,mapper::invalidateRequirements) != 1) throw unavailable("工作事项同步守卫未初始化");
} finally {
long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
if (elapsedMs >= 250) {
// 语句耗时可能包含等锁或连接等待,不能仅凭日志认定具体锁根因;不记录请求业务载荷。
try { LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}",
elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId());
} catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ }
}
}
}
/** 测量守卫读取加锁耗时,保留原有锁和失败行为,覆盖尚未进入更新语句的慢命令。 */
public AppointmentWorkMapper.Guard lock() {
long started = System.nanoTime();
try {
observeTransaction();
var guard = measure(Stage.GUARD_LOCK,mapper::lockGuard);
if (guard == null) throw unavailable("工作事项DDL尚未集成");
return guard;
} finally {
long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
if (elapsedMs >= 250) {
// 耗时包含连接及语句执行,单条日志不能区分具体等待原因,不记录业务载荷。
try { LOG.warn("Appointment work guard lock slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}",
elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId());
} catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ }
}
}
}
/** 完整性守卫保持原语义,读取耗时与事务完成只作诊断。 */
public String requireInitialized() {
var guard=mapper.guard();
observeTransaction();var guard=measure(Stage.GUARD_READ,mapper::guard);
if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项");
if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单");
return guard.getScopeVersion();
@@ -2,45 +2,58 @@ package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.*;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
public class AppointmentWorkSyncService {
private static final Logger LOG=LoggerFactory.getLogger(AppointmentWorkSyncService.class);
private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ));
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway gateway;
public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; }
/** Called by trusted server orchestration, never by GET or by opening a particular container. */
/** 服务端按完整范围同步;同一守卫事务内复用工作集,GET 不触发写入。 */
@Transactional
public SyncResult synchronize(SyncRequest request) {
if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜");
observeTransaction();var timing=new SyncTiming();boolean success=false;
try {
var guard=store.lock();
if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对");
timing.next(SyncPhase.DISCOVER);
var page=gateway.discover(request.afterContainerBookingId(),request.size());
timing.pageContainers=page.containers().size();timing.complete=page.complete();
if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); }
else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试");
else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试");
timing.next(SyncPhase.GENERATE);
// 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。
var known=workingSet();
var requirementReader=gateway.openRequirements();
int created=0; var ids=new HashSet<String>();
for(var source:page.containers()) {
validateSource(source);
if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次");
var existing=store.container(source.containerBookingId());
var existing=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId())
|| s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId()))).toList();
if(existing.isEmpty()) {
if(source.establishedFlow()==Flow.UNKNOWN) {
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); created++;
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size();
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); known.put(route.getWorkItemKey(),route); created++;
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader).size();
} else {
var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null);
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM).size();
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known,requirementReader).size();
else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) {
if(source.establishedFlow()!=Flow.UNKNOWN) {
var generated=generate(source,source.establishedFlow(),existing,SYSTEM); created+=generated.size();
var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader); created+=generated.size();
route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route);
} else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) {
route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route);
@@ -48,18 +61,66 @@ public class AppointmentWorkSyncService {
}
}
}
// An existing row is never deleted because an order left the active source page.
// Empty return and other unfinished work continue to read their original authority.
// 原来源离开当前页也保留历史事项,未完成工作仍在第二轮按原业务完整核对。
timing.next(SyncPhase.TRANSPORT_FACTS);
var all=new ArrayList<>(store.all());
var facts=gateway.facts(all.stream().filter(s->s.getType()!=Type.ROUTE && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList());
// 首轮仅归并运输关系;保留已关联车次的退役成员,才能发现解绑并恢复原运输事项。
var transportItems=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList();
timing.transportFactsCount=transportItems.size();
var facts=gateway.facts(transportItems);
timing.next(SyncPhase.RECONCILE);
reconcileTransport(all,facts);
timing.next(SyncPhase.ACTIVE_STORE);
all=new ArrayList<>(store.all());
facts=gateway.facts(all.stream().filter(s->!s.isRetired()).toList());
var activeItems=all.stream().filter(s->!s.isRetired()).toList();
timing.activeFactsCount=activeItems.size();
timing.next(SyncPhase.ACTIVE_FACTS);
facts=gateway.facts(activeItems);
timing.next(SyncPhase.OBSERVE_CHECKPOINT);
observe(all,facts,SYSTEM);
guard.setScanCursor(page.complete()?null:page.nextCursor());
if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); }
store.checkpoint(guard);
success=true;
return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete());
} finally { timing.finish(success); }
}
/** 固定阶段名用于慢同步定位,不包含来源、游标或异常业务内容。 */
private enum SyncPhase { GUARD, DISCOVER, GENERATE, TRANSPORT_FACTS, RECONCILE, ACTIVE_STORE, ACTIVE_FACTS, OBSERVE_CHECKPOINT }
/** 仅统计本次方法执行,不含事务代理提交,不能当作精确锁持有时间。 */
private static final class SyncTiming {
private final Span body=span(Stage.SYNC_BODY);
private Span diagnosticPhase=span(Stage.SYNC_GUARD);
private final long started=System.nanoTime();
private final long[] elapsed=new long[SyncPhase.values().length];
private long phaseStarted=started;
private SyncPhase phase=SyncPhase.GUARD;
private int pageContainers=-1,transportFactsCount=-1,activeFactsCount=-1;
private boolean complete;
/** 完成前一阶段后开始下一阶段;事实阶段包含紧邻的数据库回读。 */
private void next(SyncPhase next) {
long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;phaseStarted=now;phase=next;
diagnosticPhase.close();diagnosticPhase=span(switch(next){
case GUARD->Stage.SYNC_GUARD;case DISCOVER->Stage.SYNC_DISCOVER;case GENERATE->Stage.SYNC_GENERATE;
case TRANSPORT_FACTS->Stage.SYNC_TRANSPORT_FACTS;case RECONCILE->Stage.SYNC_RECONCILE;
case ACTIVE_STORE->Stage.SYNC_ACTIVE_STORE;case ACTIVE_FACTS->Stage.SYNC_ACTIVE_FACTS;case OBSERVE_CHECKPOINT->Stage.SYNC_CHECKPOINT;});
}
/** 成功和失败共用一条慢调用摘要,未到达的计数保持为负一。 */
private void finish(boolean success) {
long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;
if(!success){diagnosticPhase.failed();body.failed();}diagnosticPhase.close();body.close();
long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(now-started);
if(totalMs<250) return;
try {
// 旧 activeFactsMs 仍包含工作表读取、解析及适配;新增明细不能改变旧指标口径。
long activeMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[SyncPhase.ACTIVE_STORE.ordinal()]+elapsed[SyncPhase.ACTIVE_FACTS.ordinal()]);
LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, activeStoreMs={}, activeAdapterMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}, requestId={}, traceId={}, scopeId={}",
totalMs,millis(SyncPhase.GUARD),millis(SyncPhase.DISCOVER),millis(SyncPhase.GENERATE),millis(SyncPhase.TRANSPORT_FACTS),millis(SyncPhase.RECONCILE),activeMs,millis(SyncPhase.ACTIVE_STORE),millis(SyncPhase.ACTIVE_FACTS),millis(SyncPhase.OBSERVE_CHECKPOINT),
pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name(),org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId());
} catch(Throwable diagnosticFailure) { /* 诊断失败不能替换原业务异常或阻断提交。 */ }
}
/** 阶段仅输出整数毫秒,不携带业务标识。 */
private long millis(SyncPhase value) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[value.ordinal()]); }
}
static void validateSource(ContainerSource source) {
if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null)
@@ -76,15 +137,25 @@ public class AppointmentWorkSyncService {
if(requirement.timezone()!=null) zone(requirement.timezone());
}
}
/** Caller holds work guard and validates the route against current original facts. */
/** 命令入口仍在调用方守卫事务中即时加载工作集。 */
List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor) {
return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements());
}
/** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */
private Map<String,AppointmentWorkState> workingSet() {
var known=new TreeMap<String,AppointmentWorkState>();
store.all().forEach(state->known.put(state.getWorkItemKey(),state));
return known;
}
/** 同页各柜共享最新状态,新增、跨柜关联和退役仍逐项持久化。 */
private List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor,
Map<String,AppointmentWorkState> known,RequirementReader requirementReader) {
validateSource(source);
if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确");
var requirements=gateway.requirements(source,flow);
var requirements=requirementReader.read(source,flow);
source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements);
validateSource(source);
if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单");
var known=store.all().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity(),(a,b)->a));
var created=new ArrayList<AppointmentWorkState>();
for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created);
for(var requirement:source.requirements()) {
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import com.zaxxer.hikari.HikariDataSource;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.config.BeanPostProcessor;
import javax.sql.DataSource;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
/** 仅观察原Hikari getConnection;同池同连接,unwrap保持容器bean self契约而非隐藏target身份。 */
final class AppointmentWorkDataSourcePostProcessor implements BeanPostProcessor {
/** 不识别或已有代理的数据源保持原对象,明确记录未覆盖而不主动探测连接。 */
@Override public Object postProcessAfterInitialization(Object bean,String name) {
if(!(bean instanceof DataSource))return bean;
if(!(bean instanceof HikariDataSource) || AopUtils.isAopProxy(bean)) {
warnUnavailable();return bean;
}
try {
var factory=new ProxyFactory(bean);factory.setProxyTargetClass(true);
factory.addAdvice((MethodInterceptor)invocation->{
if(!invocation.getMethod().getName().equals("getConnection") || !active())return invocation.proceed();
try(var span=span(Stage.CONNECTION_ACQUIRE)) {
try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;}
}
});
return factory.getProxy(bean.getClass().getClassLoader());
} catch(RuntimeException failure) { warnUnavailable();return bean; }
}
/** 不输出 Bean 名、数据源属性或异常内容。 */
private static void warnUnavailable() {
try{org.slf4j.LoggerFactory.getLogger(AppointmentWorkDataSourcePostProcessor.class).warn("Appointment connection diagnostic unavailable: unsupported or already proxied datasource");}
catch(Throwable ignored){ /* 日志故障不改变 Bean 注册。 */ }
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
/** 仅测量服务端同步 filter dispatch,不代表客户端总耗时或异步响应完成时间。 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE+10)
public class AppointmentWorkDiagnosticFilter extends OncePerRequestFilter {
private static final String BASE="/api/platform/v1/last-leg/appointment-work";
/** 仅识别固定模板;路径参数、query string和载荷不进入上下文。 */
static Route route(String method,String path){
if("GET".equals(method)){
if((BASE+"/summary").equals(path))return Route.SUMMARY;
if((BASE+"/calendar").equals(path))return Route.CALENDAR;
if((BASE+"/items").equals(path))return Route.ITEMS;
if((BASE+"/items/resolve").equals(path))return Route.RESOLVE;
if(path!=null && path.matches(BASE+"/items/[^/]+"))return Route.DETAIL;
}
if("POST".equals(method) && path!=null && path.matches(BASE+"/items/[^/]+/commands"))return Route.COMMAND;
return null;
}
/** 不采集其他端点,也不为诊断增加权限判断。 */
@Override protected boolean shouldNotFilter(HttpServletRequest request){return route(request.getMethod(),request.getRequestURI())==null;}
/** 创建及清理线程作用域;不覆写上游相关标识、状态、响应或异常。 */
@Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throws ServletException,IOException{
Scope scope=null;
try{scope=open(Origin.HTTP,route(request.getMethod(),request.getRequestURI()));}catch(RuntimeException ignored){ /* 无诊断仍执行原请求。 */ }
try{chain.doFilter(request,response);}
catch(ServletException|IOException|RuntimeException|Error error){if(scope!=null)scope.failed();throw error;}
finally{if(scope!=null){try{scope.status(response.getStatus());}catch(Throwable ignored){ /* 响应诊断读取失败不替换原异常。 */ }finally{scope.close();}}}
}
}
@@ -0,0 +1,128 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import java.util.*;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/** 仅在当前线程的预约调用内累加固定计时桶,不保存业务参数或跨请求状态。 */
public final class AppointmentWorkDiagnostics {
public enum Origin { HTTP, QUERY, COMMAND, SYNC }
public enum Route { INTERNAL, SUMMARY, CALENDAR, ITEMS, RESOLVE, DETAIL, COMMAND }
public enum Stage {
TRANSACTION_ADVISOR, QUERY_GUARD, QUERY_DISCOVER, QUERY_STORE, QUERY_SELECT, QUERY_FACTS,
QUERY_PROJECT, QUERY_NEXT, QUERY_FINAL, DETAIL_STORE, DETAIL_FACTS, DETAIL_PROJECT, DETAIL_HISTORY,
GUARD_LOCK, GUARD_READ, GUARD_UPDATE, FACTS_PREPARE, FACTS_FIRSTLEG, FACTS_RECEIVING,
FACTS_DELIVERY, FACTS_TRANSPORT, FACTS_DIRECT_TRANSPORT, FACTS_OTHER, FACTS_VALIDATE,
DELIVERY_SOURCE_MAP, DELIVERY_CARGO, DELIVERY_SNAPSHOTS, DELIVERY_ROWS, DELIVERY_RECEIVING,
DELIVERY_IMPACTS, DELIVERY_ASSEMBLY, SYNC_BODY, SYNC_GUARD, SYNC_DISCOVER, SYNC_GENERATE,
SYNC_TRANSPORT_FACTS, SYNC_RECONCILE, SYNC_ACTIVE_STORE, SYNC_ACTIVE_FACTS, SYNC_CHECKPOINT, CONNECTION_ACQUIRE,
DB_EXECUTOR_QUERY, DB_EXECUTOR_UPDATE, DB_PREPARE, DB_STATEMENT_QUERY, DB_STATEMENT_UPDATE, DB_RESULT_MAPPING
}
public enum StatementBucket { NONE, GUARD_LOCK, GUARD_READ, GUARD_UPDATE, WORK_STATE, WORK_HISTORY, DELIVERY, RECEIVING, OTHER }
public record Metric(long calls,long inclusiveNanos,long selfNanos,long failures,long firstOffsetNanos,long lastOffsetNanos) { }
public record Snapshot(String scopeId,String parentScopeId,Origin origin,Route route,String requestId,String traceId,
long elapsedNanos,boolean failed,int status,Map<String,Metric> metrics,boolean transactionObserved,int completionStatus,long completionOffsetNanos,long startedNanos) { }
private static final org.slf4j.Logger LOG=LoggerFactory.getLogger(AppointmentWorkDiagnostics.class);
private static final ThreadLocal<Scope> CURRENT=new ThreadLocal<>();
private static final Span NO_SPAN=new Span(null,null,null,0);
/** 诊断工具无需实例化。 */
private AppointmentWorkDiagnostics() { }
/** 新建独立作用域;关闭时恢复调用方原作用域,不改变 MDC。 */
public static Scope open(Origin origin,Route route) { return new Scope(origin,route,System::nanoTime); }
/** 返回纯诊断标识,缺少作用域时明确为空,不生成业务关联键。 */
public static String currentScopeId() { var scope=CURRENT.get();return scope==null?null:scope.id; }
/** 无作用域时所有阶段计时均为空操作。 */
public static boolean active() { return CURRENT.get()!=null; }
/** 只监听既有事务完成,不开启事务或改变其同步、隔离级别及连接属性。 */
public static void observeTransaction() {
try {
var scope=CURRENT.get();
if(scope==null || scope.transactionObserved || !org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive()
|| !org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive())return;
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization(){
/** 回调发生在原事务完成后,仅记录相对时刻;不声称这是数据库精确释放锁时刻。 */
@Override public void afterCompletion(int status){try{scope.completionStatus=status;scope.completionOffsetNanos=Math.max(0,scope.clock.getAsLong()-scope.started);}catch(Throwable ignored){}}
});
scope.transactionObserved=true;
}catch(Throwable ignored){ /* 诊断监听失败不能改变事务。 */ }
}
/** 将可信相关标识限制为诊断字符集;任意文本不进入日志。 */
private static String correlation(String value) { return value!=null && value.matches("[A-Za-z0-9_-]{1,80}")?value:null; }
/** 固定阶段开始计时;最多保留64层,避免异常递归造成诊断状态膨胀。 */
public static Span span(Stage stage) { return span(stage,StatementBucket.NONE); }
/** 数据库阶段只接受固定语句桶,不记录 SQL、参数或动态 Mapper 名称。 */
public static Span span(Stage stage,StatementBucket bucket) {
try {
var scope=CURRENT.get();if(scope==null || scope.stack.size()>=64)return NO_SPAN;
var span=new Span(scope,stage,bucket,scope.clock.getAsLong());scope.stack.push(span);return span;
} catch(RuntimeException ignored) { return NO_SPAN; }
}
/** 保留返回对象和原异常;测量只包围原有调用一次。 */
public static <T> T measure(Stage stage,Supplier<T> operation) {
try(var span=span(stage)) { try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;} }
}
/** 无返回值调用沿相同路径执行,不增加重试或业务判断。 */
public static void run(Stage stage,Runnable operation) { measure(stage,()->{operation.run();return null;}); }
/** 读取当前统计仅用于固定诊断摘要和隔离验证。 */
static Snapshot currentSnapshot() { var scope=CURRENT.get();return scope==null?null:scope.snapshot(); }
public static final class Scope implements AutoCloseable {
private final Scope previous;
private final String id=UUID.randomUUID().toString();
private final Origin origin;private final Route route;private final LongSupplier clock;
private final String requestId,traceId;private final long started;
private final Deque<Span> stack=new ArrayDeque<>();
private final Map<String,long[]> totals=new LinkedHashMap<>();
private boolean failed,closed,transactionObserved;private int status,completionStatus=-1;private long completionOffsetNanos=-1;
/** 包内时钟注入仅供确定性验证,不修改全局时钟。 */
Scope(Origin origin,Route route,LongSupplier clock) {
this.previous=CURRENT.get();this.origin=origin;this.route=route;this.clock=clock;this.started=clock.getAsLong();
this.requestId=correlation(MDC.get("requestId"));this.traceId=correlation(MDC.get("traceId"));CURRENT.set(this);
}
/** 只标记失败,不保存异常类型以外的业务内容。 */
public void failed() { failed=true; }
/** HTTP状态由容器响应读取,业务code不从载荷解析。 */
public void status(int status) { this.status=status; }
/** 快照为累计已结束阶段;self仅扣同scope直接子段,独立子scope由parentScopeId关联且不可叠加。 */
public Snapshot snapshot() {
var metrics=new LinkedHashMap<String,Metric>();totals.forEach((key,v)->metrics.put(key,new Metric(v[0],v[1],v[2],v[3],v[4],v[5])));
return new Snapshot(id,previous==null?null:previous.id,origin,route,requestId,traceId,Math.max(0,clock.getAsLong()-started),failed,status,Collections.unmodifiableMap(metrics),transactionObserved,completionStatus,completionOffsetNanos,started);
}
/** 只有慢调用或失败输出汇总;清理优先,日志异常不得覆盖原业务异常。 */
@Override public void close() {
if(closed)return;closed=true;
try {
if(CURRENT.get()==this){if(previous==null)CURRENT.remove();else CURRENT.set(previous);}
var s=snapshot();if(!s.failed() && s.status()<500 && s.elapsedNanos()<250_000_000L)return;
// startedNanos及offset只可在同一JVM进程比较,不是数据库锁持有者或跨进程时钟。
LOG.warn("Appointment attribution: scopeId={}, parentScopeId={}, origin={}, route={}, requestId={}, traceId={}, startedNanos={}, elapsedNs={}, failed={}, httpStatus={}, connectionCoverage={}, transactionObserved={}, completionStatus={}, completionOffsetNs={}, boundary=SERVER_DISPATCH_OR_ADVISOR, stages={}",
s.scopeId(),s.parentScopeId(),s.origin(),s.route(),s.requestId(),s.traceId(),s.startedNanos(),s.elapsedNanos(),s.failed(),s.status(),
s.metrics().containsKey(Stage.CONNECTION_ACQUIRE.name())?"OBSERVED":"NOT_OBSERVED",s.transactionObserved(),s.completionStatus(),s.completionOffsetNanos(),s.metrics());
} catch(Throwable ignored) { /* 诊断设施故障不影响返回、原异常或线程清理。 */ }
}
}
public static final class Span implements AutoCloseable {
private final Scope scope;private final Stage stage;private final StatementBucket bucket;private final long started;
private long children;private boolean failed,closed;
/** 保存常量阶段,不引用任何业务对象。 */
private Span(Scope scope,Stage stage,StatementBucket bucket,long started){this.scope=scope;this.stage=stage;this.bucket=bucket;this.started=started;}
/** 失败计数只作为耗时解释,不改变原异常。 */
public void failed(){if(scope!=null){failed=true;scope.failed=true;}}
/** 每个阶段只结束一次;父段self扣除子段inclusive,不能把两种口径相加。 */
@Override public void close(){
if(scope==null || closed)return;closed=true;
try{
long elapsed=Math.max(0,scope.clock.getAsLong()-started);
if(scope.stack.peek()!=this)return;
scope.stack.pop();if(!scope.stack.isEmpty())scope.stack.peek().children+=elapsed;
var key=stage.name()+(bucket==StatementBucket.NONE?"":":"+bucket.name());
var v=scope.totals.computeIfAbsent(key,ignored->new long[]{0,0,0,0,Math.max(0,started-scope.started),0});v[0]++;v[1]+=elapsed;v[2]+=Math.max(0,elapsed-children);if(failed)v[3]++;
v[4]=Math.min(v[4],Math.max(0,started-scope.started));v[5]=Math.max(v[5],Math.max(0,started+elapsed-scope.started));
}catch(Throwable ignored){ /* 诊断计数故障不能替换原结果。 */ }
}
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import com.baiyue.lastleg.appointmentwork.*;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.Advisor;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.StaticMethodMatcherPointcut;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
import org.springframework.core.Ordered;
import java.lang.reflect.Method;
import java.util.Set;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
/** 使用现有 Spring 代理设施观察事务边界,不新增 AspectJ 或改变事务属性。 */
@Configuration(proxyBeanMethods=false)
public class AppointmentWorkDiagnosticsConfiguration {
/** 基础设施角色确保事务自动代理器纳入此 Advisor;顺序早于默认事务 Advisor。 */
@Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public Advisor appointmentWorkDiagnosticAdvisor() {
var pointcut=new StaticMethodMatcherPointcut() {
/** 仅匹配预约直接入口,不拦截其他模块或内部辅助方法。 */
@Override public boolean matches(Method method,Class<?> targetClass) {
return AppointmentWorkSyncService.class.isAssignableFrom(targetClass) && method.getName().equals("synchronize")
|| AppointmentWorkCommandService.class.isAssignableFrom(targetClass) && method.getName().equals("execute")
|| AppointmentWorkQueryService.class.isAssignableFrom(targetClass) && Set.of("summary","calendar","items","resolveBatch","detail").contains(method.getName());
}
};
MethodInterceptor interceptor=invocation->{
boolean sync=invocation.getThis() instanceof AppointmentWorkSyncService;
Origin origin=sync?Origin.SYNC:invocation.getThis() instanceof AppointmentWorkCommandService?Origin.COMMAND:Origin.QUERY;
Scope owned=null;
try { if(sync || !active())owned=open(origin,Route.INTERNAL); } catch(RuntimeException ignored) { /* 无法计时仍执行原方法。 */ }
try(var span=span(Stage.TRANSACTION_ADVISOR)) {
try{return invocation.proceed();}catch(Throwable error){span.failed();if(owned!=null)owned.failed();throw error;}
} finally { if(owned!=null)owned.close(); }
};
var advisor=new DefaultPointcutAdvisor(pointcut,interceptor);advisor.setOrder(Ordered.HIGHEST_PRECEDENCE+100);return advisor;
}
/** 静态 Bean 避免提早实例化业务依赖,只对既有 Hikari 实例增加委托代理。 */
@Bean public static BeanPostProcessor appointmentWorkDataSourceDiagnostics() { return new AppointmentWorkDataSourcePostProcessor(); }
/** MyBatis 按现有插件入口注册,不注册 Mapper、执行 SQL 或改变缓存配置。 */
@Bean public AppointmentWorkMyBatisDiagnostics appointmentWorkMyBatisDiagnostics() { return new AppointmentWorkMyBatisDiagnostics(); }
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.*;
import java.sql.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
/** query 为含映射的 inclusive 调用;结果读取另记子段,不推断为纯服务器锁等待。 */
@Intercepts({
@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class}),
@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class,CacheKey.class,BoundSql.class}),
@Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class}),
@Signature(type=StatementHandler.class,method="prepare",args={Connection.class,Integer.class}),
@Signature(type=StatementHandler.class,method="query",args={Statement.class,ResultHandler.class}),
@Signature(type=StatementHandler.class,method="update",args={Statement.class}),
@Signature(type=ResultSetHandler.class,method="handleResultSets",args={Statement.class})
})
public final class AppointmentWorkMyBatisDiagnostics implements Interceptor {
private static final ThreadLocal<StatementBucket> BUCKET=new ThreadLocal<>();
/** 所有调用只 proceed 一次,异常原样返回;固定桶上下文在 finally 恢复。 */
@Override public Object intercept(Invocation invocation)throws Throwable {
if(!active())return invocation.proceed();
var previous=BUCKET.get();boolean executor=invocation.getTarget() instanceof Executor;
var bucket=previous==null?StatementBucket.OTHER:previous;
if(executor){bucket=bucket(((MappedStatement)invocation.getArgs()[0]).getId());BUCKET.set(bucket);}
Stage stage=executor?(invocation.getMethod().getName().equals("query")?Stage.DB_EXECUTOR_QUERY:Stage.DB_EXECUTOR_UPDATE)
:invocation.getTarget() instanceof ResultSetHandler?Stage.DB_RESULT_MAPPING
:switch(invocation.getMethod().getName()){case "prepare"->Stage.DB_PREPARE;case "query"->Stage.DB_STATEMENT_QUERY;default->Stage.DB_STATEMENT_UPDATE;};
try(var span=span(stage,bucket)){
try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;}
}finally{if(executor){if(previous==null)BUCKET.remove();else BUCKET.set(previous);}}
}
/** 只有明确白名单语句有独立桶,其他名字一律合入 OTHER,绝不打印原 ID。 */
private static StatementBucket bucket(String id){
return switch(id){
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.lockGuard"->StatementBucket.GUARD_LOCK;
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.guard"->StatementBucket.GUARD_READ;
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.invalidateRequirements","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.saveGuard"->StatementBucket.GUARD_UPDATE;
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.all","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.get"->StatementBucket.WORK_STATE;
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.selectHistory"->StatementBucket.WORK_HISTORY;
case "com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.batches","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.customer","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.privateBatch"->StatementBucket.DELIVERY;
default->StatementBucket.OTHER;
};
}
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.calendar;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.security.OverseasPermissions;
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.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-calendar")
public class WarehouseCalendarController {
private final WarehouseCalendarService service;
/** 装配整柜到仓日历读取服务。 */
public WarehouseCalendarController(WarehouseCalendarService service) { this.service = service; }
/** 使用预约和原订单读取权限,写资格继续使用原订单编辑权限。 */
@PostMapping("/warehouse-arrivals/query")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<List<WarehouseCalendarModels.Entry>> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseCalendarModels.Query query) {
if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L)
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可读预约日历");
return ApiResponse.success(service.calendar(query, user.permissions().contains(OverseasPermissions.LAST_MILE_ORDER_EDIT)));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.calendar;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface WarehouseCalendarMapper {
/** 同柜全部客户合并为一条;人工时间和已建入库计划的时间不能被到港推算覆盖。 */
@Select("""
<script>
SELECT * FROM (
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id))
THEN MAX(o.estimated_warehouse_at) END plannedAt,
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version,
(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) planCount,
FALSE canEdit
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL
GROUP BY o.source_container_booking_id
) c WHERE
(c.plannedAt &gt;= #{from} AND c.plannedAt &lt; #{to})
<if test="ids != null and !ids.isEmpty()">
OR c.sourceContainerBookingId IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
ORDER BY c.sourceContainerBookingId LIMIT 2001
</script>
""")
List<WarehouseCalendarModels.Entry> calendar(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to, @Param("ids") List<String> ids);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.calendar;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 只读日历沿用整柜预计到仓版本,不保存独立计划台账。 */
public final class WarehouseCalendarModels {
/** 禁止构造工具类。 */
private WarehouseCalendarModels() { }
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName,
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
long planCount, boolean canEdit) { }
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.calendar;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class WarehouseCalendarRepository {
private final WarehouseCalendarMapper mapper;
/** 只通过本服务 Mapper 查询柜级到仓事实。 */
public WarehouseCalendarRepository(WarehouseCalendarMapper mapper) { this.mapper = mapper; }
/** 同时覆盖跨月人工改期的柜与本次到港来源柜。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query) {
return mapper.calendar(query.from().atStartOfDay(), query.to().atStartOfDay(), query.sourceContainerBookingIds());
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.calendar;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List;
@Service
public class WarehouseCalendarService {
private final WarehouseCalendarRepository repository;
/** 装配日历只读仓储查询。 */
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; }
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
|| ChronoUnit.DAYS.between(query.from(), query.to()) > 42
|| new HashSet<>(query.sourceContainerBookingIds()).size() != query.sourceContainerBookingIds().size())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
var rows = repository.calendar(query);
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(),
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList();
}
}
@@ -52,6 +52,7 @@ public class CustomerVisitService {
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private CustomerVisitFileGateway files;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository deliveryExecution;
public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId());
@@ -76,6 +77,7 @@ public class CustomerVisitService {
return detail(visit);
}
/** 人工派送动作必须在真实出库后执行,取消统一走出库聚合命令。 */
@Transactional
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
String deliveryNo,
@@ -84,8 +86,14 @@ public class CustomerVisitService {
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
throw badRequest("不支持的派送状态");
}
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
var observed = deliveryExecution.findDelivery(customerId, deliveryNo);
if (observed == null) throw notFound("派送单不存在");
// 不反向申请排车锁;按出库→派送顺序与取消、发车串行。
var outbound = deliveryExecution.lockOutbound(customerId, observed.getOutboundNo());
DeliveryOrder delivery = deliveryExecution.lockDelivery(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.matches(delivery, outbound))
throw conflict("派送尚未实际出库,不能开始派送或拒签");
if (target.equals(delivery.getStatus())) {
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
@@ -99,7 +107,7 @@ public class CustomerVisitService {
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
delivery.setUpdatedAt(now);
delivery.setVersion(version(delivery) + 1);
deliveryOrders.updateById(delivery);
deliveryExecution.save(delivery);
String result = "派送状态由 " + from + " 修改为 " + target;
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now);
@@ -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,6 +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 " +
@@ -24,10 +25,13 @@ 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} ORDER BY l.occurred_at,l.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 " +
"FROM ll_return_carton_stock_receipt r JOIN ll_outbound_pallet_item oi ON oi.id=r.source_outbound_pallet_item_id AND oi.customer_id=r.customer_id " +
"WHERE r.customer_id=#{customerId} AND r.delivery_order_id=#{deliveryOrderId} " +
"GROUP BY r.receipt_no,r.source_dispatch_item_id,oi.warehouse_pallet_id,r.received_at ORDER BY received_at,ledger_id")
List<DeliveryReturnReceiptFact> returnReceipts(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
@@ -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);
}
@@ -4,7 +4,7 @@ import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */
/** 还原整托实际入仓组成及本次已拣数量,不按展示计划过滤合托来源。 */
@Mapper
public interface DispatchFeeCargoMapper {
@Select("""
@@ -17,18 +17,15 @@ 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
WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId}
AND (di.plan_pallet_id IS NULL OR EXISTS (
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=di.customer_id
WHERE a.pallet_id=di.plan_pallet_id AND line.order_id=b.inbound_order_id))
GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code
HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
ORDER BY di.id,b.inbound_order_id
""")
/** 整托候选箱数来自物理库存,因此组成也必须包含该托所有实际来源。 */
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
}
@@ -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); }
}
@@ -6,9 +6,9 @@ import java.util.Set;
public final class DeliveryStatusPolicy {
private static final Set<String> EDITABLE_TARGETS = Set.of(
"WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED");
"WAIT_DELIVERY", "DELIVERING", "REJECTED");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"),
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED"),
"DELIVERING", Set.of("DELIVERED", "REJECTED"),
"DELIVERED", Set.of(),
"REJECTED", Set.of(),
@@ -16,23 +16,32 @@ public final class DeliveryStatusPolicy {
"RETURNED", Set.of(),
"CANCELLED", Set.of());
/** 状态规则为纯策略,实际出库由命令及查询层提供。 */
private DeliveryStatusPolicy() { }
/** 人工只允许开始派送或拒签,取消必须由出库聚合命令处理。 */
public static boolean isEditableTarget(String status) {
return EDITABLE_TARGETS.contains(status);
}
/** 校验状态机,签收终态仍由签收资料命令触发。 */
public static boolean canTransition(String from, String target) {
return TRANSITIONS.getOrDefault(from, Set.of()).contains(target);
}
/** 返回状态本身允许的动作;执行动作还须满足实际出库事实。 */
public static List<String> allowedActions(String status) {
return switch (status == null ? "" : status) {
case "WAIT_DELIVERY" -> List.of(
"START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
"START_DELIVERY", "REJECT_DELIVERY");
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN");
default -> List.of();
};
}
/** 未实际出库不得开始派送、拒签或创建运输退回。 */
public static List<String> allowedActions(String status, boolean outboundCompleted) {
return outboundCompleted ? allowedActions(status) : List.of();
}
}
@@ -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,52 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 派送执行门禁的数据入口,保留取消单据历史。 */
@Repository
public class DeliveryExecutionRepository {
private final OutboundOrderMapper outbounds;
private final DeliveryOrderMapper deliveries;
/** 装配同库的出库及派送访问。 */
public DeliveryExecutionRepository(OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries) {
this.outbounds = outbounds;
this.deliveries = deliveries;
}
/** 按客户和业务编号读取原派送,供命令确定出库锁顺序。 */
public DeliveryOrder findDelivery(String customerId, String deliveryNo) {
return deliveries.findByNo(customerId, deliveryNo);
}
/** 先锁出库,再锁派送,与取消和发车保持一致。 */
public OutboundOrder lockOutbound(String customerId, String outboundNo) {
return outbounds.lockByNo(customerId, outboundNo);
}
/** 锁定客户范围内的派送,锁后由业务层复核关联。 */
public DeliveryOrder lockDelivery(String customerId, String deliveryNo) {
return deliveries.lockByNo(customerId, deliveryNo);
}
/** 读取客户范围内的出库事实,用于动作展示。 */
public OutboundOrder findOutbound(String customerId, String outboundNo) {
return outbounds.findByNo(customerId, outboundNo);
}
/** 发车时锁定全部派送,取消与执行只能产生一个合法结果。 */
public List<DeliveryOrder> deliveries(OutboundOrder outbound, boolean lock) {
return lock ? deliveries.lockByOutbound(outbound.getCustomerId(), outbound.getId())
: deliveries.findByOutbound(outbound.getCustomerId(), outbound.getId());
}
/** 保存已校验版本的派送动作。 */
public void save(DeliveryOrder delivery) {
deliveries.updateById(delivery);
}
}
@@ -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,18 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 复用库存组成及本次拣货来源,不把展示入仓码当作整托归属。 */
@Repository
public class DispatchCargoSourceRepository {
private final DispatchFeeCargoMapper sources;
/** 装配实际货物组成查询。 */
public DispatchCargoSourceRepository(DispatchFeeCargoMapper sources) { this.sources = sources; }
/** 按客户和当前排车读取逐入仓来源数量。 */
public List<DeliveryFeeCoverageModels.Cargo> sources(String customerId, long dispatchId) {
return sources.list(customerId, dispatchId);
}
}
@@ -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,53 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Objects;
/** 统一派送执行事实,防止状态编辑绕过出库及车次事务。 */
@Service
public class DeliveryExecutionGuard {
private final DeliveryExecutionRepository repository;
/** 装配执行事实的数据入口。 */
public DeliveryExecutionGuard(DeliveryExecutionRepository repository) { this.repository = repository; }
/** 展示动作只认同客户、同出库单的实际出库事实。 */
public boolean outboundCompleted(DeliveryOrder delivery) {
return matches(delivery, repository.findOutbound(delivery.getCustomerId(), delivery.getOutboundNo()));
}
/** 发车前要求派送覆盖完整、均未执行,锁定检查与出库提交同事务。 */
public String departureBlockReason(OutboundOrder outbound, boolean lock) {
var rows = repository.deliveries(outbound, lock);
if (rows.isEmpty()) return "缺少对应派送单,请先核对转派送结果";
if (rows.stream().anyMatch(row -> !Objects.equals(row.getCustomerId(), outbound.getCustomerId())
|| !Objects.equals(row.getOutboundOrderId(), outbound.getId())
|| !Objects.equals(row.getOutboundNo(), outbound.getOutboundNo())
|| !"WAIT_DELIVERY".equals(row.getStatus()) || row.getDispatchedAt() != null || row.getSignedAt() != null
|| row.getPackageCount() == null || row.getPackageCount() <= 0))
return "关联派送单已取消、已执行或来源不一致,不能发车";
if (rows.stream().mapToLong(DeliveryOrder::getPackageCount).sum()
!= Objects.requireNonNullElse(outbound.getPackageCount(), 0))
return "关联派送数量未完整覆盖出库货物";
return null;
}
/** 真正发车时再次加锁校验,不能依赖列表查询结果。 */
public void requireDeparture(OutboundOrder outbound) {
String reason = departureBlockReason(outbound, true);
if (reason != null) throw new ResponseStatusException(HttpStatus.CONFLICT, reason);
}
/** 共享实际出库判据,不能只凭派送状态或发车时间推断。 */
public static boolean matches(DeliveryOrder delivery, OutboundOrder outbound) {
return outbound != null && "OUTBOUNDED".equals(outbound.getStatus())
&& Objects.equals(delivery.getCustomerId(), outbound.getCustomerId())
&& Objects.equals(delivery.getOutboundOrderId(), outbound.getId())
&& Objects.equals(delivery.getOutboundNo(), outbound.getOutboundNo());
}
}
@@ -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,121 +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<Long> externalItems) {
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
if (evidence == null) return List.of();
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalItems)).toList();
}
}
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<Long> externalItems) {
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
var preview = preview(dispatchNo);
if (!preview.sourceComplete()) throw conflict(preview.blockReason());
if (!Objects.equals(preview.cargoHash(), request.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化,请重新核对覆盖范围");
var references = new ArrayList<DeliveryFeeCoverageModels.Reference>();
var scopes = new HashSet<String>();
for (var selection : request.feeCoverage()) {
var fee = preview.feeSources().stream().filter(row -> Objects.equals(row.financeFeeId(), selection.financeFeeId())).findFirst()
.orElseThrow(() -> conflict("原费用不属于本次实际货物,或已审核状态/有效性发生变化"));
if (fee.sourceVersion() != selection.sourceVersion() || !Objects.equals(fee.sourceHash(), selection.sourceHash()))
throw conflict("原费用版本已变化,请重新确认服务覆盖范围");
var services = new TreeSet<>(selection.services());
if (services.isEmpty() || !Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE").containsAll(services))
throw conflict("请选择本票原费用明确包含的服务");
for (var service : services) if (!scopes.add(fee.sourceInboundId() + ":" + service))
throw conflict("同一票同一服务只能引用一项原费用");
references.add(new DeliveryFeeCoverageModels.Reference(fee, List.copyOf(services)));
}
var included = new HashSet<String>();
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalItems))) included.add(service);
return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(),
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalItems));
}
private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
List<DeliveryFeeCoverageModels.Reference> references, Set<Long> externalItems) {
if ("DELIVERY_FEE".equals(service) && externalItems.contains(cargo.dispatchItemId())) return true;
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "原费用来源暂不可用,请稍后重试"); }
}
@@ -1,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,272 +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;
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo));
if (dispatch == null) throw notFound("Dispatch order not found");
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
.toList());
}
@Transactional
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 quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
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 source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
+ quote.currency() + " " + quote.amount().toPlainString(), now);
// An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee.
if (quote.amount().signum() == 0) continue;
feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT",
new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(),
null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()),
"DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null,
null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(),
quote.quantityUnit(), quote.unitPrice(), null, null));
}
DeliveryOrder feeDelivery = deliveries.get(0);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
for (DeliveryRequests.FeeItem input : feeInputs) {
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
var uncovered = coverage.uncovered(feeCode);
var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet());
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
: feeDelivery;
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
}
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(),
firstOrder == null ? null : firstOrder.getId(), null,
firstOrder == null ? null : firstOrder.getInboundCode(), null,
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(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 DeliveryExecutionGuard deliveryExecution;
@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) {
@@ -1153,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) {
@@ -1225,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()
@@ -1315,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());
@@ -1325,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());
}
@@ -1579,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(),
@@ -1637,11 +1358,11 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
/** 派送动作由状态和实际出库共同决定,列表与详情复用同一资格。 */
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus(), deliveryExecution.outboundCompleted(d)), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
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();
@@ -1651,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()))
@@ -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); }
}
@@ -13,6 +13,15 @@ import java.util.List;
@Mapper
public interface CargoDiscardInventoryMapper {
// 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。
String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " +
"JOIN ll_inventory_position pos ON pos.id=dp.inventory_position_id AND pos.customer_id=dp.customer_id " +
"AND pos.pallet_id=dp.warehouse_pallet_id " +
"JOIN ll_inventory_balance bal ON bal.id=pos.balance_id AND bal.customer_id=pos.customer_id " +
"WHERE dp.customer_id=c.customer_id AND dp.order_id=c.order_id AND dp.id=c.profile_id " +
"AND dp.status='ACTIVE' AND pos.status='OCCUPIED' AND pos.available_packages>0 " +
"AND bal.available_packages>0) ";
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@@ -50,10 +59,12 @@ public interface CargoDiscardInventoryMapper {
int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id")
/** 仅列出仍有实际可用库存的箱子,排除已出库托盘的残留箱唛。 */
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK + " ORDER BY c.profile_id,c.id")
List<DropshipCarton> availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 提交选箱时再次核对实际可用库存,避免旧页面绕过候选过滤。 */
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND c.id IN <foreach collection='cartonIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND EXISTS(SELECT 1 FROM ll_dropship_carton_sku cs WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) ORDER BY c.profile_id,c.id FOR UPDATE</script>")
List<DropshipCarton> lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@@ -72,6 +83,7 @@ public interface CargoDiscardInventoryMapper {
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
"COUNT(c.id) available_packages FROM ll_order o " +
@@ -80,33 +92,37 @@ public interface CargoDiscardInventoryMapper {
"JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
"AND c.profile_id=p.id AND c.status='AVAILABLE' " +
"JOIN ll_dropship_carton_sku cs ON cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=s.sku_code " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " + AVAILABLE_STOCK +
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
"p.id,s.id,s.sku_code,s.product_name HAVING COUNT(c.id)&gt;0 ORDER BY o.created_at DESC,o.id DESC,p.id,s.sku_code LIMIT 500</script>")
List<CargoDiscardCandidateRow> candidates(@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** SKU 可选数量仅统计实际仍在可用仓位的箱子。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 托盘 SKU 数量与候选及提交使用相同库存门禁。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.profile_id=#{profileId} AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 全部废弃锁箱时排除已出库或无实际可用库存的托盘。 */
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) " +
"ORDER BY c.profile_id,c.carton_sequence,c.id LIMIT #{limit} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("limit") int limit);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND status='AVAILABLE'")
/** 入库单按实际可用托盘统计物理箱数,不重复累计混装 SKU。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK)
long countAvailableOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
@@ -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));
}
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, 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)));
}
/** 只列出当前拥有者未失效草稿。 */
@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,29 +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) {
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;
}

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