Merge branch 'dev' into dev-lyhfive

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
2026-08-25 15:06:11 +08:00
568 changed files with 26662 additions and 2081 deletions
+7 -4
View File
@@ -12,7 +12,7 @@
|---|---|
| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端按冻结字段校验后建单 |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
| 订单查询与更新 | `service/LastlegOrderService.java` |
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
@@ -22,10 +22,12 @@
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API |
| PDA 卸柜照片文件上传与归属校验 | `InboundPdaController.java` 的 `/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 |
| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
@@ -73,6 +75,7 @@
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。
- 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。
## 查询与租户约束
@@ -0,0 +1,199 @@
# Lastleg 内部模块化依赖基线与首切片方案
## 1. 范围与生成方式
本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。
可重复命令:
```powershell
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph package
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format dot --graph package
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format dot --graph package
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice
```
分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。
## 2. 当前规模与 SCC
| 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 |
|---|---:|---:|---:|---:|---:|---:|
| main | 347 | 672 | 157 | 137 | 60 | 38 |
| test only | 98 | 0 | 67 | 0 | 0 | 0 |
| main + test | 445 | 1,195 | 161 | 256 | 108 | 76 |
主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC:
```text
dropship
inbound-pda
inventory
tailleg-handoff
```
合并测试源码后,出现一个由测试直接绑定横向实现包形成的物理包 SCC:
```text
com.baiyue.lastleg.looseboard.service
com.baiyue.lastleg.relabel.service
com.baiyue.lastleg.service
com.baiyue.lastleg.transfer.service
```
这不是生产运行时包环。合并图的逻辑 SCC 扩大为 `dispatch-delivery`、`dropship`、`inbound-pda`、`inventory`、`procurement-trucking-looseboard`、`relabel-transfer-discard`、`tailleg-handoff` 七个切片,原因是跨域验收测试引用多个业务实现包。
## 3. 逻辑切片映射
| 逻辑切片 | 当前识别范围 | main 类数 | 跨切片出/入类边 | 当前结论 |
|---|---|---:|---:|---|
| 尾程交接 `tailleg-handoff` | 根 `controller/dto/entity/mapper/model/repository/service` 与 `shipping/` | 33 | 12/48 | 核心尾程单、事件 Inbox/Outbox 和船节点门禁被入仓、库存、换标等直接引用,位于主 SCC |
| 入仓/PDA `inbound-pda` | `inboundplan/`、`palletops/`、根 `pda/` | 100 | 53/14 | 最大切片;与尾程核心、库存和一件代发双向耦合,含大量库存 Entity/Mapper 泄漏 |
| 库存 `inventory` | `inventory/` | 26 | 3/37 | 自身出向较少,但作为多个作业域共享的 Entity/Mapper 实现被直接消费,位于主 SCC |
| 排车/派车 `dispatch-delivery` | `dispatch/`、`delivery/`、未来 `outbound/` | 46 | 20/0 | 主源码无入向 Java 类型边,但涉及明确排除的出库/派送范围,不作为首切片 |
| 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 |
| 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC |
| 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 |
| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 |
另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。
## 4. 主源码逻辑切片边
| 来源 | 目标 | 类边数 |
|---|---|---:|
| dispatch-delivery | inbound-pda | 6 |
| dispatch-delivery | inventory | 4 |
| dispatch-delivery | shared-technical | 2 |
| dispatch-delivery | tailleg-handoff | 8 |
| dropship | inbound-pda | 1 |
| dropship | shared-technical | 1 |
| dropship | tailleg-handoff | 2 |
| inbound-pda | dropship | 1 |
| inbound-pda | inventory | 25 |
| inbound-pda | shared-technical | 8 |
| inbound-pda | tailleg-handoff | 19 |
| inventory | inbound-pda | 1 |
| inventory | shared-technical | 2 |
| overseas-master-data | shared-technical | 3 |
| procurement-trucking-looseboard | overseas-master-data | 1 |
| procurement-trucking-looseboard | shared-technical | 5 |
| procurement-trucking-looseboard | tailleg-handoff | 5 |
| relabel-transfer-discard | dropship | 8 |
| relabel-transfer-discard | inventory | 6 |
| relabel-transfer-discard | shared-technical | 3 |
| relabel-transfer-discard | tailleg-handoff | 14 |
| tailleg-handoff | dropship | 2 |
| tailleg-handoff | inbound-pda | 6 |
| tailleg-handoff | inventory | 2 |
| tailleg-handoff | shared-technical | 2 |
当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向。
## 5. Entity/DTO/Mapper 泄漏基线
主源码共有 60 条跨切片 Entity/DTO/Model 泄漏、38 条跨切片 Mapper 泄漏。高频边如下:
| 来源 → 目标 | 模型 | Mapper | 代表性证据 |
|---|---:|---:|---|
| inbound-pda → inventory | 15 | 9 | 托盘、移库、盘点 Service/Mapper 直接使用 `InventoryBalance`、`InventoryPosition`、`InventoryLedger` 及其 Mapper |
| inbound-pda → tailleg-handoff | 9 | 9 | 入仓实收、卸柜完成直接使用根 `LastlegOrder*`、事件 Outbox Entity/Mapper |
| relabel-transfer-discard → dropship | 7 | 1 | 废弃直接读取一件代发箱/SKU 库存 Entity,调整服务写箱流水 Mapper |
| relabel-transfer-discard → tailleg-handoff | 6 | 6 | 换标、转运、废弃直接使用根尾程订单、明细、状态历史 Entity/Mapper |
| dispatch-delivery → tailleg-handoff | 4 | 4 | 拣货归属和派送转移直接使用根尾程订单与状态 Mapper;该范围不进入本专项首切片 |
| dispatch-delivery → inbound-pda | 4 | 2 | 派送转移直接使用入仓计划行、托盘分配 Entity/Mapper;该范围不进入本专项首切片 |
| relabel-transfer-discard → inventory | 4 | 1 | 转运直接使用库存余额/库位模型及库存命令 Mapper |
当前泄漏体现的是业务核心对其他切片持久化实现和表模型的直接依赖。后续治理应以应用 Port 和切片自有投影替换,不共享领域 Entity;本轮不做替换。
## 6. 首切片选择:车辆档案
选择 `masterdata.vehicle` 作为首个低耦合切片候选,依据当前主源码图:
1. 只有 5 个生产类,已经集中在单一子域;现有测试 3 个。
2. 零跨切片入向类依赖;唯一出向类依赖为 `PlatformVehicleController -> OverseasPermissions`。
3. 海外主数据不在 main 或 main+test 的逻辑 SCC 内,车辆子域也没有 Entity/DTO/Mapper 跨切片泄漏。
4. 不包含跨服务 Client、RestClient 或 HTTP Gateway,不受同步依赖超时专项影响。
5. 车辆档案明确不保存派送履约历史;首切片不触碰 `dispatch/`、`delivery/`、出库或派送代码。
6. 当前业务面固定为平台车辆档案列表、详情、创建、编辑、状态和 CSV 导出,不需要补齐四期业务缺口。
报价子域虽然也不在 SCC,但被采购服务通过 `PartnerWarehouseDirectory` 入向依赖,并包含 `DomesticPartnerWarehouseClient`,因此避让。月度汇率有 8 个生产类,也比车辆档案更大。
## 7. 已实施目标目录与精确文件
首切片已按下列结构实施:
```text
masterdata/vehicle/
adapter/in/web/
PlatformVehicleController.java
application/
VehicleApplicationService.java
model/VehicleModels.java
port/out/VehicleRepository.java
adapter/out/persistence/
VehiclePersistenceAdapter.java
VehicleMapper.java
VehicleRecord.java
```
精确动作草案:
| 原文件 | 已实施动作 |
|---|---|
| `masterdata/vehicle/controller/PlatformVehicleController.java` | 移至 `adapter/in/web/`;URI、权限、HTTP/CSV 响应不变 |
| `masterdata/vehicle/dto/VehicleDtos.java` | 移至 `application/model/VehicleModels.java`;JSON 字段、校验和响应语义不变 |
| `masterdata/vehicle/service/VehicleService.java` | 移至 `application/VehicleApplicationService.java`;业务校验、乐观锁和状态语义不变,只依赖 Out Port |
| 新增 `application/port/out/VehicleRepository.java` | 定义分页、详情、锁定、创建、乐观锁更新、状态更新和导出查询所需的切片内投影;不暴露 MyBatis 类型 |
| `masterdata/vehicle/entity/Vehicle.java` | 移至 `adapter/out/persistence/VehicleRecord.java`;仅作为 `ll_vehicle` 持久化记录 |
| `masterdata/vehicle/mapper/VehicleMapper.java` | 移至 `adapter/out/persistence/VehicleMapper.java`;保持现有 SQL 与 MyBatis 语义 |
| 新增 `adapter/out/persistence/VehiclePersistenceAdapter.java` | 实现 `VehicleRepository`,隔离 MyBatis `Page`、Wrapper、Mapper 和 Record |
允许依赖方向:
```text
adapter.in.web -> application -> application.model + application.port.out
adapter.out.persistence -> application.port.out + application.model
```
Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermissions`。`application` 禁止依赖 Spring MVC、MyBatis/MyBatis-Plus、全局 Mapper/Entity、RestClient、其他业务切片或出库/派送类型。
## 8. 测试与回滚边界
本次迁移只修改车辆子域 5 个原生产文件、新增 2 个车辆子域文件,并定向更新:
- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java`:改为 Mock `VehicleRepository`,继续锁定公里码、乐观锁和状态规则。
- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java`:继续锁定 URI 与六项权限。
- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java`:继续锁定 `ll_vehicle` SQL、唯一索引和字段注释。
- `scripts/analyze-lastleg-dependencies.test.mjs`:已锁定目标 7 类与 application 禁止依赖规则。
回滚只涉及上述车辆 Java 包移动、两个新增抽象及对应测试引用。不得修改或回滚共享 POM、`AGENTS.md`、RestClient 配置、数据库 SQL、OpenAPI、权限 SQL、接口路径、字段、状态、CSV 格式、Nacos、部署或其他业务域。若 Mapper 扫描或事务代理合同不满足,整体恢复车辆包内变更,不通过扩大全局扫描、兼容 Bean 或跨切片共享 Entity 绕过。
## 9. 当前只读合同
- `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。
- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 25 条 main 切片边;历史边可减少,不可增加。
- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。
最小验证:
```powershell
node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown
```
## 10. 首切片实施结果
- 旧 5 类及旧测试包已删除,没有转发兼容层;目标 7 类已由 Node 合同锁定。
- `VehicleApplicationService` 只依赖 `VehicleModels` 与 `VehicleRepository`,不依赖 MyBatis/MyBatis-Plus、Spring MVC、RestClient、全局 Mapper/Entity 或其他 Lastleg 切片。
- `VehiclePersistenceAdapter` 独占 MyBatis `Page`、Wrapper、Mapper 和 `VehicleRecord`,只向 Port 返回 Java 中立投影。
- 车辆切片保持零入向依赖,唯一出向仍是 Web Adapter 使用 `OverseasPermissions`;全局 25 条切片边、60 条模型泄漏、38 条 Mapper 泄漏及主 SCC 均未增加。
- 接口路径、六项权限、JSON 字段、校验、乐观锁、状态、CSV、SQL、表和索引保持不变。
- 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。
首切片完成后停止,不进入第二切片。
@@ -0,0 +1,326 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const moduleRoot = path.resolve(import.meta.dirname, '..');
const sourceRoots = {
main: path.join(moduleRoot, 'src', 'main', 'java'),
test: path.join(moduleRoot, 'src', 'test', 'java'),
};
const internalPrefix = 'com.baiyue.lastleg.';
const sliceRules = [
['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/],
['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/],
['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/],
['inventory', /(?:^|\.)inventory(?:\.|$)/],
['dropship', /(?:^|\.)dropship(?:\.|$)/],
['procurement-trucking-looseboard', /(?:^|\.)(?:procurement|trucking|looseboard)(?:\.|$)/],
['overseas-master-data', /(?:^|\.)masterdata(?:\.|$)/],
['tailleg-handoff', /(?:^|\.)(?:controller|dto|entity|mapper|model|repository|service|shipping)(?:\.|$)/],
];
function parseArgs(argv) {
const options = { sourceSet: 'main', format: 'json', graph: 'slice', output: null, check: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--source-set') options.sourceSet = argv[++index];
else if (argument === '--format') options.format = argv[++index];
else if (argument === '--graph') options.graph = argv[++index];
else if (argument === '--output') options.output = argv[++index];
else if (argument === '--check') options.check = argv[++index];
else if (argument === '--help') {
process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--graph slice|package] [--output FILE] [--check CONTRACT.json]\n');
process.exit(0);
} else throw new Error(`Unknown argument: ${argument}`);
}
if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`);
if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`);
if (!['slice', 'package'].includes(options.graph)) throw new Error(`Invalid graph: ${options.graph}`);
return options;
}
function walkJavaFiles(root) {
if (!fs.existsSync(root)) return [];
const result = [];
const pending = [root];
while (pending.length > 0) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) pending.push(absolute);
else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute);
}
}
return result.sort();
}
function stripCommentsAndStrings(source) {
return source
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/\/\/[^\r\n]*/g, ' ')
.replace(/"(?:\\.|[^"\\])*"/g, '""')
.replace(/'(?:\\.|[^'\\])*'/g, "''");
}
function classifySlice(fqcn) {
for (const [slice, expression] of sliceRules) {
if (expression.test(fqcn)) return slice;
}
return 'shared-technical';
}
function classifyKind(fqcn) {
const packageName = fqcn.slice(0, fqcn.lastIndexOf('.'));
if (/(?:^|\.)(?:client|gateway)(?:\.|$)/.test(packageName)) return 'cross-service-adapter';
if (/(?:^|\.)(?:adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
if (/(?:^|\.)(?:entity|dto|model)(?:\.|$)/.test(packageName)) return 'data-model';
return 'business-application';
}
function parseSource(file, sourceSet) {
const raw = fs.readFileSync(file, 'utf8');
const source = stripCommentsAndStrings(raw);
const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1];
const typeName = path.basename(file, '.java');
if (!packageName) throw new Error(`Missing package declaration: ${file}`);
const fqcn = `${packageName}.${typeName}`;
const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]);
return {
file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'),
sourceSet,
packageName,
typeName,
fqcn,
imports,
source,
slice: classifySlice(fqcn),
kind: classifyKind(fqcn),
};
}
function collectSources(sourceSet) {
const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet];
return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set)));
}
function resolveDependencies(sources) {
const knownTypes = new Map(sources.map(source => [source.fqcn, source]));
const typesByPackage = new Map();
for (const source of sources) {
if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []);
typesByPackage.get(source.packageName).push(source);
}
const dependencies = [];
const seen = new Set();
for (const source of sources) {
const candidates = new Set();
for (const imported of source.imports) {
if (knownTypes.has(imported)) candidates.add(imported);
else if (imported.startsWith(internalPrefix)) {
const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`));
if (owner) candidates.add(owner);
}
}
for (const samePackageType of typesByPackage.get(source.packageName) ?? []) {
if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) {
candidates.add(samePackageType.fqcn);
}
}
for (const targetFqcn of candidates) {
if (targetFqcn === source.fqcn) continue;
const key = `${source.fqcn}->${targetFqcn}`;
if (seen.has(key)) continue;
seen.add(key);
dependencies.push({ source: source.fqcn, target: targetFqcn });
}
}
return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
}
function aggregateEdges(dependencies, nodeOf) {
const counts = new Map();
for (const dependency of dependencies) {
const source = nodeOf(dependency.source);
const target = nodeOf(dependency.target);
if (source === target) continue;
const key = `${source}\0${target}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return [...counts].map(([key, count]) => {
const [source, target] = key.split('\0');
return { source, target, count };
}).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
}
function stronglyConnectedComponents(nodes, edges) {
const adjacency = new Map(nodes.map(node => [node, []]));
for (const edge of edges) adjacency.get(edge.source)?.push(edge.target);
const indexByNode = new Map();
const lowLink = new Map();
const stack = [];
const onStack = new Set();
const components = [];
let nextIndex = 0;
function visit(node) {
indexByNode.set(node, nextIndex);
lowLink.set(node, nextIndex);
nextIndex += 1;
stack.push(node);
onStack.add(node);
for (const target of adjacency.get(node) ?? []) {
if (!indexByNode.has(target)) {
visit(target);
lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target)));
} else if (onStack.has(target)) {
lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target)));
}
}
if (lowLink.get(node) === indexByNode.get(node)) {
const component = [];
let current;
do {
current = stack.pop();
onStack.delete(current);
component.push(current);
} while (current !== node);
components.push(component.sort());
}
}
for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node);
return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
}
function buildReport(sourceSet) {
const sources = collectSources(sourceSet);
const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source]));
const dependencies = resolveDependencies(sources);
const packages = [...new Set(sources.map(source => source.packageName))].sort();
const slices = [...new Set(sources.map(source => source.slice))].sort();
const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName);
const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice);
const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice);
const modelLeaks = directCrossSliceDependencies.filter(edge => sourceByFqcn.get(edge.target).kind === 'data-model');
const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName));
return {
schemaVersion: 1,
generatedFrom: sourceSet,
moduleRoot: 'lastleg-service',
summary: {
javaFiles: sources.length,
internalClassDependencies: dependencies.length,
packages: packages.length,
slices: slices.length,
crossSliceClassDependencies: directCrossSliceDependencies.length,
crossSliceModelLeaks: modelLeaks.length,
crossSliceMapperLeaks: mapperLeaks.length,
},
sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])),
classes: sources.map(({ source, ...entry }) => entry),
classDependencies: dependencies,
packageEdges,
sliceEdges,
packageSccs: stronglyConnectedComponents(packages, packageEdges),
sliceSccs: stronglyConnectedComponents(slices, sliceEdges),
directCrossSliceDependencies,
modelLeaks,
mapperLeaks,
};
}
function renderMarkdown(report) {
const leaks = [...report.modelLeaks, ...report.mapperLeaks]
.filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index);
return [
'# Lastleg internal dependency report', '',
`- Source set: ${report.generatedFrom}`,
`- Java files: ${report.summary.javaFiles}`,
`- Internal class dependencies: ${report.summary.internalClassDependencies}`,
`- Packages: ${report.summary.packages}`,
`- Cross-slice class dependencies: ${report.summary.crossSliceClassDependencies}`,
`- Cross-slice model leaks: ${report.summary.crossSliceModelLeaks}`,
`- Cross-slice mapper leaks: ${report.summary.crossSliceMapperLeaks}`,
`- Package SCCs (>1 node): ${report.packageSccs.length}`,
`- Slice SCCs (>1 node): ${report.sliceSccs.length}`,
'', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|',
...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`),
'', '## Slice SCCs', '',
...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
'', '## Package SCCs', '',
...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
'', '## Cross-slice model and mapper leaks', '',
...(leaks.length ? leaks.map(edge => `- \`${edge.source}\` -> \`${edge.target}\``) : ['- None']),
'',
].join('\n');
}
function renderDot(report, graph) {
const edges = graph === 'package' ? report.packageEdges : report.sliceEdges;
const nodes = graph === 'package'
? [...new Set(report.classes.map(entry => entry.packageName))].sort()
: [...new Set(report.classes.map(entry => entry.slice))].sort();
const lines = [`digraph lastleg_${graph}s {`, ' rankdir=LR;'];
for (const node of nodes) lines.push(` "${node}";`);
for (const edge of edges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`);
lines.push('}', '');
return lines.join('\n');
}
function checkContract(report, contractPath) {
const absolute = path.resolve(process.cwd(), contractPath);
const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
const allowed = new Set(contract.allowedSliceEdges ?? []);
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`);
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
const actualClass = classesByName.get(fqcn);
if (!actualClass) violations.push(`required class missing: ${fqcn}`);
else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`);
}
const applicationRule = contract.applicationDependencyRule;
if (applicationRule) {
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix));
for (const entry of applicationClasses) {
for (const imported of entry.imports) {
if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) {
violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`);
}
}
}
for (const edge of report.classDependencies) {
if (edge.source.startsWith(applicationRule.packagePrefix)
&& edge.target.startsWith(internalPrefix)
&& !edge.target.startsWith(applicationRule.allowedInternalPrefix)) {
violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`);
}
}
}
for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) {
const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort();
const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort();
if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) {
violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`);
}
if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) {
violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`);
}
}
if (violations.length > 0) {
process.stderr.write(`Lastleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
process.exitCode = 1;
}
}
const options = parseArgs(process.argv.slice(2));
const report = buildReport(options.sourceSet);
if (options.check) checkContract(report, options.check);
const rendered = options.format === 'json'
? `${JSON.stringify(report, null, 2)}\n`
: options.format === 'markdown'
? renderMarkdown(report)
: renderDot(report, options.graph);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import test from 'node:test';
const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot,
encoding: 'utf8',
}));
}
test('current source does not add slice dependencies beyond the Lastleg baseline', () => {
execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], {
cwd: moduleRoot,
encoding: 'utf8',
});
});
test('main, test, and combined source sets expose package dependency graphs', () => {
for (const sourceSet of ['main', 'test', 'all']) {
const dot = execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'dot', '--graph', 'package'], {
cwd: moduleRoot,
encoding: 'utf8',
});
assert.match(dot, /^digraph lastleg_packages \{/);
assert.match(dot, /com\.baiyue\.lastleg/);
}
});
test('vehicle master data remains the reviewed low-coupling first-slice candidate', () => {
const report = analyze();
const classes = report.classes.filter(entry => entry.fqcn.startsWith(vehiclePrefix));
assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController',
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper',
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter',
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord',
'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService',
'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels',
'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository',
]);
const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix)
&& !edge.target.startsWith(vehiclePrefix));
const incoming = report.classDependencies.filter(edge => edge.target.startsWith(vehiclePrefix)
&& !edge.source.startsWith(vehiclePrefix));
assert.deepEqual(outgoing, [{
source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController',
target: 'com.baiyue.lastleg.security.OverseasPermissions',
}]);
assert.deepEqual(incoming, []);
assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false);
assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false);
assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false);
});
test('vehicle application only depends inward and stays free of adapter frameworks', () => {
const report = analyze();
const prefix = `${vehiclePrefix}.application`;
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix));
assert.equal(applicationClasses.length, 3);
for (const entry of applicationClasses) {
assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false);
assert.equal(entry.imports.some(imported => /RestClient|\.client\./.test(imported)), false);
}
const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix));
assert.deepEqual(outwardDependencies, []);
});
@@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
/** 将海外业务 Outbox 投递到国内区域事件桥。 */
@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient",
url = "${baiyue.firstleg.event-bridge-url:}")
url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}")
public interface EventBridgeClient {
@PostMapping("/internal/event-bridge/events")
ApiResponse<Acknowledgement> receive(
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.config;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
@Component
public class LastlegRestClientFactory {
public RestClient build(RestClient.Builder builder, String baseUrl,
LastlegRestClientProperties.ClientTimeouts timeouts) {
JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts);
return builder.requestFactory(requestFactory).baseUrl(baseUrl).build();
}
HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) {
return HttpClient.newBuilder()
.connectTimeout(timeouts.getConnectTimeout())
.build();
}
JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) {
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts));
requestFactory.setReadTimeout(timeouts.getReadTimeout());
return requestFactory;
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@ConfigurationProperties(prefix = "baiyue.http.clients")
public class LastlegRestClientProperties {
private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; }
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); }
public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; }
public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); }
public ClientTimeouts getWarehouseResource() { return warehouseResource; }
public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
public ClientTimeouts getPalletPhysical() { return palletPhysical; }
public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); }
private static ClientTimeouts required(ClientTimeouts value, String client) {
if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required");
return value;
}
public static class ClientTimeouts {
private Duration connectTimeout;
private Duration readTimeout;
public ClientTimeouts() { }
public ClientTimeouts(Duration connectTimeout, Duration readTimeout) {
setConnectTimeout(connectTimeout);
setReadTimeout(readTimeout);
}
public Duration getConnectTimeout() { return connectTimeout; }
public void setConnectTimeout(Duration value) { connectTimeout = positive(value, "connect-timeout"); }
public Duration getReadTimeout() { return readTimeout; }
public void setReadTimeout(Duration value) { readTimeout = positive(value, "read-timeout"); }
private static Duration positive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " must be positive");
}
return value;
}
}
}
@@ -11,6 +11,8 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
import com.baiyue.common.event.EventTypes;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -37,16 +39,26 @@ import java.nio.charset.StandardCharsets;
@Tag(name = "Tail-leg orders")
public class LastlegOrderController {
@Resource private LoadingCompletedConsumer consumer;
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
@Resource private LastlegOrderService orderService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/internal/lastleg/events")
@Operation(summary = "Receive a first-leg loading-completed event")
@Operation(summary = "Receive a first-leg loading or actual shipping-node event",
description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.")
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));
int consumed;
if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) {
consumed = shippingNodeConsumer.consume(event);
} else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) {
consumed = consumer.consume(event);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType());
}
return ApiResponse.success(consumed);
}
@GetMapping("/api/platform/v1/last-leg/orders")
@@ -11,6 +11,8 @@ public record LastlegOrderSummary(
String orderNo,
String orderType,
String orderTypeName,
@Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"})
String destinationType,
String sourceInboundId,
String inboundCode,
String sourceOrderNo,
@@ -13,7 +13,6 @@ import java.time.LocalDateTime;
@TableName("ll_inbound_order")
public class LastlegInboundOrder {
@TableId private Long id;
private String tenantId;
private String customerId;
private String sourceRegion;
private String sourceInboundId;
@@ -25,6 +24,7 @@ public class LastlegInboundOrder {
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String destinationType;
private Integer packageCount;
private Integer productQuantity;
private Integer palletCount;
@@ -15,6 +15,7 @@ public class LastlegOrder {
private String orderNo;
private String customerId;
private String orderType;
private String destinationType;
private String inboundCode;
private String sourceRegion;
private String sourceInboundId;
@@ -23,6 +24,8 @@ public class LastlegOrder {
private String sourceOrderNo;
private String rootOrderId;
private String customerName;
private String sourceWarehouseId;
private String sourceWarehouseName;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String fbaWarehouseCode;
@@ -17,6 +17,7 @@ public class InboundPlan {
private String customerId;
private Long sourceOrderId;
private String containerNo;
private Long overseasWarehouseId;
private String status;
private Integer lineCount;
private Integer palletCount;
@@ -16,6 +16,7 @@ public class InboundPlanLine {
private Long planId;
private Long orderId;
private String inboundCode;
private String destinationType;
private String fbaWarehouseCode;
private String destinationWarehouseId;
private String destinationWarehouseCode;
@@ -6,6 +6,8 @@ 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 InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
@Select("""
@@ -31,4 +33,13 @@ public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
WHERE l.plan_id=#{planId})
""")
boolean hasLabelPool(@Param("planId") Long planId);
@Select("""
SELECT DISTINCT c.* FROM ll_inbound_plan_carton c
JOIN ll_order_detail d ON d.source_inbound_detail_id=c.source_inbound_detail_id
JOIN ll_inbound_plan_line l ON l.order_id=d.order_id
WHERE l.plan_id=#{planId} AND c.customer_id=l.customer_id
ORDER BY c.id
""")
List<InboundPlanCarton> listExpected(@Param("planId") Long planId);
}
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
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;
@@ -47,4 +48,10 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1")
InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId);
@Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " +
"WHERE id=#{planId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("planId") long planId,
@Param("warehouseId") long warehouseId);
}
@@ -1,43 +0,0 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets")
public class InboundPalletAssignmentController {
@Resource private InboundPdaWorkflowService service;
@PostMapping("/{planPalletId}/assign")
public ApiResponse<Void> assign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable long planPalletId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Assign request) {
service.assign(scope(user, customerId), planNo, planPalletId, request);
return ApiResponse.success(null);
}
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());
}
}
@@ -0,0 +1,136 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
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.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/receiving-containers")
@Tag(name = "Overseas PDA container receiving")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class ReceivingContainerController {
public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION;
@Resource private ReceivingContainerService service;
@GetMapping("/candidates")
@Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker")
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@GetMapping("/{containerNo}")
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo) {
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo));
}
@PostMapping("/{containerNo}/prepare")
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
containerNo, request));
}
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
PdaWarehouseIdentity identity = currentIdentity(user);
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
}
@PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
}
@GetMapping("/current")
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.current(identity.operatorKey()));
}
@PostMapping("/current/cartons/resolve")
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
}
@PostMapping("/current/skus/resolve")
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
}
@PostMapping("/current/pallets/resolve")
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
}
@PostMapping("/current/receipts/bulk")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/receipts/carton")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
private static PdaWarehouseIdentity identity(AuthenticatedUser user) {
return PdaWarehouseIdentity.require(user);
}
private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) {
PdaWarehouseIdentity identity = identity(user);
service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId());
return identity;
}
}
@@ -12,7 +12,9 @@ public final class PdaResponses {
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount, String orderType, boolean wholePalletInbound,
List<DropshipPallet> dropshipPallets, String inboundMode) {
List<DropshipPallet> dropshipPallets, String inboundMode,
List<Pallet> pallets, List<Allocation> allocations,
List<ExpectedCarton> expectedCartons) {
public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
@@ -20,7 +22,8 @@ public final class PdaResponses {
int pickedPackageCount) {
this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt,
unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt,
expectedPackageCount, pickedPackageCount, null, false, List.of(), null);
expectedPackageCount, pickedPackageCount, null, false, List.of(), null,
List.of(), List.of(), List.of());
}
public Task(long taskId, String taskNo, String planNo, String status,
@@ -36,6 +39,21 @@ public final class PdaResponses {
long packageCount, List<DropshipSku> skus) {}
public record DropshipSku(String skuCode, String productName, long packageCount, long productQuantity) {}
public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
long warehousePalletId, long warehouseLocationId,
String assignmentStatus, int actualPackageCount) {}
public record Allocation(long allocationId, long lineId, long planPalletId,
long orderId, String inboundCode, String fbaWarehouseCode,
int requiredPackageCount, int plannedPackageCount,
int countedPackageCount, int pickedPackageCount,
String status, int version) {}
public record ExpectedCarton(String cartonMark, String skuCode,
String destinationWarehouseId, String destinationWarehouseCode,
long planPalletId, String status) {}
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
public record Operation(String result, long allocationId, int requiredPackageCount,
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class ReceivingContainerRequests {
private ReceivingContainerRequests() { }
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { }
public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { }
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode,
@NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class ReceivingContainerResponses {
private ReceivingContainerResponses() { }
public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount,
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages, boolean currentAccount) { }
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) { }
public record SignedPhoto(ReceivingPhotoRole role, long fileId) { }
public record Inbound(String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record CartonResolved(String cartonMark, String skuCode, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record SkuResolved(String skuCode, String productName, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) { }
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
public enum ReceivingPhotoRole {
LICENSE_PLATE,
CONTAINER_SEAL,
DOOR_OPEN
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.inboundplan.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_receiving_container_operation")
public class ReceivingContainerOperation {
@TableId(type = IdType.INPUT)
private Long id;
private Long sessionId;
private String customerId;
private String operatorKey;
private String action;
private String idempotencyKey;
private String requestHash;
private String requestJson;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_container_session")
public class ReceivingContainerSession {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String containerNo;
private Long overseasWarehouseId;
private String overseasWarehouseName;
private String operatorKey;
private String operatorName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeOperatorKey;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeContainerKey;
private String status;
private String signPhotos;
private LocalDateTime preparedAt;
private LocalDateTime signedAt;
private LocalDateTime receivingAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_sku_receipt")
public class ReceivingSkuReceipt {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private Long sessionId;
private Long planId;
private Long lineId;
private Long palletId;
private Long orderId;
private String inboundCode;
private String skuCode;
private String productName;
private Integer receivedPackages;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -1,15 +1,19 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@@ -17,12 +21,17 @@ import java.io.IOException;
public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getUnloadPhotoFile());
}
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
@@ -42,10 +51,14 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo upload failed");
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Unload photo upload failed");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Unload photo upload failed");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata");
if (data == null || data.fileId() == null) throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "File service returned no photo metadata");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@@ -58,22 +71,40 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid");
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Unload photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Unload photo does not exist or its ownership is invalid");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) {
throw new IllegalArgumentException("Unload photo metadata does not match the PDA task");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unload photo metadata does not match the PDA task");
}
}
private long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); }
catch (NumberFormatException exception) { throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); }
}
private record MetadataEnvelope(Metadata data) {}
private static void requireSuccess(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if ("200".equals(response.code())) return;
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private record MetadataEnvelope(String code, String message, Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
}
@@ -1,9 +1,14 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.fasterxml.jackson.annotation.JsonAlias;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
@@ -12,6 +17,8 @@ import java.util.Map;
@Component
public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}")
private String baseUrl;
@@ -23,7 +30,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@PostConstruct
void initializeClient() {
this.client = restClientBuilder.baseUrl(baseUrl).build();
this.client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getWarehouseResource());
}
@Override
@@ -35,7 +43,9 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode());
}).body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId());
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(),
response.data().fbaWarehouseCode(), response.data().version());
}
@Override
@@ -69,6 +79,19 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.toBodilessEntity();
}
@Override
public void release(String customerId, long resourceLockId) {
client.post()
.uri("/internal/v1/warehouse-resource-locks/{lockId}/release", resourceLockId)
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("customerId", Long.parseLong(customerId)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new IllegalStateException("Warehouse rejected pallet release: HTTP " + response.getStatusCode());
})
.toBodilessEntity();
}
@Override
public void scrapPallet(String customerId, long palletId) {
client.post()
@@ -82,8 +105,79 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.toBodilessEntity();
}
@Override
public WarehousePalletReference resolvePallet(String palletCode) {
WarehousePalletResponse response = client.get()
.uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode());
}).body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(),
response.data().fbaWarehouseCode(), response.data().version());
}
@Override
public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
WarehouseLocationResponse response = client.get()
.uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/resolve/{locationCode}")
.queryParam("warehouseId", warehouseId).build(locationCode))
.header("X-Internal-Service-Token", internalToken).retrieve()
.body(WarehouseLocationResponse.class);
return location(response);
}
@Override
public WarehouseLocationReference resolveLocation(long warehouseId, long locationId) {
WarehouseLocationResponse response = client.get()
.uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/{locationId}")
.queryParam("warehouseId", warehouseId).build(locationId))
.header("X-Internal-Service-Token", internalToken).retrieve()
.body(WarehouseLocationResponse.class);
return location(response);
}
private WarehouseLocationReference location(WarehouseLocationResponse response) {
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse location was not found");
}
return response.data();
}
@Override
public WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
WarehouseReferenceResponse response = client.get()
.uri("/internal/v1/warehouse-resource-locks/shared-warehouses/{warehouseId}", warehouseId)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().value() == 404
? HttpStatus.NOT_FOUND : (result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
"Shared overseas warehouse lookup failed");
}).body(WarehouseReferenceResponse.class);
if (response == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Shared overseas warehouse service returned no response");
}
if ("A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"Active shared overseas warehouse was not found");
}
if (!"200".equals(response.code()) || response.data() == null
|| response.data().warehouseId() != warehouseId || !"ACTIVE".equals(response.data().status())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Invalid shared overseas warehouse binding");
}
return response.data();
}
private record WarehousePalletResponse(WarehousePalletData data) {}
private record WarehousePalletData(long id, String palletCode, long locationId) {}
private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {}
private record ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {}
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {}
}
@@ -4,8 +4,29 @@ public interface WarehouseResourceGateway {
WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId);
long reserve(String customerId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey);
void release(String customerId, long resourceLockId);
void scrap(String customerId, long resourceLockId);
void scrapPallet(String customerId, long palletId);
WarehousePalletReference resolvePallet(String palletCode);
default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
throw new UnsupportedOperationException("Warehouse location resolution is not configured");
}
default WarehouseLocationReference resolveLocation(long warehouseId, long locationId) {
throw new UnsupportedOperationException("Warehouse location resolution is not configured");
}
default WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
throw new UnsupportedOperationException("Shared warehouse resolution is not configured");
}
record WarehousePalletReference(long palletId, String palletNo, long locationId) {}
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {
public WarehousePalletReference(long palletId, String palletNo, long locationId) {
this(palletId, palletNo, 0L, locationId, null, null, 0);
}
}
record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName,
String status) { }
record WarehouseLocationReference(long locationId, String locationCode, String locationType,
java.math.BigDecimal maxLoadKg, String status) { }
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingContainerOperationMapper extends BaseMapper<ReceivingContainerOperation> {
@Select("SELECT * FROM ll_receiving_container_operation " +
"WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
ReceivingContainerOperation find(@Param("operatorKey") String operatorKey,
@Param("key") String key);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingContainerSession> {
@Select("SELECT * FROM ll_receiving_container_session " +
"WHERE active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE")
ReceivingContainerSession lockActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey);
@Select("SELECT * FROM ll_receiving_container_session " +
"WHERE active_operator_key=#{activeOperatorKey} LIMIT 1")
ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey);
@Select("SELECT * FROM ll_receiving_container_session " +
"WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE")
ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey);
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
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 ReceivingSkuReceiptMapper extends BaseMapper<ReceivingSkuReceipt> {
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE")
List<ReceivingSkuReceipt> lockLineSku(@Param("customerId") String customerId,
@Param("lineId") Long lineId,
@Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id")
List<ReceivingSkuReceipt> listLineSku(@Param("customerId") String customerId,
@Param("lineId") Long lineId,
@Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
"AND plan_id=#{planId} ORDER BY line_id,pallet_id,sku_code,id")
List<ReceivingSkuReceipt> listPlan(@Param("customerId") String customerId,
@Param("planId") Long planId);
}
@@ -66,6 +66,7 @@ public class PdaWorkflowRepository {
public List<InboundPlanCarton> cartons(Long planId) {
return cartons.selectList(new LambdaQueryWrapper<InboundPlanCarton>().eq(InboundPlanCarton::getPlanId, planId));
}
public List<InboundPlanCarton> expectedCartons(Long planId) { return cartons.listExpected(planId); }
public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) {
return cartons.findPlanLine(planId, carton.getSourceInboundDetailId());
}
@@ -17,6 +17,7 @@ import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -36,6 +37,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -52,6 +54,7 @@ public class InboundPdaWorkflowService {
@Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private LastlegOrderMapper orders;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private DropshipOrderService dropshipOrders;
@Resource private ObjectMapper sourceObjectMapper;
@@ -142,6 +145,118 @@ public class InboundPdaWorkflowService {
return taskResponse(task, plan, planNo);
}
@Transactional
public void ensureSignedTask(String customerId, InboundPlan plan, String operatorId, String operatorName,
List<Long> photoFileIds, LocalDateTime signedAt) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) {
task = new InboundPdaTask();
task.setId(IdWorker.getId()); task.setPlanId(plan.getId()); task.setCustomerId(customerId);
task.setTaskNo("PDA" + task.getId()); task.setCreatedAt(signedAt); task.setVersion(0);
repository.insert(task);
}
if ("COMPLETED".equals(task.getStatus())) return;
task.setStatus("SIGNED"); task.setOperatorId(operatorId); task.setOperatorName(operatorName);
task.setUnloadPhotoFileIds(write(photoFileIds)); task.setUnloadPhotoUploadedAt(signedAt);
task.setEstimatedEmptyReturnAt(signedAt.plusDays(2)); task.setUpdatedAt(signedAt);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("SIGNED"); plan.setUpdatedAt(signedAt); repository.update(plan);
}
@Transactional
public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) {
if (pallet.getWarehouseResourceLockId() != null) {
if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null;
throw conflict("Destination pallet is already bound to another physical pallet");
}
String destinationKey = destinationKey(pallet);
String businessId = plan.getContainerNo() + ":" + destinationKey;
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId();
long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(),
"INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey);
try {
pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId());
pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet);
return lockId;
} catch (RuntimeException failure) {
compensateReservation(customerId, lockId, failure);
throw failure;
}
}
public void validateBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, int packageCount) {
requireActiveTask(customerId, plan);
int received = repository.allocations(plan.getId()).stream()
.filter(value -> Objects.equals(value.getLineId(), line.getId()))
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
int pending = value(line.getRequiredPackageCount()) - received;
if (packageCount <= 0 || packageCount > pending) {
throw conflict("Received package count exceeds the inbound pending total");
}
}
public void validateCartonResolved(String customerId, InboundPlan plan, InboundPlanLine line,
InboundPlanPallet pallet, String cartonMark) {
requireActiveTask(customerId, plan);
InboundPlanCarton carton = repository.lockCarton(customerId, cartonMark);
if (carton == null || !("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) {
throw conflict("Carton mark has already been scanned or is unavailable");
}
InboundPlanLine resolvedLine = carton.getLineId() == null
? repository.lineForCarton(plan.getId(), carton) : requireLine(plan.getId(), carton.getLineId());
if (resolvedLine == null || !Objects.equals(resolvedLine.getId(), line.getId())) {
throw conflict("Carton mark belongs to another inbound code or container");
}
if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match");
if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) {
throw conflict("This inbound plan line has reached its required carton count");
}
}
public void compensateReservation(String customerId, Long resourceLockId, RuntimeException originalFailure) {
if (resourceLockId == null) return;
try {
warehouse.release(customerId, resourceLockId);
} catch (RuntimeException releaseFailure) {
originalFailure.addSuppressed(releaseFailure);
}
}
@Transactional
public int receiveBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line,
InboundPlanPallet pallet, int packageCount,
String operatorId, String operatorName) {
InboundPdaTask task = requireActiveTask(customerId, plan);
int received = repository.allocations(plan.getId()).stream()
.filter(value -> Objects.equals(value.getLineId(), line.getId()))
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
int pending = value(line.getRequiredPackageCount()) - received;
if (packageCount > pending) throw conflict("Received package count exceeds the inbound pending total");
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0);
allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME);
allocation.setStatus("RECEIVING"); allocation.setVersion(0); repository.insert(allocation);
}
BigDecimal perPackage = perPackageVolume(line);
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + packageCount);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + packageCount);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm())
.add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus(allocation.getPickedPackageCount() == value(line.getRequiredPackageCount())
? "RECEIVED" : "RECEIVING"); bump(allocation); repository.update(allocation);
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + packageCount);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm())
.add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP));
repository.update(pallet); startPicking(task, plan, operatorId, operatorName);
return packageCount;
}
@Transactional
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
@@ -280,7 +395,8 @@ public class InboundPdaWorkflowService {
List<InboundPlanCarton> cartonsToScan = List.of(carton);
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId());
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId());
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
@@ -430,14 +546,61 @@ public class InboundPdaWorkflowService {
@Transactional
public PdaResponses.Task complete(String customerId, String planNo) {
InboundPlan plan = requirePlan(customerId, planNo);
return completeContainer(customerId, List.of(plan)).get(0);
}
/** Completes every plan of one physical container and emits exactly one container-level outbox event. */
@Transactional
public List<PdaResponses.Task> completeContainer(String customerId, List<InboundPlan> containerPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
List<InboundPlan> orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList();
String containerNo = orderedPlans.get(0).getContainerNo();
if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan ->
!customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one customer and one physical container");
}
List<CompletionContext> contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList();
boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus()));
boolean allCompleted = contexts.stream().allMatch(context -> "COMPLETED".equals(context.task().getStatus()));
if (allCompleted) return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required");
LocalDateTime now = LocalDateTime.now();
for (CompletionContext context : contexts) completePlan(customerId, context, now);
String sourceContainerBookingId = sourceContainerBookingId(contexts);
LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt())
.max(LocalDateTime::compareTo).orElseThrow();
long aggregateVersion = contexts.stream().map(CompletionContext::task)
.map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L);
unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo,
now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
}
private String sourceContainerBookingId(List<CompletionContext> contexts) {
List<String> bookingIds = contexts.stream().map(context -> {
LastlegOrder order = orders.selectById(context.plan().getSourceOrderId());
if (order == null || order.getSourceContainerBookingId() == null
|| order.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
return order.getSourceContainerBookingId();
}).distinct().toList();
if (bookingIds.size() != 1) throw conflict("Container plans reference different source container bookings");
return bookingIds.get(0);
}
private CompletionContext completionContext(String customerId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) return task(task, planNo);
if (task == null) throw conflict("Push every inbound plan in the container to PDA before completing");
if ("COMPLETED".equals(task.getStatus())) {
return new CompletionContext(plan, task, List.of(), List.of(), List.of(), List.of());
}
if ("PARTIAL_INBOUND".equals(task.getStatus())) throw conflict("PDA inbound task was partially completed");
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|| task.getEstimatedEmptyReturnAt() == null) {
throw conflict("Upload unload photos before completing the PDA inbound task");
throw conflict("Upload unload photos for every inbound plan before completing the container");
}
Map<Long, Integer> pickedByLine = new HashMap<>();
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
@@ -451,15 +614,24 @@ public class InboundPdaWorkflowService {
throw conflict("All line picked package totals must exactly equal required package totals");
}
}
Map<Long, String> destinationTypes = lines.stream().collect(java.util.stream.Collectors.toMap(
InboundPlanLine::getId, line -> destinationType(line)));
List<InboundPlanCarton> cartons = repository.cartons(plan.getId());
if (mode(plan) == InboundMode.OWN_OVERSEAS && !cartons.isEmpty()
&& cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) {
if (cartons.stream().anyMatch(carton -> "OVERSEAS".equals(destinationTypes.get(carton.getLineId()))
&& !"ON_PALLET".equals(carton.getStatus()))) {
throw conflict("All expected carton marks must be scanned onto a pallet before stock-in");
}
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
for (InboundPlanCarton carton : cartons) {
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons);
}
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
InboundPlan plan = context.plan();
InboundPdaTask task = context.task();
stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(),
repository.pallets(plan.getId()), now);
for (InboundPlanCarton carton : context.cartons()) {
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
carton.setReceivedAt(now); carton.setUpdatedAt(now);
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
}
task.setStatus("COMPLETED");
@@ -470,8 +642,6 @@ public class InboundPdaWorkflowService {
plan.setStatus("COMPLETED");
plan.setUpdatedAt(now);
repository.update(plan);
unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds);
return taskResponse(task, plan, planNo);
}
/** Explicit operator decision to receive only the cartons actually picked. */
@@ -562,6 +732,7 @@ public class InboundPdaWorkflowService {
if (moved == null) {
moved = new InboundPlanPalletAllocation();
moved.setId(IdWorker.getId());
moved.setCustomerId(source.getCustomerId());
moved.setPlanId(planId);
moved.setLineId(source.getLineId());
moved.setPalletId(targetPalletId);
@@ -776,16 +947,55 @@ public class InboundPdaWorkflowService {
List<PdaResponses.DropshipPallet> dropshipPallets = wholePalletInbound
? dropshipOrders.palletSnapshots(task.getCustomerId(), order.getId()).stream()
.map(pallet -> dropshipPallet(pallet, palletsByNo, allocationsByPallet)).toList() : List.of();
PdaResponses.Task workInstructions = task(task, planNo);
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked,
orderType, wholePalletInbound, dropshipPallets, mode(plan).name());
orderType, wholePalletInbound, dropshipPallets, mode(plan).name(),
workInstructions.pallets(), workInstructions.allocations(), workInstructions.expectedCartons());
}
private PdaResponses.Task task(InboundPdaTask task, String planNo) {
List<InboundPlanPallet> planPallets = repository.pallets(task.getPlanId());
List<InboundPlanPalletAllocation> planAllocations = repository.allocations(task.getPlanId());
Map<Long, InboundPlanLine> lines = repository.lines(task.getPlanId()).stream()
.collect(java.util.stream.Collectors.toMap(InboundPlanLine::getId, value -> value));
List<PdaResponses.Pallet> pallets = planPallets.stream().map(value -> new PdaResponses.Pallet(
value.getId(), value.getPalletNo(), value.getDestinationWarehouseId(),
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(),
Objects.requireNonNullElse(value.getWarehousePalletId(), 0L),
Objects.requireNonNullElse(value.getWarehouseLocationId(), 0L),
value.getAssignmentStatus(), InboundPdaWorkflowService.value(value.getActualPackageCount()))).toList();
List<PdaResponses.Allocation> allocations = planAllocations.stream().map(value -> {
InboundPlanLine line = lines.get(value.getLineId());
if (line == null) throw conflict("Inbound allocation line is missing");
return new PdaResponses.Allocation(value.getId(), value.getLineId(), value.getPalletId(),
line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(),
InboundPdaWorkflowService.value(line.getRequiredPackageCount()),
InboundPdaWorkflowService.value(value.getPlannedPackageCount()),
InboundPdaWorkflowService.value(value.getCountedPackageCount()),
InboundPdaWorkflowService.value(value.getPickedPackageCount()), value.getStatus(),
InboundPdaWorkflowService.value(value.getVersion()));
}).toList();
List<PdaResponses.ExpectedCarton> expectedCartons = repository.expectedCartons(task.getPlanId()).stream()
.map(value -> new PdaResponses.ExpectedCarton(value.getCartonMark(), value.getSkuCode(),
value.getDestinationWarehouseId(), value.getDestinationWarehouseCode(),
recommendedPallet(value, planPallets), value.getStatus()))
.toList();
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt());
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(),
lines.values().stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(),
planAllocations.stream().mapToInt(value -> InboundPdaWorkflowService.value(value.getPickedPackageCount())).sum(),
null, false, List.of(), null, pallets, allocations, expectedCartons);
}
private long recommendedPallet(InboundPlanCarton carton, List<InboundPlanPallet> pallets) {
if (carton.getPlanPalletId() != null) return carton.getPlanPalletId();
return pallets.stream().filter(value -> Objects.equals(value.getDestinationWarehouseId(), carton.getDestinationWarehouseId())
|| (carton.getDestinationWarehouseId() == null
&& Objects.equals(value.getDestinationWarehouseCode(), carton.getDestinationWarehouseCode())))
.map(InboundPlanPallet::getId).findFirst().orElse(0L);
}
private PdaResponses.DropshipPallet dropshipPallet(DropshipPalletSnapshot pallet,
@@ -857,6 +1067,13 @@ public class InboundPdaWorkflowService {
return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP);
}
private static String destinationType(InboundPlanLine line) {
if (line.getDestinationType() != null && !line.getDestinationType().isBlank()) {
return line.getDestinationType().trim().toUpperCase();
}
return line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() ? "OVERSEAS" : "FBA";
}
private InboundPdaOperation replay(String customerId, String action, String key, Object request) {
InboundPdaOperation operation = repository.operation(customerId, key);
if (operation == null) return null;
@@ -911,6 +1128,11 @@ public class InboundPdaWorkflowService {
allocation.setVersion(value(allocation.getVersion()) + 1);
}
private record CompletionContext(InboundPlan plan, InboundPdaTask task, List<Long> photoFileIds,
List<InboundPlanLine> lines,
List<InboundPlanPalletAllocation> allocations,
List<InboundPlanCarton> cartons) { }
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
@@ -9,7 +9,9 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
@@ -39,6 +41,7 @@ public class InboundStockReceiptService {
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private InboundStockReceiptMapper receipts;
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
@Resource private InventoryCommandService inventory;
@Resource private DropshipOrderService dropshipOrders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@@ -64,6 +67,9 @@ public class InboundStockReceiptService {
.filter(allocation -> value(allocation.getPickedPackageCount()) > 0)
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId))
.collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId));
Map<Long, List<ReceivingSkuReceipt>> skuReceiptsByLine = receivingSkuReceipts
.listPlan(customerId, plan.getId()).stream()
.collect(Collectors.groupingBy(ReceivingSkuReceipt::getLineId));
Set<Long> expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream)
.map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet());
@@ -92,6 +98,12 @@ public class InboundStockReceiptService {
|| (allowPartial && pickedPackages > value(line.getRequiredPackageCount()))) {
throw conflict("PDA picked packages must equal the inbound order package total");
}
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
if (!exactSkuReceipts.isEmpty()) {
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
palletById, order, exactSkuReceipts, pickedPackages, receivedAt));
continue;
}
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount()));
Map<String, DropshipPalletSnapshot> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
@@ -127,6 +139,48 @@ public class InboundStockReceiptService {
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
}
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
Map<Long, InboundPlanPallet> palletById, LastlegOrder order,
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt) {
long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total");
Map<String, Long> expectedBySku = skuRemainders(order.getId(), pickedPackages).stream()
.collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum,
java.util.LinkedHashMap::new));
Map<String, Long> actualBySku = exactReceipts.stream().collect(Collectors.groupingBy(
value -> value.getSkuCode().toUpperCase(java.util.Locale.ROOT), java.util.LinkedHashMap::new,
Collectors.summingLong(value -> value(value.getReceivedPackages()))));
Map<String, Long> normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap(
value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum,
java.util.LinkedHashMap::new));
if (!actualBySku.equals(normalizedExpected)) {
throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots");
}
Map<Long, InboundPlanPalletAllocation> allocationByPallet = lineAllocations.stream()
.collect(Collectors.toMap(InboundPlanPalletAllocation::getPalletId, value -> value));
Map<Long, List<ReceivingSkuReceipt>> receiptsByPallet = exactReceipts.stream()
.collect(Collectors.groupingBy(ReceivingSkuReceipt::getPalletId));
List<InboundStockReceipt> created = new java.util.ArrayList<>();
for (Map.Entry<Long, List<ReceivingSkuReceipt>> entry : receiptsByPallet.entrySet()) {
InboundPlanPalletAllocation allocation = allocationByPallet.get(entry.getKey());
if (allocation == null) throw conflict("FBA SKU receipt pallet has no matching allocation");
long palletPackages = entry.getValue().stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
if (palletPackages != value(allocation.getPickedPackageCount())) {
throw conflict("FBA SKU receipt pallet total must match the allocation total");
}
InboundPlanPallet pallet = requirePallet(palletById, allocation);
for (ReceivingSkuReceipt exact : entry.getValue()) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
new SkuSnapshot(exact.getSkuCode(), exact.getProductName(), null,
value(exact.getReceivedPackages())), receivedAt));
}
}
return created;
}
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
LocalDateTime completedAt, boolean partial) {
String subOrderStatus = partial ? "INBOUND_PLAN:PARTIAL_INBOUND" : "INBOUND_PLAN:COMPLETED";
@@ -141,6 +195,7 @@ public class InboundStockReceiptService {
order.setUpdatedAt(completedAt);
orders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setCustomerId(order.getCustomerId());
history.setOrderId(order.getId());
history.setFromStatus(before);
history.setToStatus("INBOUND_COMPLETED");
@@ -161,8 +216,11 @@ public class InboundStockReceiptService {
if (packages <= 0 || pallet.getWarehousePalletId() == null) {
throw conflict("Stock-in requires picked packages and an assigned warehouse pallet");
}
String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()),
"Destination warehouse code is required for stock-in");
String destinationCode = first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode());
if (destinationCode == null && trim(line.getDestinationWarehouseId()) == null) {
throw conflict("Destination warehouse ID or code is required for stock-in");
}
if (destinationCode == null) destinationCode = "";
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode();
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse is required for stock-in");
@@ -39,10 +39,7 @@ public class InboundUnloadCompletedOutboxRelay {
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
EventEnvelopeValidator.requireValid(event);
var response = client.receive(internalToken, event);
if (response == null || !response.successful() || response.data() == null
|| !event.eventId().equals(response.data().eventId())) {
throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件");
}
requireAcknowledgement(event, response);
repository.published(row.getId(), workerId, LocalDateTime.now());
} catch (Exception exception) {
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
@@ -51,4 +48,18 @@ public class InboundUnloadCompletedOutboxRelay {
}
}
}
private void requireAcknowledgement(EventEnvelope event,
com.baiyue.common.core.api.ApiResponse<EventBridgeClient.Acknowledgement> response) {
if (response == null) throw new IllegalStateException("国内事件桥返回空响应");
if (!response.successful()) {
throw new IllegalStateException("国内事件桥拒绝海外卸柜完成事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId());
}
if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据");
if (!event.eventId().equals(response.data().eventId())) {
throw new IllegalStateException("国内事件桥确认eventId不一致: expected=" + event.eventId()
+ ", actual=" + response.data().eventId());
}
}
}
@@ -19,11 +19,13 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.dao.DuplicateKeyException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Objects;
/** Writes the overseas-to-CN handover to the local transactional outbox. */
@Service
@@ -32,31 +34,88 @@ public class InboundUnloadCompletedOutboxService {
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper;
/** 兼容单计划入仓完成;多计划同柜必须改调 publishContainerCompleted,并传柜级完成时刻。 */
public void publish(InboundPlan plan, InboundPdaTask task, List<Long> photoFileIds) {
LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId());
if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null
|| sourceOrder.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC);
var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(),
plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null);
if (task.getCompletedAt() == null) {
throw new IllegalStateException("Inbound task has no completion time");
}
if (task.getUnloadPhotoUploadedAt() == null) {
throw new IllegalStateException("Inbound task has no unload photo upload time");
}
publishContainerCompleted(task.getCustomerId(), sourceOrder.getSourceContainerBookingId(),
plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC),
task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC),
Math.max(1, task.getVersion() == null ? 1 : task.getVersion()));
}
/**
* 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。
*/
public void publishContainerCompleted(String customerId, String sourceContainerBookingId,
String containerNo, Instant unloadingCompletedAt,
Instant unloadingPhotoUploadedAt, long aggregateVersion) {
requireText(customerId, "customerId");
requireText(sourceContainerBookingId, "sourceContainerBookingId");
requireText(containerNo, "containerNo");
Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt");
Objects.requireNonNull(unloadingPhotoUploadedAt, "unloadingPhotoUploadedAt");
if (unloadingCompletedAt.isBefore(unloadingPhotoUploadedAt)) {
throw new IllegalArgumentException("unloadingCompletedAt cannot be before unloadingPhotoUploadedAt");
}
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId;
LastlegEventOutbox existing = mapper.findByEventKey(eventKey);
if (existing != null) {
requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo);
return;
}
var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId,
containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt, null);
JsonNode payloadNode = objectMapper.valueToTree(payload);
long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion());
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId();
String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING",
sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode,
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, customerId, "CONTAINER_BOOKING",
sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox();
event.setId(IdWorker.getId()); event.setCustomerId(task.getCustomerId());
event.setId(IdWorker.getId()); event.setCustomerId(customerId);
event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId());
event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion());
event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING");
event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
mapper.insert(event);
try {
mapper.insert(event);
} catch (DuplicateKeyException exception) {
LastlegEventOutbox raced = mapper.findByEventKey(eventKey);
if (raced == null) throw exception;
requireSameContainer(raced, customerId, sourceContainerBookingId, containerNo);
}
}
private void requireSameContainer(LastlegEventOutbox existing, String customerId,
String sourceContainerBookingId, String containerNo) {
try {
EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class);
if (!Objects.equals(existing.getCustomerId(), customerId)
|| !Objects.equals(existing.getAggregateId(), sourceContainerBookingId)
|| !Objects.equals(frozen.containerNo(), containerNo)) {
throw new IllegalStateException("Unload-completed event key conflicts with another container");
}
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read existing unload-completed event", exception);
}
}
private void requireText(String value, String field) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " is required");
}
private String json(Object value) {
@@ -0,0 +1,760 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import jakarta.annotation.PostConstruct;
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.transaction.annotation.Isolation;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class ReceivingContainerService {
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@Resource private InboundPlanPalletAllocationMapper allocations;
@Resource private InboundPlanCartonMapper cartons;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private ReceivingContainerSessionMapper sessions;
@Resource private ReceivingContainerOperationMapper operations;
@Resource private ReceivingSkuReceiptMapper skuReceipts;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private WarehouseResourceGateway warehouse;
@Resource private UnloadPhotoFileGateway unloadPhotos;
@Resource private InboundPdaWorkflowService workflow;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@PostConstruct
void initializeObjectMapper() {
objectMapper = sourceObjectMapper.copy()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) {
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
}
});
return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> {
requireSingleCustomer(entry.getValue(), entry.getKey());
Totals totals = totals(entry.getValue());
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN",
entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(),
totals.total(), totals.received(), active);
}).toList();
}
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse");
requireReceivingAvailable(customerId, containerNo);
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
if (current != null && !containerNo.equals(current.getContainerNo())) {
throw conflict("Current account is locked to another selected container");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
customerId, operatorKey, receivingWarehouse, containerNo), containerPlans);
}
@Transactional
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo,
ReceivingContainerRequests.Action request) {
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
if (replay != null) {
ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class);
if (!Objects.equals(result.overseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Prepared container belongs to another overseas warehouse");
}
return result;
}
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
requireReceivingAvailable(customerId, containerNo);
String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
requireSessionWarehouse(active, overseasWarehouseId);
if (active != null) {
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
}
if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
throw conflict("Container is already signed or prepared by another account");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
LocalDateTime now = LocalDateTime.now();
ReceivingContainerSession session = new ReceivingContainerSession();
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
session.setOverseasWarehouseId(overseasWarehouseId);
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
session.setVersion(0);
try { sessions.insert(session); }
catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); }
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
save(session, "PREPARE", request.idempotencyKey(), command, result); return result;
}
public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) {
ReceivingContainerSession session = requireCurrent(operatorKey, true);
String customerId = session.getCustomerId();
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
}
@Transactional
public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName,
ReceivingContainerRequests.Sign request) {
ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireCurrent(operatorKey, true);
String customerId = session.getCustomerId();
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
List<Long> photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList();
photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId));
LocalDateTime now = LocalDateTime.now();
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session);
for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) {
workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now);
}
ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo()));
save(session, "SIGN", request.idempotencyKey(), request, result); return result;
}
public ReceivingContainerResponses.Session current(String operatorKey) {
ReceivingContainerSession session = requireCurrent(operatorKey, false);
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
}
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
}
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA inbound must use bulk count instead of carton scan");
}
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
}
public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey,
ReceivingContainerRequests.SkuResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<SkuRef> matches = refs(customerId, session.getContainerNo()).stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.map(ref -> skuRef(ref, request.skuCode()))
.filter(Objects::nonNull)
.toList();
if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container");
if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container");
return skuResponse(matches.get(0));
}
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, pallet);
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
}
@Transactional
public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName,
ReceivingContainerRequests.BulkReceive request) {
ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
}
SkuRef sku = requireSku(ref, request.skuCode());
validateSkuPending(customerId, sku, request.packageCount());
workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount());
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource);
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet,
request.packageCount(), operatorKey, operatorName);
recordSkuReceipt(session, sku, pallet, accepted);
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(ref);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure);
throw failure;
}
}
@Transactional
public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName,
ReceivingContainerRequests.CartonReceive request) {
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count");
}
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource);
workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark());
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton(
request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName));
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(ref);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure);
throw failure;
}
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Action request) {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundPlan> containerPlans = requirePlans(customerId, session.getContainerNo());
Totals totals = totals(containerPlans);
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
validateFbaSkuBalance(customerId, session.getContainerNo());
workflow.completeContainer(customerId, containerPlans);
LocalDateTime now = LocalDateTime.now();
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
}
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
String key = activeOperatorKey(operatorKey);
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
: sessions.findActiveByOperator(key);
if (session == null) throw notFound("Current receiving container session was not found");
return session;
}
private ReceivingContainerSession requireSigned(String operatorKey) {
ReceivingContainerSession session = requireCurrent(operatorKey, true);
if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) {
throw conflict("Sign the receiving container before receiving cargo");
}
return session;
}
private List<InboundPlan> requirePlans(String containerNo) {
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
if (values.isEmpty()) throw notFound("Receiving container was not found");
requireSingleCustomer(values, containerNo);
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
}
private List<InboundPlan> requirePlans(String customerId, String containerNo) {
List<InboundPlan> values = plansForContainer(customerId, containerNo);
if (values.isEmpty()) throw notFound("Receiving container was not found");
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
}
private List<InboundPlan> plansForContainer(String customerId, String containerNo) {
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo)
.orderByAsc(InboundPlan::getId));
if (values.isEmpty()) throw notFound("Receiving container was not found");
return values;
}
private String requireSingleCustomer(List<InboundPlan> values, String containerNo) {
Set<String> customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new));
if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Receiving container must resolve to exactly one customer: " + containerNo);
}
return customers.iterator().next();
}
private List<InboundRef> refs(String customerId, String containerNo) {
List<InboundRef> result = new ArrayList<>();
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing");
result.add(new InboundRef(plan, line, order));
}
}
return result;
}
private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) {
List<InboundRef> matches = refs(customerId, containerNo).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container");
return matches.get(0);
}
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) {
List<InboundPlanPallet> candidates = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()
.filter(value -> sameDestination(ref.line(), value))
.filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId()))
.sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId()))
.thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount()))
.toList();
if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code");
return candidates.get(0);
}
private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) {
if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped");
long actualWarehouse;
try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); }
catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); }
long assignedWarehouse = ref.plan().getOverseasWarehouseId() == null ? actualWarehouse : ref.plan().getOverseasWarehouseId();
if (pallet.warehouseId() != assignedWarehouse) throw conflict("Pallet belongs to another overseas warehouse");
String type = destinationType(ref.line(), ref.order());
if ("FBA".equals(type)) {
if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank()
|| !ref.line().getFbaWarehouseCode().equalsIgnoreCase(String.valueOf(pallet.fbaWarehouseCode()))) {
throw conflict("Pallet FBA destination does not match the inbound code");
}
} else {
if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) {
throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton");
}
}
}
private boolean sameDestination(InboundPlanLine line, InboundPlanPallet pallet) {
if (line.getDestinationWarehouseId() != null && !line.getDestinationWarehouseId().isBlank()) {
return line.getDestinationWarehouseId().equals(pallet.getDestinationWarehouseId());
}
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
}
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream()
.map(this::inbound).toList();
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
}
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
int received = allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream()
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
int total = value(ref.line().getRequiredPackageCount());
return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()),
ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId()
: String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(),
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
}
private Totals totals(List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getCustomerId(),
containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList();
List<String> destinationCodes = values.stream().map(this::destinationCode)
.filter(Objects::nonNull).distinct().sorted().toList();
return new Totals(values.size(), destinationCodes,
values.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(),
values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
}
private String destinationCode(ReceivingContainerResponses.Inbound inbound) {
return trim("FBA".equals(inbound.destinationType()) ? inbound.fbaWarehouseCode()
: inbound.destinationWarehouseCode());
}
private List<ReceivingContainerResponses.SignedPhoto> validateSignPhotos(ReceivingContainerRequests.Sign request) {
if (request.photos() == null || request.photos().size() != 3) {
throw conflict("Exactly three typed unload photos are required");
}
if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null");
Set<com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole> roles = request.photos().stream()
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
Set<Long> fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId)
.collect(Collectors.toSet());
if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) {
throw conflict("Unload photo file IDs must be positive and unique");
}
return request.photos().stream()
.sorted(Comparator.comparing(value -> value.role().ordinal()))
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList();
}
private SkuRef requireSku(InboundRef ref, String skuCode) {
SkuRef result = skuRef(ref, skuCode);
if (result == null) throw notFound("SKU is not part of the selected inbound code");
return result;
}
private SkuRef skuRef(InboundRef ref, String skuCode) {
String requested = trim(skuCode);
if (requested == null) return null;
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId())
.eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId));
List<LastlegOrderDetail> matched = details.stream()
.filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList();
int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum();
if (expected <= 0) return null;
String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).findFirst().orElse(requested);
String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn()))
.filter(Objects::nonNull).findFirst().orElse(null);
return new SkuRef(ref, canonicalSku, productName, expected);
}
private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) {
ReceivingContainerResponses.Inbound inbound = inbound(sku.ref());
int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode());
return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(),
inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(),
inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(),
sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received));
}
private void validateSkuPending(String customerId, SkuRef sku, int packageCount) {
int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode());
if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) {
throw conflict("Received package count exceeds the SKU pending total");
}
}
private int receivedSkuPackages(String customerId, Long lineId, String skuCode) {
return skuReceipts.listLineSku(customerId, lineId, skuCode).stream()
.mapToInt(value -> value(value.getReceivedPackages())).sum();
}
private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) {
List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(session.getCustomerId(),
sku.ref().line().getId(), sku.skuCode());
ReceivingSkuReceipt receipt = existing.stream()
.filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null);
LocalDateTime now = LocalDateTime.now();
if (receipt == null) {
receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId());
receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId());
receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId());
receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId());
receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode());
receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages);
receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0);
skuReceipts.insert(receipt);
} else {
receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages);
receipt.setUpdatedAt(now);
if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently");
}
}
private void validateFbaSkuBalance(String customerId, String containerNo) {
for (InboundRef ref : refs(customerId, containerNo)) {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId()));
Set<String> skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode)
.map(ReceivingContainerService::trim).filter(Objects::nonNull)
.map(value -> value.toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
int expected = 0;
int received = 0;
for (String skuCode : skuCodes) {
SkuRef sku = requireSku(ref, skuCode);
int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode());
if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container");
expected += sku.expectedPackages(); received += skuReceived;
}
if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) {
throw conflict("FBA SKU package snapshots must exactly match the inbound code total");
}
}
}
private String destinationType(InboundPlanLine line, LastlegOrder order) {
String value = line.getDestinationType();
if (value == null || value.isBlank()) value = order.getDestinationType();
if (value == null || value.isBlank()) value = line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank()
? "OVERSEAS" : "FBA";
value = value.trim().toUpperCase();
if (!List.of("FBA", "OVERSEAS").contains(value)) throw conflict("Unsupported destination type");
return value;
}
private void markReceiving(ReceivingContainerSession session) {
if (!"RECEIVING".equals(session.getStatus())) {
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
}
session.setUpdatedAt(LocalDateTime.now()); update(session);
}
private void update(ReceivingContainerSession session) {
if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently");
}
private ReceivingContainerSession waitingView(String customerId, String operatorKey,
WarehouseResourceGateway.WarehouseReference warehouse,
String containerNo) {
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
value.setOverseasWarehouseId(warehouse.warehouseId());
value.setOverseasWarehouseName(warehouse.warehouseName());
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
return value;
}
private boolean eligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo());
return warehouseEligible(containerPlans, overseasWarehouseId)
&& shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1;
}
private boolean warehouseEligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream()
.anyMatch(plan -> plan.getOverseasWarehouseId() == null))) {
throw conflict("Container plans have inconsistent receiving warehouse assignments");
}
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
}
private void requireReceivingAvailable(String customerId, String containerNo) {
if (shippingGates.receivingAvailable(customerId, containerNo) != 1) {
throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving");
}
}
private void claimReceivingWarehouse(String customerId, List<InboundPlan> containerPlans,
WarehouseResourceGateway.WarehouseReference warehouse) {
long overseasWarehouseId = warehouse.warehouseId();
String warehouseId = String.valueOf(overseasWarehouseId);
for (InboundPlan plan : containerPlans) {
int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId);
InboundPlan actual = plans.selectById(plan.getId());
if ((claimed == 0 && actual == null) || actual == null
|| !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Container was claimed by another overseas warehouse");
}
plan.setOverseasWarehouseId(overseasWarehouseId);
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId,
warehouse.warehouseName());
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) {
throw conflict("Inbound order was claimed by another overseas warehouse");
}
}
}
}
private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) {
if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Current container belongs to another overseas warehouse");
}
}
private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) {
key = idempotencyKey(key);
ReceivingContainerOperation value = operations.find(operatorKey, key);
if (value == null) return null;
if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) {
throw conflict("Idempotency key was already used with a different request");
}
return value;
}
private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) {
key = idempotencyKey(key);
ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId());
value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId());
value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key);
value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result));
value.setCreatedAt(LocalDateTime.now()); operations.insert(value);
}
private List<ReceivingContainerResponses.SignedPhoto> signPhotos(ReceivingContainerSession session) {
if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of();
try { return objectMapper.readValue(session.getSignPhotos(),
new TypeReference<List<ReceivingContainerResponses.SignedPhoto>>() { }); }
catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read signed receiving photos", exception); }
}
private String hash(Object value) {
try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
}
private <T> T read(ReceivingContainerOperation operation, Class<T> type) {
try { return objectMapper.readValue(operation.getResultJson(), type); }
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
}
private static String activeOperatorKey(String operatorKey) { return "OPERATOR:" + operatorKey; }
private static String activeContainerKey(String containerNo) { return "CONTAINER:" + containerNo.trim().toUpperCase(java.util.Locale.ROOT); }
private static String idempotencyKey(String value) {
if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required");
value = value.trim();
if (value.length() > 255) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key exceeds 255 characters");
return value;
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
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 record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { }
private record SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { }
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { }
private record PrepareCommand(String containerNo, String idempotencyKey) { }
}
@@ -228,7 +228,8 @@ public class InboundPlanService {
.eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo)
.filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1;
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence));
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence));
pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode());
pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM);
pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED");
@@ -249,6 +250,8 @@ public class InboundPlanService {
line.setPlanId(plan.getId());
line.setOrderId(order.orderId());
line.setInboundCode(order.inboundCode());
line.setDestinationType(order.fbaWarehouseCode() == null || order.fbaWarehouseCode().isBlank()
? "OVERSEAS" : "FBA");
line.setFbaWarehouseCode(order.fbaWarehouseCode());
line.setDestinationWarehouseId(order.destinationWarehouseId());
line.setDestinationWarehouseCode(order.destinationWarehouseCode());
@@ -315,7 +318,8 @@ public class InboundPlanService {
private void validateOrders(List<SourceOrderSnapshot> orders) {
for (SourceOrderSnapshot order : orders) {
if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) {
if (!("PENDING_ASSIGNMENT".equals(order.currentStatus())
|| "DESTINATION_ASSIGNED".equals(order.currentStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
}
@@ -17,7 +17,7 @@ public final class InventoryCommandRequests {
String overseasWarehouseCode,
String overseasWarehouseName,
String destinationWarehouseId,
@NotBlank String destinationWarehouseCode,
String destinationWarehouseCode,
String destinationWarehouseName,
@NotNull Long inboundOrderId,
@NotBlank String inboundOrderNo,
@@ -44,7 +44,7 @@ public final class InventoryCommandRequests {
String overseasWarehouseCode,
String overseasWarehouseName,
String destinationWarehouseId,
@NotBlank String destinationWarehouseCode,
String destinationWarehouseCode,
String destinationWarehouseName,
@NotNull Long inboundOrderId,
@NotBlank String inboundOrderNo,
@@ -233,8 +233,14 @@ public class InventoryCommandService {
String inboundCode, String requestedSkuCode, String productNameZh,
String productNameEn) {
String skuCode = sku(requestedSkuCode);
String destinationId = trim(destinationWarehouseId);
String destinationCode = trim(destinationWarehouseCode);
if (destinationId == null && destinationCode == null) {
throw badRequest("Destination warehouse ID or code is required");
}
if (destinationCode == null) destinationCode = "";
InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId,
destinationWarehouseCode, inboundOrderId, inboundCode);
destinationCode, inboundOrderId, inboundCode);
if (balance != null) {
mergeSkuSnapshot(balance, skuCode);
return balance;
@@ -246,8 +252,8 @@ public class InventoryCommandService {
balance.setOverseasWarehouseId(overseasWarehouseId.trim());
balance.setOverseasWarehouseCode(trim(overseasWarehouseCode));
balance.setOverseasWarehouseName(trim(overseasWarehouseName));
balance.setDestinationWarehouseId(trim(destinationWarehouseId));
balance.setDestinationWarehouseCode(destinationWarehouseCode.trim());
balance.setDestinationWarehouseId(destinationId);
balance.setDestinationWarehouseCode(destinationCode);
balance.setDestinationWarehouseName(trim(destinationWarehouseName));
balance.setInboundOrderId(inboundOrderId);
balance.setInboundOrderNo(inboundOrderNo.trim());
@@ -264,7 +270,7 @@ public class InventoryCommandService {
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId,
destinationWarehouseCode, inboundOrderId, inboundCode);
destinationCode, inboundOrderId, inboundCode);
if (concurrent != null) {
mergeSkuSnapshot(concurrent, skuCode);
return concurrent;
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.inventory.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -40,8 +41,11 @@ public class InventoryLedger {
private Long deltaPackages;
private Long afterPackages;
/** Quantity snapshot is retained even though current PDA operations are carton-count based. */
@TableField(exist = false)
private Long beforeQuantity;
@TableField(exist = false)
private Long deltaQuantity;
@TableField(exist = false)
private Long afterQuantity;
private String operatorId;
private String remark;
@@ -22,6 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -40,9 +41,9 @@ public class InventoryQueryController {
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
requirePlatform(user);
return ApiResponse.success(service.dashboard(null, query));
return ApiResponse.success(service.dashboard(platformScope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances")
@@ -50,9 +51,9 @@ public class InventoryQueryController {
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
requirePlatform(user);
return ApiResponse.success(service.balances(null, query));
return ApiResponse.success(service.balances(platformScope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@@ -61,9 +62,9 @@ public class InventoryQueryController {
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long balanceId,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.PositionQuery query) {
requirePlatform(user);
return ApiResponse.success(service.positions(null, balanceId, query));
return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query));
}
@GetMapping(BASE_PATH + "/ledger")
@@ -71,9 +72,9 @@ public class InventoryQueryController {
@Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
requirePlatform(user);
return ApiResponse.success(service.ledgers(null, query));
return ApiResponse.success(service.ledgers(platformScope(user, customerId), query));
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@@ -81,9 +82,9 @@ public class InventoryQueryController {
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
requirePlatform(user);
return csv(service.exportBalances(null, query), "inventory-balances.csv");
return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@@ -91,9 +92,9 @@ public class InventoryQueryController {
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
requirePlatform(user);
return csv(service.exportLedgers(null, query), "inventory-ledger.csv");
return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
@@ -103,12 +104,14 @@ public class InventoryQueryController {
.body(body);
}
private void requirePlatform(AuthenticatedUser user) {
private String platformScope(AuthenticatedUser user, String requestedCustomerId) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
return requestedCustomerId == null || requestedCustomerId.isBlank() || "0".equals(requestedCustomerId.trim())
? null : requestedCustomerId.trim();
}
}
@@ -89,8 +89,6 @@ public interface InventoryQueryMapper {
@Select("<script>SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%')</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND (available_packages+allocated_packages+picked_packages) &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -68,5 +68,6 @@ public final class LocalLooseboardRequests {
public record ChangeStatus(
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 32) String status,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) { }
}
@@ -17,7 +17,7 @@ public final class LocalLooseboardResponses {
public record History(
String operationType, String fromStatus, String toStatus, String operatorId,
String changeDetail, String remark, LocalDateTime createdAt) { }
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark,
@@ -17,6 +17,7 @@ public class LocalLooseboardHistory {
private String fromStatus;
private String toStatus;
private String operatorId;
private String idempotencyKey;
private String changeDetail;
private String remark;
private LocalDateTime createdAt;
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.looseboard.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_local_looseboard_operation")
public class LocalLooseboardOperation {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private Long resultVersion;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.looseboard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LocalLooseboardOperationMapper extends BaseMapper<LocalLooseboardOperation> {
@Select("SELECT * FROM ll_local_looseboard_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1")
LocalLooseboardOperation findKey(@Param("customerId") String customerId,
@Param("orderId") Long orderId,
@Param("key") String key);
}
@@ -12,8 +12,10 @@ import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -51,6 +53,7 @@ public class LocalLooseboardService {
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@@ -88,7 +91,8 @@ public class LocalLooseboardService {
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, null, request.remark(), now);
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
key, null, request.remark(), now);
lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(),
"Local looseboard order submitted", now);
return detail(customerId, order.getOrderNo());
@@ -139,7 +143,7 @@ public class LocalLooseboardService {
touch(order, now);
orders.updateById(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
changeDetail(before, after), null, now);
null, changeDetail(before, after), null, now);
return response(order);
}
@@ -147,6 +151,15 @@ public class LocalLooseboardService {
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key);
if (existing != null) {
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency key was reused with a different looseboard status payload");
}
return response(order);
}
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String next = norm(request.status());
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
@@ -160,7 +173,13 @@ public class LocalLooseboardService {
touch(order, now);
orders.updateById(order);
syncLastlegOrder(order, next, now);
history(order, "STATUS", from, next, operatorId, null, request.remark(), now);
LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId);
operation.setCreatedAt(now); operations.insert(operation);
history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
return response(order);
}
@@ -204,7 +223,8 @@ public class LocalLooseboardService {
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
histories.listOrder(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
@@ -218,7 +238,7 @@ public class LocalLooseboardService {
}
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
String changeDetail, String remark, LocalDateTime now) {
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
LocalLooseboardHistory history = new LocalLooseboardHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
@@ -227,6 +247,7 @@ public class LocalLooseboardService {
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setIdempotencyKey(trim(idempotencyKey));
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
@@ -3,7 +3,11 @@ package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LastlegEventOutboxMapper extends BaseMapper<LastlegEventOutbox> {
@Select("SELECT * FROM ll_event_outbox WHERE event_key=#{eventKey} LIMIT 1")
LastlegEventOutbox findByEventKey(@Param("eventKey") String eventKey);
}
@@ -35,4 +35,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"AND shipped_package_count+#{packages} <= package_count")
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_name=#{warehouseName}," +
"updated_at=NOW(3) WHERE id=#{orderId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId,
@Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName);
}
@@ -23,6 +23,7 @@ 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 java.nio.charset.StandardCharsets;
@@ -54,9 +55,11 @@ public class PlatformOverseasQuoteController {
@GetMapping("/warehouse-options")
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
@Operation(summary = "List cached active overseas partner warehouse options",
description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs")
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions() {
return ApiResponse.success(service.warehouseOptions());
description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs; "
+ "refresh=true bypasses the positive cache after CN partner master data changes")
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions(
@RequestParam(defaultValue = "false") boolean refresh) {
return ApiResponse.success(service.warehouseOptions(refresh));
}
@PostMapping
@@ -73,8 +73,8 @@ public class OverseasQuoteService {
quote.getCreatedBy(), quote.getCreatedAt());
}
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions() {
return warehouses.options();
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions(boolean refresh) {
return refresh ? warehouses.refresh() : warehouses.options();
}
@Transactional
@@ -6,6 +6,7 @@ import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.Clock;
@@ -23,26 +24,39 @@ public class PartnerWarehouseDirectory {
private volatile Snapshot snapshot;
public List<OverseasQuoteResponses.PartnerWarehouseOption> options() {
return options(false);
}
/** Bypasses a still-valid positive snapshot after CN partner master data changes. */
public List<OverseasQuoteResponses.PartnerWarehouseOption> refresh() {
return options(true);
}
private List<OverseasQuoteResponses.PartnerWarehouseOption> options(boolean forceRefresh) {
Snapshot current = snapshot;
Instant now = clock.instant();
if (current != null && now.isBefore(current.expiresAt())) return current.options();
if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options();
synchronized (this) {
current = snapshot;
now = clock.instant();
if (current != null && now.isBefore(current.expiresAt())) return current.options();
if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options();
var response = client.activePartners(internalToken);
if (response == null || !response.successful() || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"国内海外合作仓候选暂不可用");
}
List<OverseasQuoteResponses.PartnerWarehouseOption> options = response.data().stream()
.filter(value -> value != null && value.id() != null)
.filter(this::valid)
.map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(),
value.warehouseCode(), value.warehouseName(), value.countryCode()))
value.warehouseCode().trim(), value.warehouseName().trim(),
value.countryCode().trim().toUpperCase(java.util.Locale.ROOT)))
.toList();
// An empty directory can be a short-lived cross-region visibility gap after a partner
// warehouse is created. Do not preserve that absence for the normal 30-minute TTL.
if (options.isEmpty()) return List.of();
if (options.isEmpty()) {
snapshot = null;
return List.of();
}
snapshot = new Snapshot(options, now.plus(ttl));
return snapshot.options();
}
@@ -54,6 +68,14 @@ public class PartnerWarehouseDirectory {
"所选海外合作仓不存在或已停用"));
}
private boolean valid(DomesticPartnerWarehouseClient.Option value) {
return value != null && value.id() != null
&& StringUtils.hasText(value.warehouseCode())
&& StringUtils.hasText(value.warehouseName())
&& StringUtils.hasText(value.countryCode())
&& value.countryCode().trim().matches("(?i)[A-Z]{2}");
}
private record Snapshot(List<OverseasQuoteResponses.PartnerWarehouseOption> options, Instant expiresAt) {
private Snapshot { options = List.copyOf(options); }
}
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.function.Supplier;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
private final VehicleApplicationService service;
public PlatformVehicleController(VehicleApplicationService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleModels.Response>> page(@Valid VehicleModels.Query query) {
return execute(() -> ApiResponse.success(service.page(query)));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleModels.Response> detail(@PathVariable long id) {
return execute(() -> ApiResponse.success(service.detail(id)));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleModels.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleModels.Create request) {
return execute(() -> ApiResponse.success(service.create(operator(user), request)));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleModels.Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody VehicleModels.Update request) {
return execute(() -> ApiResponse.success(service.update(id, operator(user), request)));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleModels.Response> status(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody VehicleModels.ChangeStatus request) {
return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request)));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleModels.Query query) {
return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN";
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private <T> T execute(Supplier<T> action) {
try {
return action.get();
} catch (VehicleFailure failure) {
HttpStatus status = switch (failure.kind()) {
case BAD_REQUEST -> HttpStatus.BAD_REQUEST;
case NOT_FOUND -> HttpStatus.NOT_FOUND;
case CONFLICT -> HttpStatus.CONFLICT;
};
throw new ResponseStatusException(status, failure.getMessage(), failure);
}
}
}
@@ -1,13 +1,12 @@
package com.baiyue.lastleg.masterdata.vehicle.mapper;
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface VehicleMapper extends BaseMapper<Vehicle> {
public interface VehicleMapper extends BaseMapper<VehicleRecord> {
@Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE")
Vehicle lockById(@Param("id") long id);
VehicleRecord lockById(@Param("id") long id);
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Optional;
@Repository
public class VehiclePersistenceAdapter implements VehicleRepository {
private final VehicleMapper vehicles;
public VehiclePersistenceAdapter(VehicleMapper vehicles) {
this.vehicles = vehicles;
}
@Override
public PageSlice page(SearchCriteria criteria) {
Page<VehicleRecord> page = vehicles.selectPage(Page.of(criteria.page(), criteria.size()), wrapper(criteria));
return new PageSlice(page.getRecords().stream().map(this::snapshot).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
@Override
public List<VehicleSnapshot> list(SearchCriteria criteria) {
return vehicles.selectList(wrapper(criteria)).stream().map(this::snapshot).toList();
}
@Override
public Optional<VehicleSnapshot> findById(long id) {
return Optional.ofNullable(vehicles.selectById(id)).map(this::snapshot);
}
@Override
public Optional<VehicleSnapshot> lockById(long id) {
return Optional.ofNullable(vehicles.lockById(id)).map(this::snapshot);
}
@Override
public long create(NewVehicle vehicle) {
VehicleRecord record = new VehicleRecord();
record.setId(IdWorker.getId());
record.setPlateNumber(vehicle.plateNumber());
record.setModel(vehicle.model());
record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km());
record.setMileageKm(vehicle.mileageKm());
record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany());
record.setPhone(vehicle.phone());
record.setRegion(vehicle.region());
record.setOwnership(vehicle.ownership());
record.setStatus(vehicle.status());
record.setRemark(vehicle.remark());
record.setRecordVersion(0L);
record.setCreatedBy(vehicle.operator());
record.setUpdatedBy(vehicle.operator());
record.setCreatedAt(vehicle.createdAt());
record.setUpdatedAt(vehicle.createdAt());
try {
vehicles.insert(record);
} catch (DuplicateKeyException exception) {
throw new DuplicatePlateException(exception);
}
return record.getId();
}
@Override
public boolean update(long id, long expectedVersion, VehicleChanges changes) {
try {
return vehicles.update(null, new LambdaUpdateWrapper<VehicleRecord>()
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getPlateNumber, changes.plateNumber())
.set(VehicleRecord::getModel, changes.model())
.set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km())
.set(VehicleRecord::getMileageKm, changes.mileageKm())
.set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany())
.set(VehicleRecord::getPhone, changes.phone()).set(VehicleRecord::getRegion, changes.region())
.set(VehicleRecord::getOwnership, changes.ownership()).set(VehicleRecord::getRemark, changes.remark())
.set(VehicleRecord::getUpdatedBy, changes.operator()).set(VehicleRecord::getUpdatedAt, changes.updatedAt())
.setSql("record_version = record_version + 1")) == 1;
} catch (DuplicateKeyException exception) {
throw new DuplicatePlateException(exception);
}
}
@Override
public boolean updateStatus(long id, long expectedVersion, String status, String operator,
java.time.LocalDateTime updatedAt) {
return vehicles.update(null, new LambdaUpdateWrapper<VehicleRecord>()
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getStatus, status).set(VehicleRecord::getUpdatedBy, operator)
.set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1;
}
private LambdaQueryWrapper<VehicleRecord> wrapper(SearchCriteria criteria) {
return new LambdaQueryWrapper<VehicleRecord>()
.and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword())
.or().like(VehicleRecord::getModel, criteria.keyword())
.or().like(VehicleRecord::getDriverOrDispatchCompany, criteria.keyword()))
.eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership())
.eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region())
.eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status())
.orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId);
}
private VehicleSnapshot snapshot(VehicleRecord value) {
return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private static long version(Long value) {
return value == null ? 0L : value;
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.entity;
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -10,7 +10,7 @@ import java.time.LocalDateTime;
@Data
@TableName("ll_vehicle")
public class Vehicle {
public class VehicleRecord {
@TableId(type = IdType.INPUT) private Long id;
private String plateNumber;
private String model;
@@ -0,0 +1,165 @@
package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleChanges;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleApplicationService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
private final VehicleRepository vehicles;
public VehicleApplicationService(VehicleRepository vehicles) {
this.vehicles = vehicles;
}
public PageResult<VehicleModels.Response> page(VehicleModels.Query query) {
var page = vehicles.page(criteria(query));
return new PageResult<>(page.records().stream().map(this::response).toList(), page.page(),
page.size(), page.total(), page.pages());
}
public VehicleModels.Response detail(long id) {
return response(required(id));
}
@Transactional
public VehicleModels.Response create(String operator, VehicleModels.Create request) {
LocalDateTime now = LocalDateTime.now();
long id;
try {
id = vehicles.create(new NewVehicle(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
return detail(id);
}
@Transactional
public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) {
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码");
boolean updated;
try {
updated = vehicles.update(id, request.recordVersion(), new VehicleChanges(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now()));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleModels.Response changeStatus(long id, String operator, VehicleModels.ChangeStatus request) {
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.status())) return response(current);
boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now());
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleModels.Query query) {
var rows = vehicles.list(criteria(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private SearchCriteria criteria(VehicleModels.Query query) {
return new SearchCriteria(query.page(), query.size(), trim(query.keyword()),
optional(query.ownership(), OWNERSHIPS, "车辆归属"),
optional(query.region(), REGIONS, "车辆区域"),
optional(query.status(), STATUSES, "车辆状态"));
}
private VehicleSnapshot required(long id) {
return vehicles.findById(id).orElseThrow(VehicleApplicationService::notFound);
}
private VehicleSnapshot locked(long id) {
return vehicles.lockById(id).orElseThrow(VehicleApplicationService::notFound);
}
private VehicleModels.Response response(VehicleSnapshot value) {
return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(),
value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(),
value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(),
value.updatedBy(), value.updatedAt());
}
private void requireVersion(VehicleSnapshot value, long expected) {
if (value.recordVersion() != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法");
return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
return value.trim();
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); }
private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); }
private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); }
public enum FailureKind { BAD_REQUEST, NOT_FOUND, CONFLICT }
public static final class VehicleFailure extends RuntimeException {
private final FailureKind kind;
public VehicleFailure(FailureKind kind, String message) {
super(message);
this.kind = kind;
}
public FailureKind kind() {
return kind;
}
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.dto;
package com.baiyue.lastleg.masterdata.vehicle.application.model;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
@@ -11,8 +11,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class VehicleDtos {
private VehicleDtos() { }
public final class VehicleModels {
private VehicleModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, String ownership, String region, String status) {
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.masterdata.vehicle.application.port.out;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface VehicleRepository {
PageSlice page(SearchCriteria criteria);
List<VehicleSnapshot> list(SearchCriteria criteria);
Optional<VehicleSnapshot> findById(long id);
Optional<VehicleSnapshot> lockById(long id);
long create(NewVehicle vehicle);
boolean update(long id, long expectedVersion, VehicleChanges changes);
boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt);
record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { }
record PageSlice(List<VehicleSnapshot> records, long page, long size, long total, long pages) { }
record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String status,
String remark, String operator, LocalDateTime createdAt) { }
record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String remark,
String operator, LocalDateTime updatedAt) { }
record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
long mileageKm, String driverOrDispatchCompany, String phone, String region,
String ownership, String status, String remark, long recordVersion,
String updatedBy, LocalDateTime updatedAt) { }
final class DuplicatePlateException extends RuntimeException {
public DuplicatePlateException(Throwable cause) {
super(cause);
}
}
}
@@ -1,58 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.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.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
@Resource private VehicleService service;
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleDtos.Response>> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); }
@GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleDtos.Response> detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); }
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); }
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); }
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleDtos.Query query) {
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -1,143 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
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.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
@Resource private VehicleMapper vehicles;
public PageResult<VehicleDtos.Response> page(VehicleDtos.Query query) {
Page<Vehicle> page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
public VehicleDtos.Response detail(long id) { return response(required(id)); }
@Transactional
public VehicleDtos.Response create(String operator, VehicleDtos.Create request) {
LocalDateTime now = LocalDateTime.now();
Vehicle value = new Vehicle(); value.setId(IdWorker.getId());
value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT));
value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km());
value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany()));
value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership()));
value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
return detail(value.getId());
}
@Transactional
public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码");
int updated;
try {
updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT))
.set(Vehicle::getModel, requiredText(request.model(), "车型"))
.set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km())
.set(Vehicle::getMileageKm, request.mileageKm())
.set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany()))
.set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region()))
.set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark()))
.set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
} catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.getStatus())) return response(current);
int updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator)
.set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleDtos.Query query) {
var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<Vehicle> wrapper(VehicleDtos.Query query) {
String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属");
String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态");
return new LambdaQueryWrapper<Vehicle>()
.and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword)
.or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword))
.eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region)
.eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId);
}
private Vehicle required(long id) {
Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value;
}
private Vehicle locked(long id) {
Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value;
}
private VehicleDtos.Response response(Vehicle value) {
return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private void requireVersion(Vehicle value, long expected) {
if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim();
}
private static long version(Long value) { return value == null ? 0L : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); }
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); }
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.palletops.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.service.PdaPalletOperationService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/pallet-operations")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaPalletOperationController {
@Resource private PdaPalletOperationService service;
@PostMapping("/pallets/resolve")
public ApiResponse<PalletOperationResponses.PalletContent> resolve(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Scan request) {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/assemble")
public ApiResponse<PalletOperationResponses.Operation> assemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Assemble request) {
return ApiResponse.success(service.assemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/disassemble")
public ApiResponse<PalletOperationResponses.Operation> disassemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Disassemble request) {
return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/swap")
public ApiResponse<PalletOperationResponses.Operation> swap(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Swap request) {
return ApiResponse.success(service.swap(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/unbind")
public ApiResponse<PalletOperationResponses.Operation> unbind(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Unbind request) {
return ApiResponse.success(service.unbind(PdaWarehouseIdentity.require(user), request));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.palletops.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PalletOperationRequests {
private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
@PositiveOrZero int expectedVersion) { }
public record MoveLine(@Positive long positionId, @Positive long packages,
@PositiveOrZero long expectedPositionVersion) { }
public record TargetMove(@Valid PalletExpectation target,
@NotEmpty @Size(max = 200) List<@Valid MoveLine> lines) { }
public record Assemble(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation target,
@NotEmpty @Size(max = 20) List<@Valid PalletExpectation> sources,
@NotEmpty @Size(max = 200) List<@Valid MoveLine> lines,
@Size(max = 500) String reason) { }
public record Disassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@Size(max = 500) String reason) { }
public record Unbind(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Size(max = 500) String reason) { }
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.palletops.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PalletOperationResponses {
private PalletOperationResponses() { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
String inboundCode, String skuCode, String productName,
long availablePackages, long holdPackages, long allocatedPackages,
long pickedPackages, long receivingPackages, long positionVersion) { }
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
long locationId, String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
public record Operation(long operationId, String operationType, String idempotencyKey,
String status, boolean replayed, LocalDateTime completedAt,
List<PalletContent> pallets) {
public Operation asReplay() {
return new Operation(operationId, operationType, idempotencyKey, status, true, completedAt, pallets);
}
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.palletops.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_pda_pallet_operation")
public class PdaPalletOperation {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String operationType;
private String requestHash;
private String status;
private String resultJson;
private String remoteTransitionsJson;
private String lastError;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.palletops.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initialize() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getPalletPhysical());
}
@Override
public PalletReference resolve(String palletCode) {
ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected pallet scan: HTTP " + result.getStatusCode());
}).body(ResolveEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no pallet");
return response.data();
}
@Override
public LocationReference resolveLocation(long warehouseId, long locationId) {
LocationEnvelope response = client.get().uri(uri -> uri
.path("/internal/v1/warehouse-resource-locks/locations/{locationId}")
.queryParam("warehouseId", warehouseId).build(locationId))
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected location lookup: HTTP " + result.getStatusCode());
}).body(LocationEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no location");
return response.data();
}
@Override
public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version());
body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId);
body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason);
TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId())
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet transition: HTTP " + result.getStatusCode());
}).body(TransitionEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no transition");
return response.data();
}
@Override
public void compensate(long warehouseId, Transition transition) {
client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate",
transition.palletId(), transition.operationKey())
.header("X-Internal-Service-Token", internalToken).body(Map.of("warehouseId", warehouseId))
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet compensation: HTTP " + result.getStatusCode());
}).toBodilessEntity();
}
private record ResolveEnvelope(PalletReference data) { }
private record LocationEnvelope(LocationReference data) { }
private record TransitionEnvelope(Transition data) { }
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.palletops.gateway;
public interface PalletPhysicalGateway {
PalletReference resolve(String palletCode);
LocationReference resolveLocation(long warehouseId, long locationId);
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason);
void compensate(long warehouseId, Transition transition);
record PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) { }
record LocationReference(long locationId, String locationCode, String locationType, String status) { }
record Transition(String operationKey, long palletId, String palletCode, long warehouseId,
String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId,
int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.palletops.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
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 PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation> {
String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
"b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," +
"p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," +
"p.available_packages,p.hold_packages,p.allocated_packages,p.picked_packages,p.receiving_packages,p.version " +
"FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id ";
@Select("SELECT * FROM ll_pda_pallet_operation WHERE operator_key=#{operatorKey} " +
"AND idempotency_key=#{idempotencyKey} LIMIT 1")
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
@Param("idempotencyKey") String idempotencyKey);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)&gt;0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
List<PalletInventoryRow> content(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.id=#{positionId} " +
"LIMIT 1 FOR UPDATE")
PalletInventoryRow lockPosition(@Param("warehouseId") String warehouseId,
@Param("positionId") long positionId);
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " +
"AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE")
InventoryPosition lockTarget(@Param("customerId") String customerId, @Param("balanceId") long balanceId,
@Param("palletId") long palletId, @Param("batchNo") String batchNo);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE pallet_id=#{palletId} AND overseas_warehouse_id=#{warehouseId}")
long palletPackages(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId);
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.palletops.model;
import lombok.Data;
@Data
public class PalletInventoryRow {
private String customerId;
private String customerName;
private Long balanceId;
private Long positionId;
private String inboundCode;
private String skuCode;
private String productNameZh;
private String productNameEn;
private String destinationWarehouseCode;
private Long palletId;
private String palletNo;
private String batchNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
private Long pickedPackages;
private Long receivingPackages;
private Long version;
}
@@ -0,0 +1,506 @@
package com.baiyue.lastleg.palletops.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class PdaPalletOperationService {
@Resource private PdaPalletOperationMapper mapper;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
@Resource
void configureTransactions(PlatformTransactionManager manager) {
transaction = new TransactionTemplate(manager);
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
newTransaction = new TransactionTemplate(manager, definition);
}
public PalletOperationResponses.PalletContent resolve(PdaWarehouseIdentity identity,
PalletOperationRequests.Scan request) {
PalletPhysicalGateway.PalletReference pallet = requirePallet(identity.overseasWarehouseId(), request.code());
return content(identity.overseasWarehouseId(), pallet);
}
public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Assemble request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.target(), request.sources());
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
Map<String, PalletPhysicalGateway.PalletReference> sources = new LinkedHashMap<>();
request.sources().forEach(value -> sources.put(code(value.palletCode()), pallets.get(code(value.palletCode()))));
List<PlannedMove> moves = plannedLines(identity.overseasWarehouseId(), request.lines(), sources,
target, null);
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
return execute(identity, "PALLET_ASSEMBLE", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
}
public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Disassemble request) {
LinkedHashMap<String, PalletOperationRequests.PalletExpectation> expectations = new LinkedHashMap<>();
expectations.put(code(request.source().palletCode()), request.source());
request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target()));
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
expectations.values());
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
List<PlannedMove> moves = new ArrayList<>();
for (PalletOperationRequests.TargetMove targetMove : request.targets()) {
PalletPhysicalGateway.PalletReference target = pallets.get(code(targetMove.target().palletCode()));
moves.addAll(plannedLines(identity.overseasWarehouseId(), targetMove.lines(), Map.of(
code(request.source().palletCode()), source), target, source));
}
requireUniqueLines(moves);
List<PalletPhysicalGateway.PalletReference> targets = request.targets().stream()
.map(value -> pallets.get(code(value.target().palletCode()))).toList();
requireCommonDestination(identity.overseasWarehouseId(), moves, targets);
return execute(identity, "PALLET_DISASSEMBLE", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves,
targets.stream().map(PalletPhysicalGateway.PalletReference::palletId).collect(java.util.stream.Collectors.toSet()),
false));
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
PalletOperationRequests.Swap request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.source(), List.of(request.target()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
if (!mapper.content(String.valueOf(identity.overseasWarehouseId()), target.palletId()).isEmpty()) {
throw conflict("换托目标托盘必须为空");
}
List<PalletInventoryRow> rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId());
List<PlannedMove> moves = rows.stream().map(row -> {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()));
}).toList();
return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
}
public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity,
PalletOperationRequests.Unbind request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
List.of(request.source()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
List<PalletInventoryRow> rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId());
List<PlannedMove> moves = rows.stream().map(row -> {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(),
locationCode(identity.overseasWarehouseId(), source.locationId()));
}).toList();
return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true));
}
private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key,
Object request, String reason, Plan plan) {
String normalizedKey = required(key, "幂等键不能为空");
String requestHash = hash(request);
PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
List<PalletPhysicalGateway.Transition> transitions = new ArrayList<>();
try {
for (PalletChange change : plan.changes()) {
transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey,
change.pallet().palletId()), reason));
}
saveTransitions(operation.getId(), transitions);
PalletOperationResponses.Operation result = transaction.execute(status ->
mutate(identity, operation, plan, normalizedKey, requestHash, reason));
if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果");
return result;
} catch (RuntimeException failure) {
boolean compensated = compensate(identity.overseasWarehouseId(), transitions);
fail(operation.getId(), failure, compensated);
throw failure;
}
}
private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation,
Plan plan, String key, String requestHash, String reason) {
Set<Long> seen = new HashSet<>();
LinkedHashSet<String> customers = new LinkedHashSet<>();
for (PlannedMove move : plan.moves()) {
if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动");
PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()),
move.positionId());
if (locked == null || !Objects.equals(locked.getPalletId(), move.sourcePalletId())) {
throw conflict("库存所在托盘已变化,请重新扫码");
}
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(locked, move.packages());
customers.add(locked.getCustomerId());
moveInventory(identity, operation, locked, move, key, reason);
}
for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
if (shouldBeEmpty != (actual == 0)) {
throw conflict("库存与实物托盘状态不一致,作业已回滚");
}
}
List<PalletOperationResponses.PalletContent> contents = plan.pallets().values().stream()
.sorted(Comparator.comparingLong(PalletPhysicalGateway.PalletReference::palletId))
.map(pallet -> content(identity.overseasWarehouseId(), physical.resolve(pallet.palletCode())))
.toList();
PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(),
operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents);
operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setLastError(null);
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
history.record(identity.overseasWarehouseId(), customers.size() == 1 ? customers.iterator().next() : null,
identity.operatorKey(), operation.getOperationType(), businessKey(plan), "COMPLETED", key, requestHash,
"托盘作业完成,移动库存行 " + plan.moves().size() + " 条");
return result;
}
private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
if (source == null) throw conflict("库存行不存在");
long sourceBefore = value(source.getAvailablePackages());
source.setAvailablePackages(sourceBefore - move.packages());
source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED");
source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now());
positions.updateById(source);
InventoryPosition target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(),
move.targetPalletId(), source.getBatchNo());
if (target == null) {
target = new InventoryPosition(); target.setId(IdWorker.getId());
target.setCustomerId(source.getCustomerId()); target.setBalanceId(source.getBalanceId());
target.setOverseasWarehouseId(source.getOverseasWarehouseId());
target.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
target.setPalletId(move.targetPalletId()); target.setPalletNo(move.targetPalletCode());
target.setBatchNo(source.getBatchNo()); target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setAvailablePackages(0L); target.setHoldPackages(0L); target.setAllocatedPackages(0L);
target.setPickedPackages(0L); target.setReceivingPackages(0L); target.setStatus("OCCUPIED");
target.setVersion(0L); target.setCreatedAt(LocalDateTime.now()); target.setUpdatedAt(target.getCreatedAt());
try { positions.insert(target); }
catch (DuplicateKeyException duplicate) {
target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), move.targetPalletId(), source.getBatchNo());
if (target == null) throw duplicate;
}
}
long targetBefore = value(target.getAvailablePackages());
target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED");
target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now());
positions.updateById(target);
InventoryBalance balance = balances.selectById(source.getBalanceId());
if (balance == null) throw new IllegalStateException("库存余额不存在");
ledger(identity, operation, balance, source, key + ":" + source.getId() + ":OUT",
sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN",
targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
}
private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance,
InventoryPosition position, String idempotencyKey, long before, long delta, long after,
String sourcePallet, String targetPallet, String reason) {
InventoryLedger ledger = new InventoryLedger(); ledger.setId(IdWorker.getId());
ledger.setCustomerId(balance.getCustomerId()); ledger.setBalanceId(balance.getId());
ledger.setPositionId(position.getId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
ledger.setInboundOrderId(balance.getInboundOrderId()); ledger.setPalletId(position.getPalletId());
ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo());
ledger.setSkuCode(balance.getSkuCode()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId());
ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode());
ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey);
ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet);
ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after);
ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason));
ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger);
}
private Plan plan(long warehouseId, Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, Set<Long> targets, boolean unbind) {
Map<Long, Long> movedBySource = new HashMap<>();
moves.forEach(move -> movedBySource.merge(move.sourcePalletId(), move.packages(), Long::sum));
List<PalletChange> changes = new ArrayList<>();
for (PalletPhysicalGateway.PalletReference pallet : pallets.values()) {
long total = mapper.palletPackages(String.valueOf(warehouseId), pallet.palletId());
long moved = movedBySource.getOrDefault(pallet.palletId(), 0L);
String targetStatus = targets.contains(pallet.palletId()) ? "OCCUPIED"
: moved > 0 && moved == total ? "IDLE" : pallet.status();
if ("SCRAPPED".equals(targetStatus)) throw conflict("已报废托盘不能执行作业");
changes.add(new PalletChange(pallet, targetStatus, pallet.locationId()));
}
if (unbind && changes.stream().noneMatch(change -> "IDLE".equals(change.targetStatus()))) {
throw conflict("解绑必须清空来源托盘");
}
changes.sort(Comparator.comparingLong(change -> change.pallet().palletId()));
return new Plan(pallets, moves, changes);
}
private List<PlannedMove> plannedLines(long warehouseId, List<PalletOperationRequests.MoveLine> lines,
Map<String, PalletPhysicalGateway.PalletReference> sources,
PalletPhysicalGateway.PalletReference target,
PalletPhysicalGateway.PalletReference requiredSource) {
Map<Long, PalletInventoryRow> available = new HashMap<>();
for (PalletPhysicalGateway.PalletReference source : sources.values()) {
for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), source.palletId())) {
available.put(row.getPositionId(), row);
}
}
List<PlannedMove> result = new ArrayList<>();
for (PalletOperationRequests.MoveLine line : lines) {
PalletInventoryRow row = available.get(line.positionId());
if (row == null || requiredSource != null && !Objects.equals(row.getPalletId(), requiredSource.palletId())) {
throw conflict("库存行不属于已扫描的来源托盘");
}
if (value(row.getVersion()) != line.expectedPositionVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(row, line.packages());
result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(),
row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(warehouseId, target.locationId())));
}
requireUniqueLines(result);
return result;
}
private void requireCommonDestination(long warehouseId, List<PlannedMove> moves,
List<PalletPhysicalGateway.PalletReference> targets) {
LinkedHashSet<String> destinations = new LinkedHashSet<>();
for (PlannedMove move : moves) {
PalletInventoryRow row = mapper.content(String.valueOf(warehouseId), move.sourcePalletId()).stream()
.filter(value -> Objects.equals(value.getPositionId(), move.positionId())).findFirst()
.orElseThrow(() -> conflict("库存行不存在"));
destinations.add(text(row.getDestinationWarehouseCode()));
}
for (PalletPhysicalGateway.PalletReference target : targets) {
mapper.content(String.valueOf(warehouseId), target.palletId()).stream()
.map(PalletInventoryRow::getDestinationWarehouseCode).map(PdaPalletOperationService::text)
.forEach(destinations::add);
}
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘");
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) {
List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest);
return resolvePallets(warehouseId, values);
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
java.util.Collection<PalletOperationRequests.PalletExpectation> values) {
LinkedHashMap<String, PalletPhysicalGateway.PalletReference> result = new LinkedHashMap<>();
for (PalletOperationRequests.PalletExpectation expected : values) {
String code = code(expected.palletCode());
if (result.containsKey(code)) throw badRequest("一次作业不能重复提交同一托盘");
PalletPhysicalGateway.PalletReference pallet = requirePallet(warehouseId, code);
if (pallet.version() != expected.expectedVersion()) throw conflict("托盘版本已变化,请重新扫码");
result.put(code, pallet);
}
return result;
}
private PalletPhysicalGateway.PalletReference requirePallet(long warehouseId, String code) {
PalletPhysicalGateway.PalletReference pallet = physical.resolve(code(code));
if (pallet.warehouseId() != warehouseId) throw conflict("托盘不属于 Token 冻结的海外仓");
if ("SCRAPPED".equals(pallet.status())) throw conflict("已报废托盘不能执行作业");
return pallet;
}
private List<PalletInventoryRow> nonEmptyContent(long warehouseId, long palletId) {
List<PalletInventoryRow> values = mapper.content(String.valueOf(warehouseId), palletId);
if (values.isEmpty()) throw conflict("来源托盘没有可操作库存");
return values;
}
private PalletOperationResponses.PalletContent content(long warehouseId,
PalletPhysicalGateway.PalletReference pallet) {
List<PalletInventoryRow> rows = mapper.content(String.valueOf(warehouseId), pallet.palletId());
LinkedHashSet<String> destinations = new LinkedHashSet<>();
List<PalletOperationResponses.InventoryLine> inventory = rows.stream().map(row -> {
destinations.add(text(row.getDestinationWarehouseCode()));
return new PalletOperationResponses.InventoryLine(row.getCustomerId(), row.getCustomerName(),
value(row.getBalanceId()), value(row.getPositionId()), row.getInboundCode(), row.getSkuCode(),
first(row.getProductNameZh(), row.getProductNameEn()), value(row.getAvailablePackages()),
value(row.getHoldPackages()), value(row.getAllocatedPackages()), value(row.getPickedPackages()),
value(row.getReceivingPackages()), value(row.getVersion()));
}).toList();
long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum();
String locationCode = locationCode(warehouseId, pallet.locationId());
return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(),
pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null,
total, inventory);
}
private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) {
PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), key);
if (existing != null) return validateExisting(existing, type, requestHash);
try {
return newTransaction.execute(status -> {
PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(IdWorker.getId());
operation.setOverseasWarehouseId(identity.overseasWarehouseId());
operation.setOperatorKey(identity.operatorKey()); operation.setIdempotencyKey(key);
operation.setOperationType(type); operation.setRequestHash(requestHash); operation.setStatus("PROCESSING");
operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt());
mapper.insert(operation); return operation;
});
} catch (DuplicateKeyException duplicate) {
PdaPalletOperation winner = mapper.findOperation(identity.operatorKey(), key);
if (winner != null) return validateExisting(winner, type, requestHash);
throw duplicate;
}
}
private PdaPalletOperation validateExisting(PdaPalletOperation value, String type, String requestHash) {
if (!type.equals(value.getOperationType()) || !requestHash.equals(value.getRequestHash())) {
throw conflict("幂等键已被不同托盘作业使用");
}
if (!"COMPLETED".equals(value.getStatus())) {
throw conflict("同一托盘作业正在处理或需要人工恢复");
}
return value;
}
private void saveTransitions(long operationId, List<PalletPhysicalGateway.Transition> transitions) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}
private void fail(long operationId, RuntimeException failure, boolean compensated) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
if (operation == null || "COMPLETED".equals(operation.getStatus())) return;
operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED");
operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}
private boolean compensate(long warehouseId, List<PalletPhysicalGateway.Transition> transitions) {
boolean success = true;
for (int i = transitions.size() - 1; i >= 0; i--) {
try { physical.compensate(warehouseId, transitions.get(i)); }
catch (RuntimeException ignored) { success = false; }
}
return success;
}
private PalletOperationResponses.Operation read(PdaPalletOperation operation) {
try { return objectMapper.readValue(operation.getResultJson(), PalletOperationResponses.Operation.class); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取托盘作业幂等结果", exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法序列化托盘作业", exception); }
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private static String transitionKey(String operator, String key, long palletId) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String locationCode(long warehouseId, long locationId) {
PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId);
if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) {
throw conflict("托盘库位不存在或已停用");
}
return location.locationCode();
}
private static void requireMovable(PalletInventoryRow row, long packages) {
if (packages <= 0 || value(row.getAvailablePackages()) < packages) throw conflict("可用库存箱数不足");
if (value(row.getHoldPackages()) > 0 || value(row.getAllocatedPackages()) > 0
|| value(row.getPickedPackages()) > 0 || value(row.getReceivingPackages()) > 0) {
throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业");
}
}
private static void requireUniqueLines(List<PlannedMove> moves) {
Set<Long> ids = new HashSet<>();
if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交");
}
private static long rowPackages(PalletInventoryRow row) {
return value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
+ value(row.getPickedPackages()) + value(row.getReceivingPackages());
}
private static long positionPackages(InventoryPosition value) {
return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + value(value.getAllocatedPackages())
+ value(value.getPickedPackages()) + value(value.getReceivingPackages());
}
private static String businessKey(Plan plan) {
return plan.pallets().values().stream().map(PalletPhysicalGateway.PalletReference::palletCode)
.sorted().collect(java.util.stream.Collectors.joining("->"));
}
private static String required(String value, String message) { String result = trim(value); if (result == null) throw badRequest(message); return result; }
private static String code(String value) { return required(value, "托盘码不能为空").toUpperCase(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String text(String value) { return value == null ? "" : value.trim(); }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); }
private static long value(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
long targetPalletId, String targetPalletCode, long targetLocationId,
String targetLocationCode) { }
private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus,
long targetLocationId) { }
private record Plan(Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, List<PalletChange> changes) { }
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/** Registers mapper packages used by the overseas PDA warehouse operation workflows. */
@Configuration
@MapperScan(basePackages = {
"com.baiyue.lastleg.palletops.mapper",
"com.baiyue.lastleg.pda.move.mapper",
"com.baiyue.lastleg.pda.stocktake.mapper",
"com.baiyue.lastleg.pda.warehouse.mapper"
})
public class PdaOperationsMapperConfiguration {
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.pda.move.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inventory-moves")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaInventoryMoveController {
@Resource private PdaInventoryMoveService service;
@PostMapping
public ApiResponse<PdaInventoryMoveResponse> move(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaInventoryMoveRequest request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.pda.move.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record PdaInventoryMoveRequest(
@NotBlank @Size(max = 128) String sourcePalletCode,
@NotBlank @Size(max = 128) String targetLocationCode,
@PositiveOrZero int expectedPalletVersion,
@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 500) String reason
) { }
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.pda.move.dto;
import java.time.LocalDateTime;
public record PdaInventoryMoveResponse(
String moveNo,
long palletId,
String palletCode,
long sourceLocationId,
String sourceLocationCode,
long targetLocationId,
String targetLocationCode,
int customerCount,
int positionCount,
long packageCount,
int palletVersion,
String status,
LocalDateTime completedAt,
boolean replayed
) {
public PdaInventoryMoveResponse asReplay() {
return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId,
sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount,
packageCount, palletVersion, status, completedAt, true);
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.move.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_pda_inventory_move")
public class PdaInventoryMove {
@TableId(type = IdType.INPUT) private Long id;
private String moveNo;
private Long overseasWarehouseId;
private Long palletId;
private String palletCode;
private Long sourceLocationId;
private String sourceLocationCode;
private Long targetLocationId;
private String targetLocationCode;
private Integer customerCount;
private Integer positionCount;
private Long packageCount;
private Integer palletVersionBefore;
private Integer palletVersionAfter;
private String operatorKey;
private String reason;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.pda.move.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
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 PdaInventoryMoveMapper extends BaseMapper<PdaInventoryMove> {
@Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaInventoryMove find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_id=#{palletId} AND " +
"(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " +
"ORDER BY customer_id,id FOR UPDATE")
List<InventoryPosition> lockPositions(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
}
@@ -0,0 +1,192 @@
package com.baiyue.lastleg.pda.move.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class PdaInventoryMoveService {
@Resource private PdaInventoryMoveMapper moves;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
@Transactional
public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, requestHash);
var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required"));
if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse");
var target = warehouse.resolveLocation(warehouseId,
required(request.targetLocationCode(), "Target location code is required"));
if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) {
throw conflict("Target location is not available");
}
boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId()
&& pallet.version() == request.expectedPalletVersion() + 1;
if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) {
throw conflict("Source pallet version changed; rescan it");
}
List<InventoryPosition> inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId());
if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory");
Set<Long> inventoryLocations = new HashSet<>();
inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId()));
if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
long sourceLocationId = inventoryLocations.iterator().next();
if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ");
var source = warehouse.resolveLocation(warehouseId, sourceLocationId);
long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum();
Set<String> customers = new HashSet<>();
inventory.forEach(position -> customers.add(position.getCustomerId()));
long moveId = IdWorker.getId();
PdaInventoryMove operation = new PdaInventoryMove();
operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId));
operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId());
operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId());
operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId());
operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size());
operation.setPositionCount(inventory.size()); operation.setPackageCount(packages);
operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey);
operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now());
try { moves.insert(operation); }
catch (DuplicateKeyException duplicate) {
PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, requestHash);
}
String transitionKey = transitionKey(operatorKey, key, pallet.palletId());
var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(),
warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion());
var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(),
transitionKey, request.reason());
if (relocated.afterLocationId() != target.locationId()
|| !"APPLIED".equals(relocated.operationStatus())) {
throw new IllegalStateException("Warehouse relocation returned another target location");
}
LocalDateTime completedAt = LocalDateTime.now();
for (InventoryPosition position : inventory) {
InventoryBalance balance = balances.selectById(position.getBalanceId());
if (balance == null || !position.getCustomerId().equals(balance.getCustomerId())
|| !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) {
throw new IllegalStateException("Inventory balance scope is inconsistent during relocation");
}
position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode());
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt);
positions.updateById(position);
ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(),
target.locationCode(), completedAt);
}
operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED");
operation.setCompletedAt(completedAt);
PdaInventoryMoveResponse response = response(operation, false);
operation.setResultJson(write(response)); moves.updateById(operation);
history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED",
"INVENTORY_MOVE:" + key, requestHash,
pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode()
+ "," + packages + " 箱");
return response;
}
private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was reused with a different inventory move");
}
if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) {
throw conflict("Inventory move is still processing");
}
try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); }
}
private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) {
return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(),
value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(),
value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(),
value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed);
}
private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance,
String operator, String key, String reason, String source, String target,
LocalDateTime now) {
InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId());
row.setBalanceId(balance.getId()); row.setPositionId(position.getId());
row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE");
row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId());
row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position);
row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages);
row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now);
ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private static String transitionKey(String operator, String key, long palletId) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId)
.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); }
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); }
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); }
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.pda.security;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER";
public static PdaWarehouseIdentity require(AuthenticatedUser user) {
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
|| !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"Authenticated platform warehouse PDA identity is required");
}
if (!user.permissions().contains(PICKER_ROLE)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required");
}
if (!user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"PDA operator must be bound to one overseas warehouse");
}
String operator = String.valueOf(user.userId());
return new PdaWarehouseIdentity(operator,
user.username() == null || user.username().isBlank() ? operator : user.username(),
user.overseasWarehouseId());
}
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.pda.stocktake.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.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.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.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/stocktakes")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaStocktakeController {
@Resource private PdaStocktakeService service;
@GetMapping
public ApiResponse<PageResult<PdaStocktakeResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.page(identity.overseasWarehouseId(), query));
}
@PostMapping
public ApiResponse<PdaStocktakeResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaStocktakeRequests.Create request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
@GetMapping("/{taskNo}")
public ApiResponse<PdaStocktakeResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo));
}
@PostMapping("/{taskNo}/counts")
public ApiResponse<PdaStocktakeResponses.Item> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
@PostMapping("/{taskNo}/submit")
public ApiResponse<PdaStocktakeResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class PdaStocktakeRequests {
private PdaStocktakeRequests() { }
public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) {
public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record Create(@NotBlank @Size(max = 16) String scopeType,
@NotBlank @Size(max = 128) String scopeCode,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages,
@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Submit(@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaStocktakeResponses {
private PdaStocktakeResponses() { }
public record Summary(String taskNo, String scopeType, String scopeCode, String status,
int positionCount, int countedCount, int differenceCount, long version,
LocalDateTime createdAt, LocalDateTime submittedAt) { }
public record Item(long itemId, String customerId, long balanceId, long positionId,
String skuCode, String productName, String inboundCode,
long palletId, String palletCode, long locationId, String locationCode,
long bookPackages, Long actualPackages, Long differencePackages,
String status, long positionVersion, long version) { }
public record Detail(Summary task, List<Item> items) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_action")
public class PdaStocktakeAction {
@TableId(type = IdType.INPUT) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String actionType;
private Long taskId;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_item")
public class PdaStocktakeItem {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long actualPackages;
private Long differencePackages;
private String status;
private Long positionVersion;
private String countedBy;
private Long version;
private LocalDateTime countedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_task")
public class PdaStocktakeTask {
@TableId(type = IdType.INPUT) private Long id;
private String taskNo;
private Long overseasWarehouseId;
private String scopeType;
private Long scopeResourceId;
private String scopeCode;
private String status;
private Integer positionCount;
private Integer countedCount;
private Integer differenceCount;
private String createdBy;
private String submittedBy;
private String submitReason;
private String idempotencyKey;
private String requestHash;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeActionMapper extends BaseMapper<PdaStocktakeAction> {
@Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeAction find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeInventoryMapper {
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
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 PdaStocktakeItemMapper extends BaseMapper<PdaStocktakeItem> {
String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," +
"COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," +
"p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
"p.warehouse_location_code location_code," +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," +
"p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " +
"ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} ";
@Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.id")
List<StocktakeCandidate> candidatesByPallet(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.pallet_no,p.id")
List<StocktakeCandidate> candidatesByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeItem> list(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE")
List<PdaStocktakeItem> lockAll(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE")
PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId);
@Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," +
"difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," +
"counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
"WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')")
int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion,
@Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages,
@Param("status") String status, @Param("operatorKey") String operatorKey);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL")
int counted(@Param("taskId") long taskId);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"AND actual_packages IS NOT NULL AND difference_packages&lt;&gt;0")
int differences(@Param("taskId") long taskId);
@Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," +
"pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," +
"status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeResponses.Item> views(@Param("taskId") long taskId);
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
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 PdaStocktakeTaskMapper extends BaseMapper<PdaStocktakeTask> {
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1")
PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1 FOR UPDATE")
PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("<script>SELECT task_no,scope_type,scope_code,status,position_count,counted_count,difference_count," +
"version,created_at,submitted_at FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status} </if>" +
"ORDER BY created_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<PdaStocktakeResponses.Summary> page(@Param("warehouseId") long warehouseId,
@Param("status") String status,
@Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status}</if></script>")
long count(@Param("warehouseId") long warehouseId, @Param("status") String status);
@Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," +
"version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " +
"AND status='IN_PROGRESS'")
int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount);
@Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," +
"submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," +
"version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'")
int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("operatorKey") String operatorKey, @Param("reason") String reason);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.pda.stocktake.model;
import lombok.Data;
@Data
public class StocktakeCandidate {
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long positionVersion;
}
@@ -0,0 +1,323 @@
package com.baiyue.lastleg.pda.stocktake.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@Service
public class PdaStocktakeService {
@Resource private PdaStocktakeTaskMapper tasks;
@Resource private PdaStocktakeItemMapper items;
@Resource private PdaStocktakeActionMapper actions;
@Resource private PdaStocktakeInventoryMapper inventory;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
public PageResult<PdaStocktakeResponses.Summary> page(long warehouseId, PdaStocktakeRequests.TaskQuery query) {
String status = status(query.status());
long offset = (query.page() - 1) * query.size();
List<PdaStocktakeResponses.Summary> rows = tasks.page(warehouseId, status, offset, query.size());
long total = tasks.count(warehouseId, status);
return new PageResult<>(rows, query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) {
PdaStocktakeTask task = requireTask(warehouseId, taskNo, false);
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey,
PdaStocktakeRequests.Create request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake");
return detail(replay);
}
String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase();
String scopeCode = required(request.scopeCode(), "Scope code is required");
long resourceId;
List<StocktakeCandidate> candidates;
if ("PALLET".equals(scopeType)) {
var pallet = warehouse.resolvePallet(scopeCode);
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
resourceId = pallet.palletId();
scopeCode = pallet.palletNo();
candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId);
} else if ("LOCATION".equals(scopeType)) {
var location = warehouse.resolveLocation(warehouseId, scopeCode);
if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) {
throw conflict("Stocktake location is not active");
}
resourceId = location.locationId();
scopeCode = location.locationCode();
candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode);
} else {
throw badRequest("scopeType must be LOCATION or PALLET");
}
if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope");
long taskId = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
PdaStocktakeTask task = new PdaStocktakeTask();
task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId));
task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId);
task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size());
task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey);
task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L);
task.setCreatedAt(now); task.setUpdatedAt(now);
try {
tasks.insert(task);
} catch (DuplicateKeyException duplicate) {
PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key);
if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate;
return detail(winner);
}
for (StocktakeCandidate candidate : candidates) {
PdaStocktakeItem item = new PdaStocktakeItem();
item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId());
item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId());
item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName());
item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId());
item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId());
item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages()));
item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion()));
item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item);
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS",
"STOCKTAKE_CREATE:" + key, requestHash,
scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置");
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Count request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed");
PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId());
if (item == null) throw notFound("Stocktake position was not found in this task");
long difference = request.actualPackages() - value(item.getBookPackages());
if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference,
difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) {
throw conflict("Stocktake item version changed; refresh and retry");
}
int counted = items.counted(task.getId());
int differences = items.differences(task.getId());
if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
PdaStocktakeResponses.Item response = items.views(task.getId()).stream()
.filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow();
complete(action, response);
return response;
}
@Transactional
public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Submit request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted");
if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry");
List<PdaStocktakeItem> taskItems = items.lockAll(task.getId());
if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) {
throw conflict("Every stocktake position must be counted before submission");
}
Map<Long, InventoryBalance> changedBalances = new HashMap<>();
LocalDateTime now = LocalDateTime.now();
for (PdaStocktakeItem item : taskItems) {
InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(),
String.valueOf(warehouseId));
if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) {
throw conflict("Inventory changed after counting; recreate the stocktake task");
}
long before = physical(position);
if (before != value(item.getBookPackages())) {
throw conflict("Inventory package total changed after counting; recreate the stocktake task");
}
long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages())
+ value(position.getPickedPackages()) + value(position.getReceivingPackages());
if (value(item.getActualPackages()) < fixed) {
throw conflict("Actual packages cannot be lower than reserved or processing packages");
}
long newAvailable = value(item.getActualPackages()) - fixed;
long delta = newAvailable - value(position.getAvailablePackages());
InventoryBalance balance = changedBalances.get(item.getBalanceId());
if (balance == null) {
balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId));
if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing");
changedBalances.put(item.getBalanceId(), balance);
}
long balanceAfter = value(balance.getAvailablePackages()) + delta;
if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative");
position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position);
balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now);
ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta,
value(item.getActualPackages()), now);
item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now);
item.setVersion(value(item.getVersion()) + 1); items.updateById(item);
}
changedBalances.values().forEach(balances::updateById);
if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED",
"STOCKTAKE_SUBMIT:" + key, requestHash,
"盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim());
PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false));
complete(action, response);
return response;
}
private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId()));
}
private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(),
task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()),
intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt());
}
private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) {
String normalized = required(taskNo, "Stocktake task number is required");
PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized);
if (task == null) throw notFound("Stocktake task was not found in the bound warehouse");
return task;
}
private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type,
String requestHash, String taskNo) {
PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, type, requestHash);
PdaStocktakeAction action = new PdaStocktakeAction();
action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey);
action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash);
PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId());
action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt());
try { actions.insert(action); return action; }
catch (DuplicateKeyException duplicate) {
PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, type, requestHash);
}
}
private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) {
if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) {
throw conflict("Idempotency key was reused with a different stocktake action");
}
if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing");
return action;
}
private void complete(PdaStocktakeAction action, Object response) {
action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now());
actions.updateById(action);
}
private <T> T read(PdaStocktakeAction action, Class<T> type) {
try { return objectMapper.readValue(action.getResultJson(), type); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); }
}
private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position,
InventoryBalance balance, String operator, String key, String reason,
long before, long delta, long after, LocalDateTime now) {
InventoryLedger row = new InventoryLedger();
row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId());
row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode());
row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId());
row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before);
row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim());
row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); }
}
private static String status(String value) {
String normalized = trim(value); if (normalized == null) return null;
normalized = normalized.toUpperCase();
if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status");
return normalized;
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static int intValue(Integer value) { return value == null ? 0 : value; }
private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
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); }
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.pda.warehouse.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.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaWarehouseController {
@Resource private PdaWarehouseService service;
@PostMapping("/inventory/resolve")
public ApiResponse<PdaWarehouseResponses.InventoryScan> inventory(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.inventory(identity.overseasWarehouseId(), request));
}
@PostMapping("/locations/resolve")
public ApiResponse<PdaWarehouseResponses.Location> location(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.location(identity.overseasWarehouseId(), request));
}
@PostMapping("/pallets/resolve")
public ApiResponse<PdaWarehouseResponses.Pallet> pallet(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pallet(identity.overseasWarehouseId(), request));
}
@GetMapping("/operations")
public ApiResponse<PageResult<PdaWarehouseResponses.Operation>> operations(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaWarehouseRequests.HistoryQuery query) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.history(identity.overseasWarehouseId(), identity.operatorKey(), query));
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda.warehouse.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public final class PdaWarehouseRequests {
private PdaWarehouseRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) {
public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.pda.warehouse.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaWarehouseResponses {
private PdaWarehouseResponses() { }
public record InventoryScan(String queryCode, List<InventoryItem> items) { }
public record InventoryItem(String customerId, String customerName, long balanceId, long positionId,
String skuCode, String productName, String inboundCode, String cartonMark,
long palletId, String palletCode, Long locationId, String locationCode,
long availablePackages, long holdPackages, long receivingPackages,
String status, long version) { }
public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg,
String status, long occupiedPackages, int inventoryPositionCount) { }
public record Pallet(long palletId, String palletCode, String status, int version,
long locationId, String locationCode, long occupiedPackages,
int inventoryPositionCount, List<InventoryItem> inventory) { }
public record Operation(long operationId, String operationType, String businessKey, String status,
String summary, String customerId, LocalDateTime occurredAt) { }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.pda.warehouse.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_pda_operation_history")
public class PdaOperationHistory {
@TableId(type = IdType.INPUT)
private Long id;
private Long overseasWarehouseId;
private String customerId;
private String operatorKey;
private String operationType;
private String businessKey;
private String status;
private String idempotencyKey;
private String requestHash;
private String summary;
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.pda.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
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 PdaWarehouseMapper extends BaseMapper<PdaOperationHistory> {
String INVENTORY_SELECT = "SELECT DISTINCT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
"COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," +
"COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," +
"COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," +
"c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
"p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," +
"p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " +
"ON p.balance_id=b.id AND p.customer_id=b.customer_id " +
"LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " +
"LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " +
"AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' ";
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
"AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " +
"OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
List<PdaInventoryProjection> scanInventory(@Param("warehouseId") String warehouseId,
@Param("code") String code);
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
List<PdaInventoryProjection> inventoryByPallet(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId,
@Param("code") String ignored);
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
"AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
List<PdaInventoryProjection> inventoryByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode,
@Param("code") String ignored);
@Select("<script>SELECT operation_id,operation_type,business_key,status,summary,customer_id,occurred_at FROM (" +
"SELECT h.id operation_id,h.operation_type,h.business_key,h.status,h.summary,h.customer_id,h.occurred_at " +
"FROM ll_pda_operation_history h WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
"UNION ALL SELECT o.id,CONCAT('RECEIVING_',o.action),s.container_no,'COMPLETED'," +
"CONCAT('柜号作业 ',s.container_no),s.customer_id,o.created_at " +
"FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>" +
") x ORDER BY occurred_at DESC,operation_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses.Operation> history(
@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
@Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM (SELECT h.id FROM ll_pda_operation_history h " +
"WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
"UNION ALL SELECT o.id FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>) x</script>")
long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
@Param("operationType") String operationType);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.pda.warehouse.model;
import lombok.Data;
@Data
public class PdaInventoryProjection {
private String customerId;
private String customerName;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productNameZh;
private String productNameEn;
private String inboundCode;
private String cartonMark;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long availablePackages;
private Long holdPackages;
private Long receivingPackages;
private String status;
private Long version;
}
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.pda.warehouse.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses;
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
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;
@Service
public class PdaWarehouseService {
@Resource private PdaWarehouseMapper mapper;
@Resource private WarehouseResourceGateway warehouse;
public PdaWarehouseResponses.InventoryScan inventory(long warehouseId, PdaWarehouseRequests.Scan request) {
String code = code(request.code());
List<PdaWarehouseResponses.InventoryItem> items = items(mapper.scanInventory(String.valueOf(warehouseId), code));
if (items.isEmpty()) throw notFound("Inventory code was not found in the bound warehouse");
return new PdaWarehouseResponses.InventoryScan(code, items);
}
public PdaWarehouseResponses.Location location(long warehouseId, PdaWarehouseRequests.Scan request) {
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
code(request.code()));
List<PdaWarehouseResponses.InventoryItem> inventory = items(mapper.inventoryByLocation(
String.valueOf(warehouseId), location.locationId(), location.locationCode(), "__NO_CARTON__"));
long packages = inventory.stream().mapToLong(this::packages).sum();
int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count();
return new PdaWarehouseResponses.Location(location.locationId(), location.locationCode(),
location.locationType(), location.maxLoadKg(), location.status(), packages, positions);
}
public PdaWarehouseResponses.Pallet pallet(long warehouseId, PdaWarehouseRequests.Scan request) {
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(code(request.code()));
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
pallet.locationId());
List<PdaWarehouseResponses.InventoryItem> inventory = items(mapper.inventoryByPallet(
String.valueOf(warehouseId), pallet.palletId(), "__NO_CARTON__"));
long packages = inventory.stream().mapToLong(this::packages).sum();
int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count();
return new PdaWarehouseResponses.Pallet(pallet.palletId(), pallet.palletNo(), pallet.status(), pallet.version(),
location.locationId(), location.locationCode(), packages, positions, inventory);
}
public PageResult<PdaWarehouseResponses.Operation> history(long warehouseId, String operatorKey,
PdaWarehouseRequests.HistoryQuery query) {
long offset = (query.page() - 1) * query.size();
List<PdaWarehouseResponses.Operation> rows = mapper.history(warehouseId, operatorKey,
trim(query.operationType()), offset, query.size());
long total = mapper.countHistory(warehouseId, operatorKey, trim(query.operationType()));
return new PageResult<>(rows, query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
public void record(long warehouseId, String customerId, String operatorKey, String operationType,
String businessKey, String status, String idempotencyKey, String requestHash, String summary) {
PdaOperationHistory history = new PdaOperationHistory();
history.setId(IdWorker.getId()); history.setOverseasWarehouseId(warehouseId);
history.setCustomerId(trim(customerId)); history.setOperatorKey(operatorKey);
history.setOperationType(operationType); history.setBusinessKey(businessKey); history.setStatus(status);
history.setIdempotencyKey(trim(idempotencyKey)); history.setRequestHash(trim(requestHash));
history.setSummary(trim(summary)); history.setOccurredAt(LocalDateTime.now()); history.setCreatedAt(history.getOccurredAt());
mapper.insert(history);
}
private List<PdaWarehouseResponses.InventoryItem> items(List<PdaInventoryProjection> values) {
LinkedHashMap<String, PdaWarehouseResponses.InventoryItem> result = new LinkedHashMap<>();
for (PdaInventoryProjection value : values) {
String key = value.getPositionId() + ":" + text(value.getSkuCode()) + ":" + text(value.getCartonMark());
result.putIfAbsent(key, new PdaWarehouseResponses.InventoryItem(value.getCustomerId(), value.getCustomerName(),
number(value.getBalanceId()), number(value.getPositionId()), value.getSkuCode(),
first(value.getProductNameZh(), value.getProductNameEn()), value.getInboundCode(), value.getCartonMark(),
number(value.getPalletId()), value.getPalletCode(), value.getLocationId(), value.getLocationCode(),
number(value.getAvailablePackages()), number(value.getHoldPackages()), number(value.getReceivingPackages()),
value.getStatus(), number(value.getVersion())));
}
return List.copyOf(result.values());
}
private long packages(PdaWarehouseResponses.InventoryItem value) {
return value.availablePackages() + value.holdPackages() + value.receivingPackages();
}
private static String code(String value) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Scan code is required"); return result; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String text(String value) { return value == null ? "" : value; }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static long number(Long value) { return value == null ? 0 : value; }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -36,7 +36,11 @@ public final class OverseasProcurementRequests {
String productName, String status, String purchaseWarehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
public record Review(
@NotNull @Min(0) Long version,
@NotBlank String decision,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Update(
@NotNull @Min(0) Long version,
@@ -22,7 +22,7 @@ public final class OverseasProcurementResponses {
public record History(
String operationType, String fromStatus, String toStatus, String operatorId,
String changeDetail, String remark,
String idempotencyKey, String changeDetail, String remark,
LocalDateTime createdAt) {}
public record Detail(

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