Compare commits

...
7 Commits
Author SHA1 Message Date
liuyihe ce5144d27e 功能提交 2026-08-19 15:52:47 +08:00
liuyihe 8bf775f885 功能提交 2026-08-19 09:24:56 +08:00
liuyihe 31cf37c528 功能提交 2026-08-18 16:31:55 +08:00
liuyihe d2c532f807 提交 2026-08-18 11:39:40 +08:00
liuyihe ac6c0d0395 提交 2026-08-17 19:04:58 +08:00
liuyihe 3082167411 派车提交 2026-08-17 13:38:48 +08:00
liuyihe d6fe79b0f2 提交 2026-08-17 09:18:03 +08:00
334 changed files with 25023 additions and 154 deletions
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
# 海外尾程后端项目指引
## 适用范围
- 本文件适用于 `baiyue-lastleg/` Maven 多模块仓库。修改具体服务前,继续读取该模块的 `AGENTS.md`。
- 本层维护服务边界、跨服务协作、数据库变更入口和通用验证;类、接口和表的入口以模块级指引为准。
- 海外服务不得跨库查询国内头程数据。跨区事实必须通过版本化事件和不可变快照传递。
## 服务路由
| 任务关键词 | 首选模块 | 模块指引 |
|---|---|---|
| RS256、JWKS、Audience、Claims、海外统一入口 | `gateway/` | `gateway/AGENTS.md` |
| 尾程订单、装柜完成事件、Inbox、订单明细、状态历史 | `lastleg/` | `lastleg/AGENTS.md` |
| 入仓计划、托盘分配、PDA 入仓作业、合托 | `lastleg/` | `lastleg/AGENTS.md` 的入仓计划索引 |
| 跨区事件接收、重试、死信、人工补投、投递记录 | `event-bridge-service/` | `event-bridge-service/AGENTS.md` |
| 海外仓、库位、托盘、资源锁定与释放 | `warehouse/` | `warehouse/AGENTS.md` |
| 文件、打印、审计、第三方集成 | `file/`、`print/`、`audit-service/`、`integration-service/` | 对应模块源码;补充业务能力时先新增模块指引 |
## 固定契约与边界
- 头程装柜完成事件固定为 `FirstLegLoadingPlanCompleted` V1,`CN -> OVERSEAS`,聚合类型为 `LOADING_PLAN`。
- 一个事件只承载一个 `tenantId/accountId`;`payload.orders` 中每个最终实装 `sourceInboundId` 独立生成一条尾程订单记录,不按 FBA 仓、渠道或海外仓再次合并。
- 尾程消费者不得回查头程。订单、明细、重量、体积、目的仓、SKU、FBA tracking 和 shipment 等均使用事件冻结快照。
- 事件幂等和业务幂等必须同时成立:相同 `eventId + payloadHash` 重投返回已有结果;`(tenant_id, source_region, source_inbound_id)` 防止重复建单。
- 内部事件接口必须校验 `X-Internal-Service-Token`。外部用户接口由海外网关校验 RS256/JWKS、`aud=lastleg-api` 和必要身份声明。
- 调用方不得通过请求参数任意扩大 `tenantId` 范围;租户范围必须来自可信认证身份或平台权限。
## 数据库与验证
- 模块基线 SQL 位于 `<module>/src/main/resources/db/`。已部署环境的后续字段、注释和索引变更统一追加到 `scripts/db/incremental.sql`,不得依赖修改 `CREATE TABLE IF NOT EXISTS` 自动更新旧表。
- SQL 必须可重复执行,新增索引前检查 `information_schema.statistics`,新增/修改字段前检查 `information_schema.columns`,并保留已有业务数据原文。
- 单模块优先运行 `mvn -pl <module> -am test`;跨事件链路至少运行 `lastleg` 与 `event-bridge-service` 的定向测试。
- 不提交 `target/`、运行日志、临时联调脚本或本地密钥。
+6 -2
View File
@@ -21,12 +21,14 @@
1. 用户在国内统一入口登录,获得使用非对称算法签名的短时效 JWT。
2. JWT 包含用户、租户、应用端、角色、按钮权限和权限版本等快照信息。
3. 海外网关通过 `JWT_JWK_SET_URI` 获取并缓存公钥,只负责验签、签发方、有效期和应用端校验,不保存签名私钥。
3. 海外网关通过 `JWT_JWK_SET_URI` 获取并缓存公钥,校验 RS256/ES256 签名、签发方、有效期、`lastleg-api` Audience、应用端和必要身份声明,不保存签名私钥。
4. 尾程业务服务通过公共安全组件读取权限快照并执行接口、按钮和租户边界校验,不在每次请求时跨区查询国内权限数据。
5. 用户停用、租户冻结和权限版本变化通过跨区事件同步到海外本地投影;高风险操作可依据本地冻结投影立即拒绝。
海外服务不得依赖国内 Redis 中的登录会话。正式上线前,头程公共安全制品必须完成 JWKS 解码器和本地权限快照校验改造。
生产令牌必须包含 `sub`、`tenant_id`、`app_code`、`permission_version`,且 `aud` 必须包含配置的 `JWT_AUDIENCE`。认证中心应将访问令牌有效期控制在 5 至 15 分钟,并通过权限事件及时同步用户停用、租户冻结和权限版本变化。
## 公共制品依赖
本仓库不复制 `common-*` 源码。公共组件由头程仓发布到 Maven 私有仓库,本项目通过以下制品导入版本:
@@ -48,7 +50,7 @@ com.baiyue.shared:common-event-contract
| 服务 | 独立数据库 |
|---|---|
| `lastleg-service` | `baiyue_lastleg` |
| `warehouse-service` | `baiyue_warehouse` |
| `warehouse-service` | `baiyue_lastlag_warehouse` |
| `file-service` | `baiyue_file` |
| `print-service` | `baiyue_print` |
| `audit-service` | `baiyue_audit` |
@@ -56,6 +58,8 @@ com.baiyue.shared:common-event-contract
服务之间不得跨库查询。国内订单和头程事实通过版本化事件进入尾程本地投影,尾程状态和签收事实也通过事件回传国内。
事件桥的海外本地投递使用数据库租约避免多实例重复领取,失败后指数退避并在达到上限后进入 `DEAD`。每次投递写入 `bridge_delivery_attempt`;经审批后可调用内部补投接口重新进入重试队列。生产环境必须将事件桥内部接口置于来源白名单和 mTLS 之后,并把 `LASTLEG_CONSUMER_URL` 配置为受信任的 HTTPS 内部地址。
## 文件服务说明
原工程中企业资质上传后同步调用国内权限服务的链路已从尾程仓移除。海外文件中心当前只保留基础骨架;提单、装箱单、清关文件和签收证明的对象存储、授权下载、病毒扫描及保留期限策略属于后续必做能力。
@@ -4,17 +4,14 @@ import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.entity.AuditLogEntity;
import com.baiyue.audit.mapper.AuditLogMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
/**
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
*/
@Repository
public class AuditEventRepository {
private final AuditLogMapper mapper;
public AuditEventRepository(AuditLogMapper mapper) {
this.mapper = mapper;
}
@Resource private AuditLogMapper mapper;
public void save(AuditIngestRequest event) {
AuditLogEntity entity = new AuditLogEntity();
+1
View File
@@ -5,6 +5,7 @@ baiyue:
data-residency: ${DATA_RESIDENCY:OVERSEAS}
security:
issuer: ${JWT_ISSUER:https://auth.example.com}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
+10
View File
@@ -8,3 +8,13 @@ baiyue:
region: ${REGION_CODE:OVERSEAS}
deployment-cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
event-dispatcher:
consumer-url: ${LASTLEG_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
+121
View File
@@ -0,0 +1,121 @@
# 尾程平台已完成功能报告(2026-08-15)
## 1. 交付结论
头程装柜完成到尾程订单的真实链路已打通。头程按客户发布
`FirstLegLoadingPlanCompleted V1` 事件,经 Event Bridge 可靠投递后,尾程按事件中
每一笔最终实装入仓单分别落一条订单记录。同一柜的订单记录共享尾程单号,
`sourceInboundId` 作为来源建单唯一标识。
本报告仅描述当前代码和已经取得证据的能力,不把规划中的功能列为已完成。
## 2. 已完成功能
### 2.1 跨区域事件桥
- 接收并持久化版本化事件信封,保存幂等键、负载摘要和冻结 JSON。
- 使用数据库租约领取待投递事件,支持多实例竞争和超时租约恢复。
- `RECEIVED` 与 `RETRY` 都可重新调度,失败采用指数退避,达到上限进入 `DEAD`。
- 每次投递写入 `bridge_delivery_attempt`,记录投递序号、结果、响应码和错误。
- 支持经过内部令牌保护的死信人工补投;成功后才把 Inbox 标记为 `PROCESSED`。
- Event Bridge 与尾程内部接口使用统一的 `X-Internal-Service-Token`。
### 2.2 装柜完成事件消费与尾程建单
- 只接受 `CN -> OVERSEAS`、`LOADING_PLAN`、版本为 1 的装柜完成事件。
- 强制校验实际 `containerNo` 和非空 `orders`,尾程不回查头程数据。
- 事件 Inbox 以 `eventId` 防重复,并校验重复事件的 `payloadHash`。
- 按 `orders` 逐笔创建尾程订单;同柜入仓单共享一个不可碰撞的尾程单号。
- 以 `(tenant_id, source_region, source_inbound_id)` 保证来源入仓单建单幂等。
- 保存根入仓单、来源订单、装柜计划版本、交接版本和来源摘要等追溯字段。
- 保存商品、箱数、数量、托数、重量、体积、目的仓、SKU、FBA 跟踪号、
shipmentNo 和唛头等实装冻结明细。
- 新订单初始化为 `PENDING_ASSIGNMENT`,同步写入状态历史。
- 同一来源入仓单摘要不一致时拒绝静默覆盖。
### 2.3 尾程订单查询与转仓
- 按租户分页查询,支持尾程单号、入仓码、头程订单号、柜号、状态、转仓、
异常、暂扣和创建时间筛选。
- 查询订单详情、实装明细和状态历史。
- 导出 UTF-8 CSV,并处理公式注入风险。
- 支持单笔和批量转仓,更新订单及明细目的仓快照并写状态历史。
- 已生成入库计划的订单禁止再次修改目的仓。
- 普通客户只能查询认证身份对应租户;平台身份必须显式指定客户租户,
不允许通过请求参数任意跨客户查询。
### 2.4 尾程入库计划与 PDA 作业
- 按租户和实际集装箱生成唯一入库计划。
- 保存入仓码明细、目的仓快照、计划托盘和托盘分配关系。
- 按目的仓隔离合托,支持固定容量托盘分配。
- 提供 PDA 任务、操作幂等记录、托盘合并记录及仓库资源锁关联字段。
### 2.5 仓库资源和海外认证边界
- 仓库维护库位、托盘及资源锁,资源锁使用租户幂等键并记录业务归属。
- 仓库数据源启动保护避免 schema 接错后误执行建表脚本。
- 海外 Gateway 使用 JWKS 验证非对称 JWT,校验 `lastleg-api` Audience 及必需 claims。
- 尾程内部事件入口使用常量时间令牌比较;未配置或令牌错误时拒绝消费。
### 2.6 工程规范与数据库交付
- 生产 Spring Bean 的依赖注入已统一为 `jakarta.annotation.Resource`;仅保留配置值
`@Value` 和普通值对象/校验器构造器参数,不把不可变业务值误当作 Bean 注入。
- 新增统一增量入口 `scripts/db/incremental.sql`,按 `001-006` 归档 Event Bridge、尾程订单与入库计划/PDA、仓库资源及仓库主数据的字段、中文注释和索引变更。
- 增量脚本使用 `information_schema` 守卫,45 个动态 DDL 均成对释放,且不包含业务数据写入、删除或清理语句。
## 3. 真实链路验收证据
| 项目 | 结果 |
|---|---|
| loadingPlanId | `2091000814000009002` |
| containerNo | `LLSYNC-CNTR-20260814` |
| tenantId/accountId | `10001` |
| eventId | `88cd0f63-dc9e-4c16-bdb7-c32b20a83c3b` |
| payloadHash | `sha256:23011feff2ff91203fb94ebab7cab8306f58e3e128f61d17913d2f74af5c87f4` |
| Event Bridge 状态 | `PROCESSED` |
| 尾程单号 | `LMO43831679558828067566368238939` |
同一事件实际生成两条入仓单维度订单记录:
| sourceInboundId | 入仓码 | 箱数 | 托数 | 明细数 |
|---|---|---:|---:|---:|
| `2091000814000000301` | `LLSYNC-20260814-IN-01` | 2 | 1 | 1 |
| `2091000814000001301` | `LLSYNC-20260814-IN-02` | 2 | 1 | 1 |
重复投递相同 `eventId` 后接口仍返回成功,数据库订单记录总数保持为 2,未重复建单。
## 4. 数据库交付约定
全量初始化脚本继续由各服务维护:
- `lastleg/src/main/resources/db/lastleg.sql`
- `event-bridge-service/src/main/resources/db/event-bridge.sql`
- `warehouse/src/main/resources/db/warehouse.sql`
从本次交付开始,所有字段和索引变更同时追加到
`scripts/db/incremental.sql`。该文件按日期和序号只追加,已发布段落不得修改;
新增字段和索引必须查询 `information_schema` 后再执行,禁止放入测试数据、
业务数据修复、`DELETE` 或 `TRUNCATE`。
当前归档段为 `001` Event Bridge、`002` 仓库资源、`003` 入库计划/PDA、
`004` Event Bridge 存量字段升级、`005` 尾程订单投影和 `006` 仓库主数据注释/索引。
脚本静态契约校验结果:UTF-8、614 行、45 个 `PREPARE` 与 45 个释放操作,
未包含业务 DML,也未检测到乱码字符。
## 5. 验证结果
- `mvn -pl lastleg -am test`:34 tests passed。
- `mvn -pl event-bridge-service -am test`:15 tests passed。
- `mvn -pl warehouse -am test`:26 tests passed。
- `mvn -pl gateway -am test`:7 tests passed。
- 注入改造额外验证:`integration-service` 1 test passed,`audit-service` 编译通过。
- 生产代码扫描无 `@Autowired`、`@Inject` 或 Lombok 构造器注入残留;文档和 SQL UTF-8 乱码扫描通过。
## 6. 当前未声明完成的范围
- 到港、清关、拆柜、正式入库、出库、派送和签收的完整状态命令链路。
- 前端尾程订单页面。
- 权限冻结事件的海外本地投影和完整生产密钥轮换演练。
- 生产级 mTLS、来源网络白名单、监控告警和灾备演练。
+11 -2
View File
@@ -88,7 +88,6 @@ PDA 部分拣货会生成本柜实装 `LOADED` 分片以及可继续分柜的 `R
"loadingPlanNo": "LP202608130001",
"containerBookingId": "208500001",
"containerNo": "MSCU1234567",
"cabinetNo": "CAB20260813001",
"containerType": "40HQ",
"sealNo": "SEAL001",
"voyageNo": "V001",
@@ -142,12 +141,20 @@ PDA 部分拣货会生成本柜实装 `LOADED` 分片以及可继续分柜的 `R
- 文件只传不可变文件引用、区域和摘要,不传二进制内容。
- V1 消费者忽略未知字段;已有字段不改变含义或类型。
### 5.1 订单类型分支
`orderType`、`pallets`、`palletNo`、`skuPackages` 和 `cartonMarks` 必须正式加入头程公共事件契约及发送端,不能长期依赖尾程读取原始 JSON 扩展字段。三个服务升级到同一 `common-event-contract` 版本后再启用生产流量。
`ONE_CLICK_DROPSHIP` 的一笔最终实装入仓单可包含一个或多个来源托盘快照。消费时必须满足:所有托盘的 SKU 箱数之和等于订单箱数、SKU 数量之和等于订单数量、每个 SKU 的箱唛数等于其箱数、同一托盘内 SKU 不重复、同一订单内来源托盘号和箱唛不重复。通过后按来源托盘创建一件代发档案及托盘级 SKU 库存,并创建箱唛记录和不可变箱唛流水。`source_pallet_no` 是头程冻结快照;`warehouse_pallet_id/pallet_no` 是 Warehouse API 分配的海外仓实物托盘,两者不得混用。
非 `ONE_CLICK_DROPSHIP` 类型只创建 `ll_order`、冻结明细和初始状态历史,来源 `palletCount` 按 0 入库,不创建一件代发 SKU/箱唛库存。后续入仓 PDA 分配的托盘属于海外仓作业资源。
## 6. 尾程建单幂等
尾程订单表必须有以下唯一约束:
```text
UNIQUE (tenant_id, source_region, source_inbound_id)
UNIQUE (customer_id, source_region, source_inbound_id)
```
建议同时保存:
@@ -170,6 +177,8 @@ UNIQUE (tenant_id, source_region, source_inbound_id)
事件内任意一笔订单建单失败时,整个事件消费事务回滚,不允许出现同一客户同一柜次只生成部分尾程订单。
同一实际集装箱的订单共享 `order_no`、`container_no` 和 `source_loading_plan_id`;`ll_order.id` 仍是每条订单独立的雪花 ID。整柜查询、整柜操作和转入库计划统一以 `containerNo` 聚合,不使用虚拟柜号 `cabinetNo`。数据库必须保留 `KEY idx_ll_order_container (customer_id, container_no)`。
## 7. 尾程订单初始状态
事件自动生成的订单建议使用:
+2 -3
View File
@@ -62,10 +62,9 @@
- 已完成独立根 POM、业务模块抽取、公共依赖坐标调整和海外配置收敛。
- 已移除文件服务中依赖国内权限服务的企业资质链路。
- 海外网关已改为 JWKS 验签并默认启用海外显式路由。
- 海外网关已改为 JWKS 验签,强制校验 Audience 和必要身份声明,并默认启用海外显式路由。
- 公共安全组件的 JWKS 解码、本地权限快照和冻结事件处理仍需在公共制品中完成。
- 海外提单、装箱单、清关文件和签收证明能力仍待实现。
- 跨区发送器、消息中间件接入、重试调度和死信补投仍待实现。
- 海外事件桥到尾程消费者的本地调度已具备租约、指数退避、投递审计、死信和人工补投;头程跨区发送端、尾程反向回传和生产 mTLS 仍需完成联调验收。
只有上述待办完成并通过故障注入、跨区断网、重复事件、乱序事件和密钥轮换验收后,尾程服务才允许迁移到海外生产环境。
+13 -3
View File
@@ -4,7 +4,7 @@ DEPLOYMENT_CELL=prod-overseas
DATA_RESIDENCY=OVERSEAS
# 海外注册中心和配置中心
NACOS_SERVER_ADDR=nacos-overseas.internal:8848
NACOS_SERVER_ADDR=nacos-overseas.internal:8858
NACOS_NAMESPACE=prod-overseas
NACOS_GROUP=BAIYUE
NACOS_USERNAME=
@@ -12,13 +12,14 @@ NACOS_PASSWORD=
# 海外本地缓存,不得连接国内 Redis
REDIS_HOST=redis-overseas.internal
REDIS_PORT=6379
REDIS_PORT=6380
REDIS_PASSWORD=
REDIS_DATABASE=0
PERMISSION_SNAPSHOT_KEY_PREFIX=baiyue:permission-snapshot
# 国内认证中心只公开非对称签名公钥,海外不得保存签名私钥
JWT_ISSUER=https://auth.example.com
JWT_AUDIENCE=lastleg-api
JWT_JWK_SET_URI=https://auth.example.com/.well-known/jwks.json
INTERNAL_SERVICE_TOKEN=
@@ -26,7 +27,7 @@ INTERNAL_SERVICE_TOKEN=
LASTLEG_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
LASTLEG_DB_USERNAME=
LASTLEG_DB_PASSWORD=
WAREHOUSE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
WAREHOUSE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
WAREHOUSE_DB_USERNAME=
WAREHOUSE_DB_PASSWORD=
FILE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
@@ -42,6 +43,15 @@ EVENT_BRIDGE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_event_bridg
EVENT_BRIDGE_DB_USERNAME=
EVENT_BRIDGE_DB_PASSWORD=
EVENT_BRIDGE_PORT=9300
LASTLEG_CONSUMER_URL=https://lastleg-service.internal/internal/lastleg/events
EVENT_DISPATCH_FIXED_DELAY=3s
EVENT_DISPATCH_BATCH_SIZE=50
EVENT_DISPATCH_MAX_ATTEMPTS=10
EVENT_DISPATCH_LEASE_DURATION=30s
EVENT_DISPATCH_INITIAL_BACKOFF=5s
EVENT_DISPATCH_MAX_BACKOFF=15m
EVENT_DISPATCH_CONNECT_TIMEOUT=3s
EVENT_DISPATCH_REQUEST_TIMEOUT=10s
# 海外对象存储和外部渠道适配
FILE_STORAGE_PROVIDER=s3
+34
View File
@@ -0,0 +1,34 @@
# Event Bridge Service 模块指引
## 职责边界
- 负责跨区事件接收、Inbox 持久化、向海外尾程投递、失败重试、投递记录和人工补投。
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
## 快速索引
| 功能 | 入口 |
|---|---|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| Inbox 接收与幂等 | `service/EventInboxService.java` |
| 海外调度与重试 | `service/OverseasEventDispatcher.java` |
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| 人工补投 | `service/EventReplayService.java` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
## 可靠投递规则
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
## 修改与验证
- 定向验证:`mvn -pl event-bridge-service -am test`。
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
@@ -1,14 +1,19 @@
package com.baiyue.eventbridge;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 跨区域事件桥启动入口。
*/
@MapperScan("com.baiyue.eventbridge.mapper")
@SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties(EventDispatcherProperties.class)
public class EventBridgeApplication {
public static void main(String[] args) {
SpringApplication.run(EventBridgeApplication.class, args);
@@ -0,0 +1,34 @@
package com.baiyue.eventbridge.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.net.URI;
import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.event-dispatcher")
public record EventDispatcherProperties(
URI consumerUrl,
int batchSize,
int maxAttempts,
Duration leaseDuration,
Duration initialBackoff,
Duration maxBackoff,
Duration connectTimeout,
Duration requestTimeout) {
public EventDispatcherProperties {
consumerUrl = consumerUrl == null
? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl;
batchSize = batchSize <= 0 ? 50 : batchSize;
maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts;
leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30));
initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5));
maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15));
connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3));
requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10));
}
private static Duration positiveOrDefault(Duration value, Duration fallback) {
return value == null || value.isNegative() || value.isZero() ? fallback : value;
}
}
@@ -4,13 +4,15 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.eventbridge.dto.BridgeStatus;
import com.baiyue.eventbridge.dto.EventReceiveResult;
import com.baiyue.eventbridge.dto.EventReplayRequest;
import com.baiyue.eventbridge.service.EventInboxService;
import com.baiyue.eventbridge.service.EventReplayService;
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
/**
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
@@ -25,12 +28,12 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态")
@RestController
@RequestMapping("/internal/event-bridge")
@RequiredArgsConstructor
public class EventBridgeController {
private final EventInboxService eventInboxService;
private final InternalServiceTokenGuard internalServiceTokenGuard;
@Resource private EventInboxService eventInboxService;
@Resource private EventReplayService eventReplayService;
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
@Value("${baiyue.region:CN}")
@Value("${baiyue.region:OVERSEAS}")
private String region;
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
@@ -48,6 +51,18 @@ public class EventBridgeController {
return ApiResponse.success(eventInboxService.receive(envelope));
}
@Operation(summary = "人工补投死信事件")
@PostMapping("/events/{eventId}/replay")
public ApiResponse<Void> replay(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") String operatorId,
@PathVariable String eventId,
@RequestBody EventReplayRequest request) {
internalServiceTokenGuard.verify(token);
eventReplayService.replayDead(eventId, operatorId, request.reason());
return ApiResponse.success(null);
}
@Operation(summary = "查询事件桥能力状态")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
@GetMapping("/status")
@@ -0,0 +1,24 @@
package com.baiyue.eventbridge.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("bridge_delivery_attempt")
public class BridgeDeliveryAttempt {
@TableId(type = IdType.AUTO)
private Long id;
private String eventId;
private String sourceRegion;
private String targetRegion;
private Integer attemptNo;
private String status;
private String responseCode;
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
}
@@ -20,7 +20,7 @@ public class BridgeInbox {
private Integer eventVersion;
private String sourceRegion;
private String targetRegion;
private String tenantId;
private String customerId;
private String aggregateType;
private String aggregateId;
private Long aggregateVersion;
@@ -32,5 +32,11 @@ public class BridgeInbox {
private String payloadHash;
private String payloadJson;
private String status;
private String leaseOwner;
private LocalDateTime leaseUntil;
private Integer retryCount;
private LocalDateTime nextRetryAt;
private String lastError;
private LocalDateTime receivedAt;
private LocalDateTime processedAt;
}
@@ -0,0 +1,4 @@
package com.baiyue.eventbridge.dto;
public record EventReplayRequest(String reason) {
}
@@ -0,0 +1,9 @@
package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BridgeDeliveryAttemptMapper extends BaseMapper<BridgeDeliveryAttempt> {
}
@@ -3,10 +3,62 @@ package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeInbox;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 入站事件持久化映射。
*/
@Mapper
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
@Select("""
SELECT event_id FROM bridge_inbox
WHERE target_region = 'OVERSEAS'
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
ORDER BY received_at LIMIT #{limit}
""")
List<String> dispatchCandidates(@Param("limit") int limit);
@Update("""
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
WHERE event_id=#{eventId} AND target_region='OVERSEAS'
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
""")
int claim(@Param("eventId") String eventId, @Param("owner") String owner,
@Param("leaseSeconds") long leaseSeconds);
@Update("""
UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
""")
int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner);
@Update("""
UPDATE bridge_inbox SET retry_count=retry_count+1,
status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END,
next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL
ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END,
lease_owner=NULL, lease_until=NULL, last_error=#{error}
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
""")
int markFailed(@Param("eventId") String eventId, @Param("owner") String owner,
@Param("maxAttempts") int maxAttempts,
@Param("backoffSeconds") long backoffSeconds, @Param("error") String error);
@Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}")
int nextAttemptNo(@Param("eventId") String eventId);
@Update("""
UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL, lease_until=NULL, last_error=#{reason}
WHERE event_id=#{eventId} AND status='DEAD'
""")
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
}
@@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
@@ -19,16 +20,11 @@ import java.time.LocalDateTime;
*/
@Service
public class EventInboxService {
private final InboxStore inboxStore;
private final ObjectMapper objectMapper;
private final String localRegion;
@Resource private InboxStore inboxStore;
@Resource private ObjectMapper objectMapper;
public EventInboxService(InboxStore inboxStore, ObjectMapper objectMapper,
@Value("${baiyue.region:CN}") String localRegion) {
this.inboxStore = inboxStore;
this.objectMapper = objectMapper;
this.localRegion = localRegion;
}
@Value("${baiyue.region:CN}")
private String localRegion;
@Transactional
public EventReceiveResult receive(EventEnvelope envelope) {
@@ -36,7 +32,9 @@ public class EventInboxService {
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash());
boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash());
if (!currentHash && !legacyHash) {
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
}
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
@@ -71,7 +69,7 @@ public class EventInboxService {
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setTenantId(envelope.tenantId());
inbox.setCustomerId(envelope.tenantId());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
@@ -0,0 +1,27 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class EventReplayService {
@Resource private BridgeInboxMapper inboxMapper;
public void replayDead(String eventId, String operatorId, String reason) {
if (operatorId == null || operatorId.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供操作人");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供原因");
}
String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000);
if (inboxMapper.replayDead(eventId, auditReason) != 1) {
throw new IllegalStateException("事件不存在或未处于 DEAD 状态");
}
}
private static String abbreviate(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
}
@@ -0,0 +1,55 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpLastlegEventClient implements LastlegEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Value("${baiyue.security.internal-service-token:}")
private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
String body = objectMapper.writeValueAsString(envelope);
HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl())
.timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody = objectMapper.readTree(response.body());
if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode());
}
return String.valueOf(response.statusCode());
}
}
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface LastlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import lombok.RequiredArgsConstructor;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/**
* 基于 MyBatis-Plus 的入站幂等存储。
*/
@Repository
@RequiredArgsConstructor
public class MybatisInboxStore implements InboxStore {
private final BridgeInboxMapper mapper;
@Resource private BridgeInboxMapper mapper;
@Override
public BridgeInbox findByEventId(String eventId) {
@@ -0,0 +1,88 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.util.UUID;
@Component
@Slf4j
public class OverseasEventDispatcher {
@Resource private BridgeInboxMapper inboxMapper;
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient;
@Resource private EventDispatcherProperties properties;
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
@Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3s}")
public void dispatch() {
for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) {
dispatchOne(eventId);
}
}
void dispatchOne(String eventId) {
if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) {
return;
}
BridgeInbox event = inboxMapper.selectById(eventId);
int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1;
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
try {
String responseCode = eventClient.deliver(event);
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
throw new IllegalStateException("事件租约已失效,无法确认投递结果");
}
finishAttempt(attempt, "DELIVERED", responseCode, null);
} catch (Exception exception) {
String error = abbreviate(exception.getMessage(), 1000);
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error);
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error);
log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
eventId, retryAttempt, deliveryAttemptNo, error);
}
}
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
attempt.setEventId(event.getEventId());
attempt.setSourceRegion(event.getSourceRegion());
attempt.setTargetRegion(event.getTargetRegion());
attempt.setAttemptNo(attemptNo);
attempt.setStatus("PROCESSING");
attempt.setStartedAt(LocalDateTime.now());
attemptMapper.insert(attempt);
return attempt;
}
private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) {
attempt.setStatus(status);
attempt.setResponseCode(responseCode);
attempt.setErrorMessage(error);
attempt.setFinishedAt(LocalDateTime.now());
attemptMapper.updateById(attempt);
}
private long backoffSeconds(int attemptNo) {
long initial = properties.initialBackoff().toSeconds();
int shift = Math.min(Math.max(attemptNo - 1, 0), 20);
long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift;
return Math.min(delay, properties.maxBackoff().toSeconds());
}
private static String abbreviate(String value, int maxLength) {
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
return safe.length() <= maxLength ? safe : safe.substring(0, maxLength);
}
}
@@ -16,7 +16,7 @@ spring:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${EVENT_BRIDGE_DB_USERNAME:root}
password: ${EVENT_BRIDGE_DB_PASSWORD:}
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
@@ -29,7 +29,17 @@ spring:
namespace: ${NACOS_NAMESPACE:}
baiyue:
region: ${REGION_CODE:CN}
region: ${REGION_CODE:OVERSEAS}
event-dispatcher:
consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识',
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识',
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -8,6 +8,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -23,7 +24,7 @@ import static org.mockito.Mockito.when;
class EventInboxServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final InboxStore store = mock(InboxStore.class);
private final EventInboxService service = new EventInboxService(store, objectMapper, "OVERSEAS");
private final EventInboxService service = service("OVERSEAS");
@Test
void 首次事件落库并返回已接收() {
@@ -34,6 +35,19 @@ class EventInboxServiceTest {
verify(store).insert(any(BridgeInbox.class));
}
@Test
void 数字规范化上线前的历史摘要仍可接收() {
EventEnvelope source = envelope("sha256:placeholder");
source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150);
String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload()));
EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash);
assertThat(service.receive(envelope).result()).isEqualTo("已接收");
}
@Test
void 相同事件和摘要重复接收不再次写入() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
@@ -64,7 +78,7 @@ class EventInboxServiceTest {
@Test
void 目标区域与当前部署区域不一致时拒绝接收() {
EventInboxService cnService = new EventInboxService(store, objectMapper, "CN");
EventInboxService cnService = service("CN");
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
assertThatThrownBy(() -> cnService.receive(envelope))
@@ -79,6 +93,14 @@ class EventInboxServiceTest {
objectMapper.createObjectNode().put("orderId", "90001"), hash);
}
private EventInboxService service(String region) {
EventInboxService service = new EventInboxService();
ReflectionTestUtils.setField(service, "inboxStore", store);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "localRegion", region);
return service;
}
private EventEnvelope withHash(EventEnvelope source) {
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
@@ -0,0 +1,36 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
class EventReplayServiceTest {
private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
private final EventReplayService service = service();
@Test
void requeuesDeadEventWithAuditReason() {
when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1);
service.replayDead("event-1", "operator-7", "incident-42 approved");
verify(mapper).replayDead(eq("event-1"), contains("operator-7"));
}
@Test
void rejectsEventThatIsNotDead() {
when(mapper.replayDead(anyString(), anyString())).thenReturn(0);
assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry"))
.isInstanceOf(IllegalStateException.class);
}
private EventReplayService service() {
EventReplayService service = new EventReplayService();
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
return service;
}
}
@@ -0,0 +1,94 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.net.URI;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OverseasEventDispatcherTest {
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final LastlegEventClient eventClient = mock(LastlegEventClient.class);
private OverseasEventDispatcher dispatcher;
private BridgeInbox event;
@BeforeEach
void setUp() {
EventDispatcherProperties properties = new EventDispatcherProperties(
URI.create("https://lastleg.internal/events"), 50, 3,
Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1),
Duration.ofSeconds(3), Duration.ofSeconds(10));
dispatcher = new OverseasEventDispatcher();
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties);
event = new BridgeInbox();
event.setEventId("event-1");
event.setSourceRegion("CN");
event.setTargetRegion("OVERSEAS");
event.setRetryCount(0);
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
}
@Test
void successfulDeliveryIsConfirmedAndAudited() throws Exception {
when(eventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markProcessed(eq("event-1"), anyString());
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void failedDeliveryIsScheduledForRetry() throws Exception {
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"));
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
}
@Test
void finalFailureIsMovedToDeadState() throws Exception {
event.setRetryCount(2);
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
dispatcher.dispatchOne("event-1");
assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD");
}
@Test
void eventClaimedByAnotherInstanceIsSkipped() {
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0);
dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient);
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
}
private BridgeDeliveryAttempt updatedAttempt() {
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture());
return captor.getValue();
}
}
+30
View File
@@ -0,0 +1,30 @@
# Gateway 模块指引
## 职责边界
- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。
- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。
## 快速索引
| 功能 | 入口 |
|---|---|
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
| Audience 校验 | `config/AudienceJwtValidator.java` |
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
| 安全配置项 | `config/GatewaySecurityProperties.java` |
| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` |
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
| 测试 | `src/test/java/com/baiyue/gateway/config/` |
## 安全规则
- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。
- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。
- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。
## 修改与验证
- 定向验证:`mvn -pl gateway -am test`。
- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。
@@ -0,0 +1,23 @@
package com.baiyue.gateway.config;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
final class AudienceJwtValidator implements OAuth2TokenValidator<Jwt> {
private final String requiredAudience;
AudienceJwtValidator(String requiredAudience) {
this.requiredAudience = requiredAudience;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains(requiredAudience)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token", "JWT audience 不允许访问海外 API", null));
}
}
@@ -7,6 +7,8 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
@@ -48,8 +50,16 @@ public class GatewaySecurityConfig {
@Bean
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withJwkSetUri(properties.jwkSetUri()).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer()));
.withJwkSetUri(properties.jwkSetUri())
.jwsAlgorithms(algorithms -> {
algorithms.add(SignatureAlgorithm.RS256);
algorithms.add(SignatureAlgorithm.ES256);
})
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(properties.issuer()),
new AudienceJwtValidator(properties.audience()),
new RequiredClaimsJwtValidator()));
return decoder;
}
@@ -8,7 +8,10 @@ import java.util.List;
* 海外网关安全配置,只保存验签所需的签发方和公钥集地址。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record GatewaySecurityProperties(String jwkSetUri, String issuer, List<String> permitPaths) {
public record GatewaySecurityProperties(String jwkSetUri,
String issuer,
String audience,
List<String> permitPaths) {
public GatewaySecurityProperties {
if (jwkSetUri == null || jwkSetUri.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI");
@@ -16,6 +19,9 @@ public record GatewaySecurityProperties(String jwkSetUri, String issuer, List<St
if (issuer == null || issuer.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_ISSUER");
}
if (audience == null || audience.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_AUDIENCE");
}
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
}
}
@@ -0,0 +1,26 @@
package com.baiyue.gateway.config;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.List;
/** Validates the identity and authorization claims required by overseas APIs. */
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
private static final List<String> REQUIRED_CLAIMS = List.of(
"sub", "tenant_id", "app_code", "permission_version");
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
for (String claim : REQUIRED_CLAIMS) {
Object value = jwt.getClaims().get(claim);
if (value == null || value.toString().isBlank()) {
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token", "JWT 缺少必要声明: " + claim, null));
}
}
return OAuth2TokenValidatorResult.success();
}
}
@@ -37,6 +37,7 @@ spring:
baiyue:
security:
issuer: ${JWT_ISSUER:https://auth.example.com}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
permit-paths:
- /actuator/health
@@ -0,0 +1,33 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AudienceJwtValidatorTest {
private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api");
@Test
void acceptsTokenForLastlegApi() {
assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse();
}
@Test
void rejectsTokenForAnotherApi() {
assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue();
}
private Jwt jwt(List<String> audiences) {
return Jwt.withTokenValue("token")
.header("alg", "RS256")
.subject("1001")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.audience(audiences)
.build();
}
}
@@ -0,0 +1,36 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class RequiredClaimsJwtValidatorTest {
private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator();
@Test
void acceptsCompleteIdentityClaims() {
assertThat(validator.validate(jwt(true)).hasErrors()).isFalse();
}
@Test
void rejectsMissingPermissionVersion() {
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
}
private Jwt jwt(boolean includePermissionVersion) {
Jwt.Builder builder = Jwt.withTokenValue("token")
.header("alg", "RS256")
.subject("1001")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "2001")
.claim("app_code", "PLATFORM");
if (includePermissionVersion) {
builder.claim("permission_version", 3L);
}
return builder.build();
}
}
@@ -3,14 +3,11 @@ package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.ProviderStatus;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
@Component
public class AmazonLogisticsAdapter implements IntegrationProviderAdapter {
private final IntegrationProperties properties;
public AmazonLogisticsAdapter(IntegrationProperties properties) {
this.properties = properties;
}
@Resource private IntegrationProperties properties;
@Override
public ProviderStatus status() {
@@ -3,14 +3,11 @@ package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.ProviderStatus;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
@Component
public class BaiduOcrAdapter implements IntegrationProviderAdapter {
private final IntegrationProperties properties;
public BaiduOcrAdapter(IntegrationProperties properties) {
this.properties = properties;
}
@Resource private IntegrationProperties properties;
@Override
public ProviderStatus status() {
@@ -3,16 +3,13 @@ package com.baiyue.integration.service;
import com.baiyue.integration.dto.ProviderStatus;
import com.baiyue.integration.provider.IntegrationProviderAdapter;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.util.List;
@Service
public class ProviderStatusService {
private final List<IntegrationProviderAdapter> adapters;
public ProviderStatusService(List<IntegrationProviderAdapter> adapters) {
this.adapters = adapters;
}
@Resource private List<IntegrationProviderAdapter> adapters;
public List<ProviderStatus> providers() {
return adapters.stream().map(IntegrationProviderAdapter::status).toList();
+72
View File
@@ -0,0 +1,72 @@
# Lastleg Service 模块指引
## 职责与边界
- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。
- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。
- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。
## 快速索引
| 功能 | 入口 |
|---|---|
| 事件接收、订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
| 订单查询与更新 | `service/LastlegOrderService.java` |
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| 入仓计划转换/查询 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/service/InboundPlanService.java` |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
## 数据库表索引与注释约束
| 业务域 | 表名 | 中文含义 |
|---|---|---|
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件、尾程订单、明细及状态历史 |
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 |
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
| 采购与卡车 | `ll_overseas_procurement_*`、`ll_us_truck_*` | 海外采购及美国卡车运输 |
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。
- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。
- 表备注、字段备注和索引变更必须可重复执行;存量库的备注修订通过统一增量脚本落地,不依赖基线建表语句。
## 事件建单规则
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
## 查询与租户约束
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
- 派车状态只允许 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`。
## 修改与验证
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。
- 定向验证:`mvn -pl lastleg -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
+1
View File
@@ -5,6 +5,7 @@
<parent><groupId>com.baiyue.lastleg</groupId><artifactId>baiyue-lastleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>lastleg-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId></dependency>
@@ -0,0 +1,113 @@
package com.baiyue.lastleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@RestController
@Tag(name = "Tail-leg orders")
public class LastlegOrderController {
@Resource private LoadingCompletedConsumer consumer;
@Resource private LastlegOrderService orderService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/internal/lastleg/events")
@Operation(summary = "Receive a first-leg loading-completed event")
public ApiResponse<Integer> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
tokenGuard.verify(token);
return ApiResponse.success(consumer.consume(event));
}
@GetMapping("/api/platform/v1/last-leg/orders")
@Operation(summary = "Page tail-leg order rows")
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(scope(user, customerId), query));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@Operation(summary = "Get one inbound-code-level tail-leg order")
public ApiResponse<LastlegOrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id) {
return ApiResponse.success(orderService.detail(scope(user, customerId), id));
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV")
public ResponseEntity<byte[]> export(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(scope(user, customerId), query);
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
.body(body);
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@Operation(summary = "Transfer one order to a destination warehouse")
public ApiResponse<Integer> transferOne(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request.withOrderId(id)));
}
@PutMapping("/api/platform/v1/last-leg/orders/destination")
@Operation(summary = "Transfer multiple orders to a destination warehouse")
public ApiResponse<Integer> transferBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.TransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.delivery;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.delivery.mapper")
public class DeliveryMapperConfiguration {
}
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.delivery.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.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
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.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg")
@Tag(name = "Outbound and delivery")
public class DeliveryTransferController {
@Resource private DeliveryTransferService service;
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@Operation(summary = "Create one pending outbound order and one pending delivery order")
public ApiResponse<DeliveryResponses.TransferResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
}
@GetMapping("/outbound-orders")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.outboundPage(scope(user, customerId), query));
}
@GetMapping("/outbound-orders/{outboundNo}")
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
}
@GetMapping("/delivery-orders")
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(scope(user, customerId), query));
}
@GetMapping("/delivery-orders/{deliveryNo}")
public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer.trim();
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.delivery.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public final class DeliveryRequests {
private DeliveryRequests() {}
public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String documentNo, String warehouseId, String status) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size,
String destinationWarehouseCode) {
public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.delivery.dto;
import com.baiyue.common.core.api.PageResult;
import java.time.LocalDateTime;
public final class DeliveryResponses {
private DeliveryResponses() {}
public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus,
String deliveryNo, String deliveryStatus, int palletCount,
int packageCount, boolean replayed) {}
public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId,
String status, int palletCount, int packageCount, LocalDateTime createdAt) {}
public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo,
String overseasWarehouseId, String status, int palletCount,
int packageCount, LocalDateTime createdAt) {}
public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode,
String orderRefs, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery,
PageResult<PalletRow> pallets) {}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.delivery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Delivery document linked one-to-one to its outbound document and dispatch. */
@Data
@TableName("ll_delivery_order")
public class DeliveryOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String customerId;
private String deliveryNo;
private Long outboundOrderId;
private String outboundNo;
private Long dispatchOrderId;
private String dispatchNo;
private String overseasWarehouseId;
private String status;
private Integer packageCount;
private Integer palletCount;
private String idempotencyKey;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.delivery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Outbound document created when a dispatch is transferred to delivery. */
@Data
@TableName("ll_outbound_order")
public class OutboundOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String customerId;
private String outboundNo;
private Long dispatchOrderId;
private String dispatchNo;
private String overseasWarehouseId;
private String status;
private Integer packageCount;
private Integer palletCount;
private String idempotencyKey;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.delivery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Pallet-level immutable snapshot belonging to one outbound document. */
@Data
@TableName("ll_outbound_pallet_item")
public class OutboundPalletItem {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String customerId;
private Long outboundOrderId;
private Long warehousePalletId;
private String palletNo;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private Integer packageCount;
private String orderRefs;
private String inboundCode;
private LocalDateTime createdAt;
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
@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 customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface OutboundPalletItemMapper extends BaseMapper<OutboundPalletItem> {
@Select("SELECT * FROM ll_outbound_pallet_item WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
List<OutboundPalletItem> findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
}
@@ -0,0 +1,213 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
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.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Service
public class DeliveryTransferService {
@Resource private DispatchOrderMapper dispatchOrders;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPallets;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private InboundPlanPalletAllocationMapper planAllocations;
@Resource private InboundPlanLineMapper planLines;
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Transactional
public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
return result(replay, requireDelivery(customerId, replay.getId()), true);
}
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true);
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
throw conflict("Dispatch cannot be transferred to delivery in its current status");
}
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");
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId());
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND");
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
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 result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
}
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (concurrent != null) {
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
}
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.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now);
outboundPallets.insert(item);
}
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId());
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY");
delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount());
delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
return result(outbound, delivery, false);
}
public PageResult<DeliveryResponses.OutboundRow> outboundPage(String customerId, DeliveryRequests.Query query) {
Page<OutboundOrder> page = outboundOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundOrder>().eq(OutboundOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
.orderByDesc(OutboundOrder::getCreatedAt));
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
}
public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo,
DeliveryRequests.PalletQuery query) {
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null) throw notFound("Outbound order not found");
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
Page<OutboundPalletItem> page = outboundPallets.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getCustomerId, customerId)
.eq(OutboundPalletItem::getOutboundOrderId, outbound.getId())
.eq(StringUtils.hasText(query.destinationWarehouseCode()),
OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode())
.orderByAsc(OutboundPalletItem::getId));
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery),
page(page, page.getRecords().stream().map(this::palletRow).toList()));
}
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
Page<DeliveryOrder> page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DeliveryOrder>().eq(DeliveryOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo())
.eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId())
.eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status())
.orderByDesc(DeliveryOrder::getCreatedAt));
return page(page, page.getRecords().stream().map(this::deliveryRow).toList());
}
public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo,
DeliveryRequests.PalletQuery query) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("Delivery order not found");
OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId());
if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found");
return outboundDetail(customerId, outbound.getOutboundNo(), query);
}
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.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 DeliveryOrder requireDelivery(String customerId, Long outboundId) {
DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId);
if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order");
return value;
}
private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) {
return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(),
d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed);
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); }
private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); }
private static <T> PageResult<T> page(Page<?> page, List<T> records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.discard;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.discard.mapper")
public class DiscardMapperConfiguration {
}
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.discard.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.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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 java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/cargo-discards")
public class CargoDiscardController {
@Resource private CargoDiscardService service;
@GetMapping("/inventory-candidates")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String inboundCode) {
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
}
@PostMapping
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid CargoDiscardRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{discardNo}")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
return ApiResponse.success(service.detail(scope(user, customerId), discardNo));
}
@PostMapping("/{discardNo}/review")
public ApiResponse<CargoDiscardResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo,
@Valid @RequestBody CargoDiscardRequests.Review request) {
requireApprovalPermission(user);
return ApiResponse.success(service.review(scope(user, customerId), discardNo, operator(user), request));
}
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requested == null || requested.isBlank() || "0".equals(requested)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requested.trim();
}
return String.valueOf(user.tenantId());
}
private void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required");
}
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.discard.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class CargoDiscardRequests {
private CargoDiscardRequests() {}
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages) {}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String discardNo, String inboundCode, String overseasWarehouseId,
String status, LocalDateTime createdFrom, LocalDateTime createdTo) {}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.discard.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class CargoDiscardResponses {
private CargoDiscardResponses() {}
public record Candidate(Long orderId, String inboundCode, String inboundOrderNo,
String overseasWarehouseId, String overseasWarehouseName,
LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages) {}
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record Detail(Summary summary, BigDecimal feePerPackage, String remark,
String reviewedBy, String reviewRemark, List<Item> items,
List<History> histories) {}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_application")
public class CargoDiscardApplication {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String discardNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String inboundCodes;
private Integer totalPackages;
private BigDecimal feePerPackage;
private BigDecimal totalFee;
private String currency;
private String status;
private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private String reviewedBy;
private String reviewRemark;
private LocalDateTime reviewedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_carton")
public class CargoDiscardCarton {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private Long itemId;
private String customerId;
private Long cartonId;
private String cartonMark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_item")
public class CargoDiscardItem {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String customerId;
private Long orderId;
private String inboundCode;
private Long profileId;
private Long skuInventoryId;
private String skuCode;
private String productName;
private Integer availablePackagesSnapshot;
private Integer discardPackages;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_status_history")
public class CargoDiscardStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String customerId;
private String fromStatus;
private String toStatus;
private String operatorId;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface CargoDiscardApplicationMapper extends BaseMapper<CargoDiscardApplication> {
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1")
CargoDiscardApplication findNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
CargoDiscardApplication findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE")
CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CargoDiscardCartonMapper extends BaseMapper<CargoDiscardCarton> {
@Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id")
List<CargoDiscardCarton> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.discard.mapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface CargoDiscardInventoryMapper {
@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 " +
"JOIN ll_dropship_order_profile p ON p.customer_id=o.customer_id AND p.order_id=o.id " +
"JOIN ll_dropship_sku_inventory s ON s.customer_id=o.customer_id AND s.order_id=o.id AND s.profile_id=p.id " +
"LEFT 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.sku_code=s.sku_code AND c.status='AVAILABLE' " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
"<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 WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND sku_code=#{skuCode} AND status='AVAILABLE'")
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'")
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("limit") int limit);
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE")
DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId);
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id);
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId,
@Param("orderId") Long orderId,
@Param("profileId") Long profileId,
@Param("skuCode") String skuCode);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") int packages);
@Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}")
Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CargoDiscardItemMapper extends BaseMapper<CargoDiscardItem> {
@Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id")
List<CargoDiscardItem> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CargoDiscardStatusHistoryMapper extends BaseMapper<CargoDiscardStatusHistory> {
@Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id")
List<CargoDiscardStatusHistory> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.discard.model;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CargoDiscardCandidateRow {
private Long orderId;
private String inboundCode;
private String inboundOrderNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private LocalDateTime createdAt;
private Long profileId;
private Long skuInventoryId;
private String skuCode;
private String productName;
private Long availablePackages;
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.discard.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** Final inventory mutation for a warehouse-approved cargo discard. */
@Service
public class CargoDiscardInventoryAdjustmentService {
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@Transactional(propagation = Propagation.MANDATORY)
public AdjustmentResult discard(String customerId, CargoDiscardApplication application,
List<CargoDiscardItem> items,
Map<Long, List<CargoDiscardCarton>> linksByItem,
String operatorId, LocalDateTime occurredAt) {
long beforePackages = 0;
long discardedPackages = 0;
long afterPackages = 0;
for (CargoDiscardItem item : items) {
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode());
long before = value(sku.getPackageCount());
int discarded = item.getDiscardPackages();
if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) {
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
}
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete");
for (CargoDiscardCarton link : links) {
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt));
}
Long persistedAfter = inventory.skuPackages(customerId, sku.getId());
long expectedAfter = before - discarded;
if (persistedAfter == null || persistedAfter != expectedAfter) {
throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode());
}
beforePackages = Math.addExact(beforePackages, before);
discardedPackages = Math.addExact(discardedPackages, discarded);
afterPackages = Math.addExact(afterPackages, persistedAfter);
}
if (beforePackages - discardedPackages != afterPackages
|| discardedPackages != application.getTotalPackages()) {
throw conflict("Cargo discard application package conservation check failed");
}
return new AdjustmentResult(beforePackages, discardedPackages, afterPackages);
}
private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application,
CargoDiscardItem item, DropshipCarton carton,
String operatorId, LocalDateTime occurredAt) {
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
fact.setBusinessOrderNo(application.getDiscardNo());
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark()));
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt);
return fact;
}
private static long value(Long value) { return value == null ? 0L : value; }
private static String remarkSuffix(String value) {
return value == null || value.isBlank() ? "" : "; " + value.trim();
}
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) {
public String auditRemark(String reviewRemark) {
String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; ";
return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages
+ ", inventoryAfter=" + afterPackages;
}
}
}
@@ -0,0 +1,303 @@
package com.baiyue.lastleg.discard.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
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.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class CargoDiscardService {
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper discardCartons;
@Resource private CargoDiscardStatusHistoryMapper histories;
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
private BigDecimal feePerPackage = new BigDecimal("5.00");
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode));
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>();
for (CargoDiscardCandidateRow row : rows) {
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
value.add(row);
}
return grouped.values().stream().map(CandidateAccumulator::response).toList();
}
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
CargoDiscardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
CargoDiscardApplication replay = applications.findKey(customerId, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency key was reused with different cargo discard data");
}
return detail(customerId, replay.getDiscardNo());
}
Set<String> requestedDimensions = new LinkedHashSet<>();
Map<Long, LastlegOrder> orderCache = new HashMap<>();
String warehouseId = null;
String warehouseName = null;
int total = 0;
for (CargoDiscardRequests.Item input : request.items()) {
String dimension = input.orderId() + ":" + input.skuCode().trim();
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
LastlegOrder order = requireDropshipOrder(customerId, input.orderId());
orderCache.put(order.getId(), order);
if (warehouseId == null) {
warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse");
warehouseName = trim(order.getOverseasWarehouseName());
} else if (!warehouseId.equals(order.getOverseasWarehouseId())) {
throw conflict("One cargo discard application cannot span operating warehouses");
}
total = Math.addExact(total, input.discardPackages());
}
LocalDateTime now = LocalDateTime.now();
CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId());
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode)
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
application.setRequestHash(requestHash); application.setCreatedBy(operatorId);
application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application);
for (CargoDiscardRequests.Item input : request.items()) {
reserve(customerId, application, orderCache.get(input.orderId()), input, now);
}
history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now);
return detail(customerId, application.getDiscardNo());
}
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(CargoDiscardApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId,
query.overseasWarehouseId())
.eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status())
.ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(this::summary).toList();
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
}
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages()))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList();
return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(),
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews);
}
@Transactional
public CargoDiscardResponses.Detail review(String customerId, String discardNo, String operatorId,
CargoDiscardRequests.Review request) {
String decision = request.decision().trim().toUpperCase();
String next = switch (decision) {
case "APPROVE", APPROVED -> APPROVED;
case "REJECT", REJECTED -> REJECTED;
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
if (next.equals(application.getStatus())) return detail(customerId, discardNo);
if (!PENDING_REVIEW.equals(application.getStatus())) throw conflict("Cargo discard application was already reviewed");
List<CargoDiscardItem> applicationItems = items.listApplication(customerId, application.getId());
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
String historyRemark = request.remark();
if (APPROVED.equals(next)) {
CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard(
customerId, application, applicationItems, linksByItem, operatorId, now());
historyRemark = result.auditRemark(request.remark());
} else {
release(customerId, links);
}
LocalDateTime reviewedAt = now();
application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
application.setUpdatedAt(reviewedAt); applications.updateById(application);
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
return detail(customerId, discardNo);
}
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
CargoDiscardRequests.Item input, LocalDateTime now) {
String skuCode = input.skuCode().trim();
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) {
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode());
}
List<DropshipCarton> selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request");
Map<Long, List<DropshipCarton>> byProfile = selected.stream()
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
for (Map.Entry<Long, List<DropshipCarton>> entry : byProfile.entrySet()) {
DropshipSkuInventory sku = inventory.lockSkuInventoryDimension(customerId, order.getId(), entry.getKey(), skuCode);
if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo");
CargoDiscardItem item = new CargoDiscardItem();
item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId);
item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey());
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
customerId, order.getId(), entry.getKey(), skuCode)));
item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item);
for (DropshipCarton carton : entry.getValue()) {
if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) {
throw conflict("Cargo was reserved concurrently; retry the discard request");
}
CargoDiscardCarton link = new CargoDiscardCarton();
link.setId(IdWorker.getId()); link.setApplicationId(application.getId()); link.setItemId(item.getId());
link.setCustomerId(customerId); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark());
link.setCreatedAt(now); discardCartons.insert(link);
}
}
}
private void release(String customerId, List<CargoDiscardCarton> links) {
for (CargoDiscardCarton link : links) {
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
throw conflict("Reserved cargo status changed before rejection");
}
}
}
private LastlegOrder requireDropshipOrder(String customerId, Long orderId) {
LastlegOrder order = orders.selectById(orderId);
if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found");
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order");
}
return order;
}
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt());
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
String remark, LocalDateTime createdAt) {
CargoDiscardStatusHistory value = new CargoDiscardStatusHistory();
value.setId(IdWorker.getId()); value.setApplicationId(applicationId); value.setCustomerId(customerId);
value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(operatorId);
value.setRemark(trim(remark)); value.setCreatedAt(createdAt); histories.insert(value);
}
private void validateQuery(CargoDiscardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status");
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
}
private String hash(Object value) {
try {
byte[] json = objectMapper.writeValueAsBytes(value);
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new IllegalStateException("Cannot hash cargo discard request", exception);
}
}
private static LocalDateTime now() { return LocalDateTime.now(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static final class CandidateAccumulator {
private final CargoDiscardCandidateRow first;
private final Map<String, CandidateSkuAccumulator> skus = new LinkedHashMap<>();
private CandidateAccumulator(CargoDiscardCandidateRow first) { this.first = first; }
private void add(CargoDiscardCandidateRow row) {
skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName()))
.packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages();
}
private CargoDiscardResponses.Candidate response() {
List<CargoDiscardResponses.CandidateSku> skuViews = skus.values().stream()
.map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList();
long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum();
return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(),
first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews);
}
}
private static final class CandidateSkuAccumulator {
private final String skuCode;
private final String productName;
private long packages;
private CandidateSkuAccumulator(String skuCode, String productName) { this.skuCode = skuCode; this.productName = productName; }
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.dispatch;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dispatch.mapper")
public class DispatchMapperConfiguration {
}
@@ -0,0 +1,134 @@
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.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.dispatch.service.DispatchService;
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.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String orderNo,
@RequestParam(required = false) String fbaWarehouseCode,
@RequestParam(required = false) String sourceStage) {
return ApiResponse.success(service.candidates(scope(user, customerId), orderNo, fbaWarehouseCode, sourceStage));
}
@PostMapping("/pre-arrange")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets")
public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchRequests.PreArrange request) {
String scopedTenant = scope(user, customerId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request));
}
@GetMapping
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid DispatchRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{dispatchNo}")
@Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo) {
return ApiResponse.success(service.detail(scope(user, customerId), dispatchNo));
}
@PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
public ApiResponse<DispatchResponse> update(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchRequests.Update request) {
return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request));
}
@DeleteMapping("/{dispatchNo}")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo) {
service.cancel(scope(user, customerId), dispatchNo);
return ApiResponse.success(null);
}
@PostMapping("/{dispatchNo}/depart")
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
public ApiResponse<DispatchPdaResponses.Departed> depart(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.Depart request) {
requirePdaOperate(user);
String scopedTenant = scope(user, customerId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
private void requirePdaOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
DispatchPdaController.OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -0,0 +1,35 @@
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.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
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) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus) {
}
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
@Size(max = 255) String route, LocalDateTime departAt,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) {
}
}
@@ -0,0 +1,22 @@
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) {
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 volumeCbm, String sourceStage) {
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** Immutable pallet/cargo snapshot selected into a dispatch order. */
@Data
@TableName("ll_dispatch_item")
public class DispatchItem {
@TableId(type = IdType.INPUT)
private Long id;
private Long dispatchOrderId;
private String customerId;
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
private Long orderId;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String orderNo;
private String orderRefs;
private String inboundCode;
private String palletNo;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String fbaWarehouseCode;
private Integer packageCount;
private BigDecimal volumeCbm;
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
private String sourceStage;
private LocalDateTime createdAt;
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */
@Data
@TableName("ll_dispatch_order")
public class DispatchOrder {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String dispatchNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String vehiclePlate;
private String driverName;
private String driverPhone;
private String carrierName;
private String route;
private LocalDateTime departAt;
private String fbaAppointmentNo;
private String fbaAppointmentStatus;
private String vehicleAppointmentStatus;
private LocalDateTime estimatedArrivalAt;
private String status;
private Integer itemCount;
private Integer palletCount;
private Integer packageCount;
private Integer pickedPackageCount;
private Integer shippedPackageCount;
private String partialCompletionReason;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Prevents a source pallet from being added to more than one active dispatch order. */
@Data
@TableName("ll_dispatch_pallet_lock")
public class DispatchPalletLock {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String sourcePalletKey;
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
private String activeKey;
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
private Long dispatchOrderId;
private String status;
private LocalDateTime lockedAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DispatchCandidateMapper {
@Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} " +
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
"</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
@Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode,
@Param("sourceStage") String sourceStage);
@Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" +
"</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id")
List<DispatchItem> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
@Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " +
"JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " +
"SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " +
"WHERE i.customer_id=#{customerId} AND i.dispatch_order_id=#{dispatchOrderId} " +
"AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL")
int bindReceivedPallets(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("customerId") String customerId,
@Param("sourcePalletKey") String sourcePalletKey);
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.dispatch.model;
import java.math.BigDecimal;
public record DispatchPalletCandidate(
Long planId,
Long planPalletId,
Long warehousePalletId,
Long orderId,
String overseasWarehouseId,
String overseasWarehouseName,
String orderNo,
String orderRefs,
String inboundCode,
String palletNo,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
String fbaWarehouseCode,
Integer packageCount,
BigDecimal volumeCbm,
String sourceStage
) {
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
String orderNo, String orderRefs, String inboundCode, String palletNo,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount,
BigDecimal volumeCbm, String sourceStage) {
this(planId, planPalletId, warehousePalletId, orderId, null, null, orderNo, orderRefs, inboundCode,
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, packageCount, volumeCbm, sourceStage);
}
public String sourcePalletKey() {
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId;
}
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.dispatch.pda;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper")
public class DispatchPdaMapperConfiguration {}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.dispatch.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda")
public class DispatchPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private DispatchPdaWorkflowService service;
@PostMapping("/push")
public ApiResponse<DispatchPdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Push request) {
requireOperate(user);
return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request));
}
@GetMapping
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.task(scope(user, customerId), dispatchNo));
}
@PostMapping("/items/{itemId}/pick")
public ApiResponse<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, @PathVariable long itemId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.Pick request) {
requireOperate(user);
return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request));
}
@PostMapping("/complete")
public ApiResponse<DispatchPdaResponses.PickingCompleted> completePicking(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
requireOperate(user);
return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer.trim();
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
private void requireOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class DispatchPdaRequests {
private DispatchPdaRequests() {}
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName,
String warehouseId) {
public Push(String idempotencyKey, String operatorId, String operatorName) {
this(idempotencyKey, operatorId, operatorName, null);
}
}
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark, String warehouseId) {
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
}
}
public record Depart(@NotBlank String idempotencyKey, String remark) {}
public record CompletePicking(@NotBlank String idempotencyKey,
@NotBlank @Size(max = 1000) String reason) {}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import java.util.List;
public final class DispatchPdaResponses {
private DispatchPdaResponses() {}
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
List<Item> items) {}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages,
long pickedPackages, List<Position> positions) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
boolean palletScrapped, boolean replayed) {}
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
}
public record PickingCompleted(String dispatchNo, String status, long plannedPackages,
long pickedPackages, long remainingPackages, boolean partial,
boolean replayed) {
public PickingCompleted withReplay() {
return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages,
remainingPackages, partial, true);
}
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dispatch.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_dispatch_pda_operation")
public class DispatchPdaOperation {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long taskId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private String resultJson;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dispatch.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_dispatch_pda_pick")
public class DispatchPdaPick {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long taskId;
private Long dispatchItemId;
private Long inventoryPositionId;
private Long packages;
private Long productQuantity;
private String idempotencyKey;
private LocalDateTime pickedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Immutable FIFO attribution of a PDA pick to its inbound receipt and tail-leg order. */
@Data
@TableName("ll_dispatch_pda_pick_order")
public class DispatchPdaPickOrder {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long pickId;
private Long receiptId;
private Long orderId;
private Long packages;
private Long productQuantity;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.dispatch.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_dispatch_pda_task")
public class DispatchPdaTask {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long dispatchOrderId;
private String taskNo;
private String status;
private String operatorId;
private String operatorName;
private String warehouseId;
private LocalDateTime pushedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
import org.apache.ibatis.annotations.*;
@Mapper
public interface DispatchPdaOperationMapper extends BaseMapper<DispatchPdaOperation> {
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface DispatchPdaPickMapper extends BaseMapper<DispatchPdaPick> {
@Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id")
List<DispatchPdaPick> listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}")
long totalItem(@Param("customerId") String customerId, @Param("taskId") Long taskId, @Param("itemId") Long itemId);
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOrder> {
@Select("SELECT r.* FROM ll_inbound_stock_receipt r " +
"WHERE r.customer_id=#{customerId} AND r.inventory_position_id=#{positionId} " +
"AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " +
"WHERE a.customer_id=r.customer_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id")
List<InboundStockReceipt> availableReceipts(@Param("customerId") String customerId,
@Param("positionId") Long positionId);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order " +
"WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}")
long attributedPackages(@Param("customerId") String customerId, @Param("receiptId") Long receiptId);
@Select("SELECT COALESCE(SUM(product_quantity),0) FROM ll_dispatch_pda_pick_order " +
"WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}")
long attributedQuantity(@Param("customerId") String customerId, @Param("receiptId") Long receiptId);
@Select("SELECT * FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND pick_id=#{pickId} ORDER BY id")
List<DispatchPdaPickOrder> listPick(@Param("customerId") String customerId, @Param("pickId") Long pickId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import org.apache.ibatis.annotations.*;
@Mapper
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,277 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class DispatchPdaWorkflowService {
@Resource private DispatchOrderMapper orders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private DispatchItemMapper items;
@Resource private DispatchPdaTaskMapper tasks;
@Resource private DispatchPdaPickMapper picks;
@Resource private DispatchPdaOperationMapper operations;
@Resource private DispatchPalletLockMapper palletLocks;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryCommandService inventory;
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@Transactional
public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Task.class);
items.bindReceivedPallets(customerId, order.getId());
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push");
if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
throw conflict("All pre-arranged pallets must be received before PDA push");
}
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
LocalDateTime now = LocalDateTime.now();
if (task == null) {
task = new DispatchPdaTask();
task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(order.getId());
task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING");
task.setWarehouseId(warehouseId);
task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName()));
task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task);
}
if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); }
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse");
DispatchPdaResponses.Task result = task(customerId, order, task, dispatchItems);
save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result);
return result;
}
@Transactional(readOnly = true)
public DispatchPdaResponses.Task task(String customerId, String dispatchNo) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId());
if (task == null) throw notFound("Dispatch PDA task not found");
return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId()));
}
@Transactional
public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId,
String operatorId, DispatchPdaRequests.Pick request) {
DispatchOrder order = requireOrder(customerId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
String warehouseId = warehouse(request.warehouseId());
requireDispatchWarehouse(order, warehouseId);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for picking");
}
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse");
DispatchItem item = items.selectById(itemId);
if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) {
throw notFound("Dispatch pallet item not found");
}
if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse");
DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Picked.class);
InventoryPosition position = positions.selectById(request.positionId());
if (position == null || !customerId.equals(position.getCustomerId())
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) {
throw conflict("Inventory position does not belong to this dispatch pallet");
}
long picked = picks.totalItem(customerId, task.getId(), itemId);
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey();
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark()));
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId());
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
pickAttribution.attribute(customerId, pick);
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) {
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART");
}
order.setUpdatedAt(LocalDateTime.now());
orders.updateById(order);
tasks.updateById(task);
DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false);
save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo,
DispatchPdaRequests.CompletePicking request) {
DispatchOrder found = requireOrder(customerId, dispatchNo);
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for completion");
}
List<DispatchItem> source = items.findByDispatchOrder(customerId, order.getId());
long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count");
boolean partial = picked < planned;
LocalDateTime now = LocalDateTime.now();
task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task);
order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART");
order.setPartialCompletionReason(partial ? request.reason().trim() : null);
order.setPickedPackageCount((int) picked);
order.setUpdatedAt(now); orders.updateById(order);
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(),
order.getStatus(), planned, picked, planned - picked, partial, false);
save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
return result;
}
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
@Transactional
public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId,
DispatchPdaRequests.Depart request) {
DispatchOrder found = requireOrder(customerId, dispatchNo);
DispatchOrder order = orders.lockById(customerId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
requireVehicle(order);
OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId());
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
throw conflict("Dispatch must be transferred to a pending outbound order before departure");
}
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !"COMPLETED".equals(task.getStatus())
|| !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) {
throw conflict("Dispatch must complete PDA picking before departure");
}
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus());
if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) {
throw conflict("Every dispatch pallet must be fully picked before departure");
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
long shipped = 0;
for (DispatchPdaPick pick : dispatchPicks) {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
pick.getInventoryPositionId(), n(pick.getPackages()),
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
}
LocalDateTime now = LocalDateTime.now();
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
outbound.setStatus("OUTBOUNDED");
outbound.setUpdatedAt(now);
outboundOrders.updateById(outbound);
order.setShippedPackageCount((int) shipped);
order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED");
order.setUpdatedAt(now);
orders.updateById(order);
releasePalletLocks(customerId, order.getId(), now);
DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(),
order.getStatus(), shipped, false);
save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
return result;
}
private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source) {
List<DispatchPdaResponses.Item> values = new ArrayList<>();
for (DispatchItem item : source) {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
}
return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getWarehouseId(), task.getStatus(), values);
}
private boolean allPicked(String customerId, Long taskId, List<DispatchItem> source) {
return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount());
}
private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) {
List<DispatchPalletLock> activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId)
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
palletLocks.updateById(lock);
}
}
private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; }
private void requirePdaStatus(DispatchOrder order) {
if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus())
|| "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA");
}
private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); }
private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); }
private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) {
if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) {
throw conflict("Selected warehouse does not match dispatch warehouse");
}
}
private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; }
private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); }
private <T> T read(DispatchPdaOperation op, Class<T> type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static long n(Long value) { return value == null ? 0 : value; }
private static long n(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}
@@ -0,0 +1,105 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class DispatchPickOrderAttributionService {
@Resource private DispatchPdaPickOrderMapper attributions;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper history;
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick) {
long remainingPackages = value(pick.getPackages());
long totalPackages = remainingPackages;
long totalQuantity = value(pick.getProductQuantity());
long allocatedPackages = 0;
long allocatedQuantity = 0;
Map<Long, Long> pickedByOrder = new LinkedHashMap<>();
for (InboundStockReceipt receipt : attributions.availableReceipts(customerId,
pick.getInventoryPositionId())) {
if (remainingPackages == 0) break;
long receiptPackages = value(receipt.getReceivedPackages())
- attributions.attributedPackages(customerId, receipt.getId());
if (receiptPackages <= 0) continue;
long packages = Math.min(remainingPackages, receiptPackages);
long nextPackages = allocatedPackages + packages;
long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages);
long quantity = nextQuantity - allocatedQuantity;
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
row.setId(IdWorker.getId());
row.setCustomerId(customerId);
row.setPickId(pick.getId());
row.setReceiptId(receipt.getId());
row.setOrderId(receipt.getOrderId());
row.setPackages(packages);
row.setProductQuantity(quantity);
row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt());
attributions.insert(row);
pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum);
remainingPackages -= packages;
allocatedPackages = nextPackages;
allocatedQuantity = nextQuantity;
}
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) {
throw conflict("Picked packages exceed tail-leg order remaining packages");
}
}
return pickedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList();
}
/** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */
public List<OrderPackages> depart(String customerId, String outboundNo, String dispatchNo, List<DispatchPdaPick> picks,
LocalDateTime departedAt) {
Map<Long, Long> shippedByOrder = new LinkedHashMap<>();
for (DispatchPdaPick pick : picks) {
for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) {
shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum);
}
}
for (Map.Entry<Long, Long> entry : shippedByOrder.entrySet()) {
LastlegOrder before = orders.lockById(customerId, entry.getKey());
if (before == null || orders.transferPickedToShipped(customerId, entry.getKey(), entry.getValue(), outboundNo) != 1) {
throw conflict("Tail-leg order picked packages are inconsistent");
}
long shipped = value(before.getShippedPackageCount()) + entry.getValue();
String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED";
LastlegOrderStatusHistory row = new LastlegOrderStatusHistory();
row.setOrderId(entry.getKey());
row.setFromStatus(before.getCurrentStatus());
row.setToStatus(to);
row.setEventId("DISPATCH_DEPART:" + dispatchNo);
row.setRemark("Dispatch departed " + entry.getValue() + " packages; cumulative shipped "
+ shipped + "/" + value(before.getPackageCount()));
row.setCreatedAt(departedAt);
history.insert(row);
}
return shippedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList();
}
public record OrderPackages(Long orderId, long packages) {}
private static long value(Long value) { return value == null ? 0 : value; }
private static long value(Integer value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
}
@@ -0,0 +1,261 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class DispatchService {
public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE";
public static final String CANCELLED = "CANCELLED";
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchCandidateMapper candidates;
public List<DispatchPalletCandidate> candidates(String customerId, String orderNo,
String fbaWarehouseCode, String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
}
return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage));
}
@Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
List<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList();
if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty");
}
List<DispatchPalletCandidate> selected = candidates.lockSelected(customerId, palletIds);
if (selected.size() != palletIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
}
if (selected.stream().anyMatch(candidate -> candidate.packageCount() == null || candidate.packageCount() <= 0)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Selected pallet has no remaining packages to dispatch");
}
Set<String> warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet());
if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse");
}
for (DispatchPalletCandidate candidate : selected) {
boolean planLocked = candidate.planPalletId() != null
&& locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()) != null;
boolean receivedLocked = candidate.warehousePalletId() != null
&& locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId()) != null;
if (planLocked || receivedLocked) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo());
}
}
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setCustomerId(customerId);
dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId());
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected));
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setStatus(PENDING_DEPARTURE);
dispatch.setItemCount(selected.size());
dispatch.setPalletCount(selected.size());
dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
dispatch.setPickedPackageCount(0);
dispatch.setShippedPackageCount(0);
dispatch.setCreatedBy(trimToNull(operatorId));
dispatch.setCreatedAt(now);
dispatch.setUpdatedAt(now);
orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock lock = new DispatchPalletLock();
lock.setId(IdWorker.getId());
lock.setCustomerId(customerId);
lock.setSourcePalletKey(candidate.sourcePalletKey());
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
lock.setPlanPalletId(candidate.planPalletId());
lock.setWarehousePalletId(candidate.warehousePalletId());
lock.setDispatchOrderId(dispatch.getId());
lock.setStatus("ACTIVE");
lock.setLockedAt(now);
lock.setCreatedAt(now);
lock.setUpdatedAt(now);
try {
locks.insert(lock);
} catch (DuplicateKeyException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
}
DispatchItem item = new DispatchItem();
item.setId(IdWorker.getId());
item.setDispatchOrderId(dispatch.getId());
item.setCustomerId(customerId);
item.setPlanId(candidate.planId());
item.setPlanPalletId(candidate.planPalletId());
item.setWarehousePalletId(candidate.warehousePalletId());
item.setOrderId(candidate.orderId());
item.setOverseasWarehouseId(candidate.overseasWarehouseId());
item.setOverseasWarehouseName(candidate.overseasWarehouseName());
item.setOrderNo(candidate.orderNo());
item.setOrderRefs(candidate.orderRefs());
item.setInboundCode(candidate.inboundCode());
item.setPalletNo(candidate.palletNo());
item.setDestinationWarehouseId(candidate.destinationWarehouseId());
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
item.setDestinationWarehouseName(candidate.destinationWarehouseName());
item.setFbaWarehouseCode(candidate.fbaWarehouseCode());
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
item.setSourceStage(candidate.sourceStage());
item.setCreatedAt(now);
items.insert(item);
}
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
.like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo,
query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt));
List<DispatchResponse> records = page.getRecords().stream()
.map(order -> response(order, items.findByDispatchOrder(customerId, order.getId()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public DispatchResponse detail(String customerId, String dispatchNo) {
DispatchOrder dispatch = find(customerId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
// Vehicle plate is intentionally editable through all dispatch states.
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
dispatch.setCarrierName(trimToNull(request.carrierName()));
dispatch.setRoute(trimToNull(request.route()));
dispatch.setDepartAt(request.departAt());
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
if (StringUtils.hasText(request.status())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Dispatch status is PDA-managed and cannot be changed by the edit endpoint");
}
dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
public void cancel(String customerId, String dispatchNo) {
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled");
}
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
locks.updateById(lock);
}
}
private DispatchOrder find(String customerId, String dispatchNo) {
DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
return dispatch;
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(),
order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(),
order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(),
order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0),
Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0),
Objects.requireNonNullElse(order.getPickedPackageCount(), 0),
Objects.requireNonNullElse(order.getShippedPackageCount(), 0),
Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0)
- Objects.requireNonNullElse(order.getShippedPackageCount(), 0)), order.getPartialCompletionReason(),
order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(),
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList());
}
private static String route(List<DispatchPalletCandidate> selected) {
return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText)
.distinct().collect(Collectors.joining(" -> "));
}
private static List<String> splitOrderRefs(String refs, String fallback) {
String value = StringUtils.hasText(refs) ? refs : fallback;
return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of();
}
private static String defaultStatus(String status) {
return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING";
}
private static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.dropship;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dropship.mapper")
public class DropshipMapperConfiguration {
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dropship.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/internal/v1/lastleg/dropship")
public class DropshipOrderController {
@Resource private DropshipOrderService service;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/receive")
public ApiResponse<Integer> receive(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody DropshipRequests.Receive request) {
tokenGuard.verify(token);
return ApiResponse.success(service.receive(request));
}
}

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