From d6fe79b0f2eb385bf2454cd790786a723355bde1 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 17 Aug 2026 09:18:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 34 + README.md | 8 +- .../repository/AuditEventRepository.java | 7 +- config/nacos/baiyue-common.yml | 1 + config/nacos/event-bridge-service.yml | 10 + docs/completed-features-report-20260815.md | 121 ++++ docs/repository-boundary.md | 5 +- env.example | 16 +- event-bridge-service/AGENTS.md | 34 + .../eventbridge/EventBridgeApplication.java | 5 + .../config/EventDispatcherProperties.java | 34 + .../controller/EventBridgeController.java | 25 +- .../domain/BridgeDeliveryAttempt.java | 24 + .../eventbridge/domain/BridgeInbox.java | 6 + .../eventbridge/dto/EventReplayRequest.java | 4 + .../mapper/BridgeDeliveryAttemptMapper.java | 9 + .../eventbridge/mapper/BridgeInboxMapper.java | 52 ++ .../service/EventInboxService.java | 18 +- .../service/EventReplayService.java | 27 + .../service/HttpLastlegEventClient.java | 55 ++ .../service/LastlegEventClient.java | 7 + .../service/MybatisInboxStore.java | 5 +- .../service/OverseasEventDispatcher.java | 88 +++ .../src/main/resources/application.yml | 14 +- .../service/EventInboxServiceTest.java | 26 +- .../service/EventReplayServiceTest.java | 36 + .../service/OverseasEventDispatcherTest.java | 94 +++ gateway/AGENTS.md | 30 + .../gateway/config/AudienceJwtValidator.java | 23 + .../gateway/config/GatewaySecurityConfig.java | 14 +- .../config/GatewaySecurityProperties.java | 8 +- .../config/RequiredClaimsJwtValidator.java | 26 + gateway/src/main/resources/application.yml | 1 + .../config/AudienceJwtValidatorTest.java | 33 + .../RequiredClaimsJwtValidatorTest.java | 36 + .../provider/AmazonLogisticsAdapter.java | 7 +- .../integration/provider/BaiduOcrAdapter.java | 7 +- .../service/ProviderStatusService.java | 7 +- lastleg/AGENTS.md | 47 ++ lastleg/pom.xml | 1 + .../controller/LastlegOrderController.java | 113 ++++ .../dto/LastlegOrderDetailResponse.java | 14 + .../lastleg/dto/LastlegOrderRequests.java | 55 ++ .../lastleg/dto/LastlegOrderSummary.java | 47 ++ .../lastleg/entity/LastlegEventInbox.java | 3 + .../baiyue/lastleg/entity/LastlegOrder.java | 61 ++ .../lastleg/entity/LastlegOrderDetail.java | 3 + .../entity/LastlegOrderStatusHistory.java | 3 + .../InboundPlanMapperConfiguration.java | 9 + .../controller/InboundPlanController.java | 51 ++ .../dto/ConvertInboundPlanRequest.java | 9 + .../inboundplan/dto/InboundPlanResponse.java | 37 ++ .../inboundplan/entity/InboundPlan.java | 25 + .../inboundplan/entity/InboundPlanLine.java | 24 + .../inboundplan/entity/InboundPlanPallet.java | 31 + .../entity/InboundPlanPalletAllocation.java | 25 + .../mapper/InboundPlanLineMapper.java | 9 + .../inboundplan/mapper/InboundPlanMapper.java | 13 + .../mapper/InboundPlanOrderMapper.java | 49 ++ .../InboundPlanPalletAllocationMapper.java | 9 + .../mapper/InboundPlanPalletMapper.java | 9 + .../model/SourceOrderSnapshot.java | 20 + .../InboundPalletAssignmentController.java | 43 ++ .../pda/controller/InboundPdaController.java | 94 +++ .../inboundplan/pda/dto/PdaRequests.java | 19 + .../inboundplan/pda/dto/PdaResponses.java | 26 + .../pda/entity/InboundPalletMerge.java | 32 + .../pda/entity/InboundPdaOperation.java | 24 + .../pda/entity/InboundPdaTask.java | 27 + .../gateway/HttpWarehouseResourceGateway.java | 62 ++ .../pda/gateway/WarehouseResourceGateway.java | 7 + .../pda/mapper/InboundPalletMergeMapper.java | 9 + .../pda/mapper/InboundPdaOperationMapper.java | 13 + .../pda/mapper/InboundPdaTaskMapper.java | 16 + .../pda/repository/PdaWorkflowRepository.java | 79 +++ .../service/InboundPdaWorkflowService.java | 527 +++++++++++++++ .../service/InboundPlanService.java | 238 +++++++ .../inboundplan/service/PalletAllocator.java | 129 ++++ .../mapper/LastlegEventInboxMapper.java | 3 + .../mapper/LastlegOrderDetailMapper.java | 3 + .../lastleg/mapper/LastlegOrderMapper.java | 19 + .../LastlegOrderStatusHistoryMapper.java | 3 + .../security/InternalServiceTokenGuard.java | 20 + .../service/LastlegOrderNoGenerator.java | 35 + .../lastleg/service/LastlegOrderService.java | 171 +++++ .../service/LoadingCompletedConsumer.java | 114 ++++ lastleg/src/main/resources/application.yml | 16 +- lastleg/src/main/resources/db/lastleg.sql | 505 +++++++++++++++ .../LastlegOrderControllerTest.java | 54 ++ .../InboundPlanSchemaContractTest.java | 28 + .../InboundPdaWorkflowServiceTest.java | 357 ++++++++++ .../service/InboundPlanServiceTest.java | 143 ++++ .../service/PalletAllocatorTest.java | 57 ++ .../InternalServiceTokenGuardTest.java | 12 + .../service/LastlegOrderNoGeneratorTest.java | 31 + .../service/LastlegOrderServiceTest.java | 97 +++ .../service/LoadingCompletedConsumerTest.java | 12 + scripts/check-boundary.ps1 | 10 +- scripts/db/incremental.sql | 613 ++++++++++++++++++ warehouse/AGENTS.md | 31 + .../WarehouseDatabaseGuardConfiguration.java | 27 + ...ternalWarehouseResourceLockController.java | 48 ++ .../MerchantWarehouseResourceController.java | 109 ++++ .../PlatformWarehouseResourceController.java | 131 ++++ .../warehouse/dto/LocationRequests.java | 39 ++ .../baiyue/warehouse/dto/PalletRequests.java | 45 ++ .../dto/WarehouseResourceLockRequests.java | 20 + .../dto/WarehouseResourceLockResponse.java | 25 + .../entity/WarehouseLocationEntity.java | 26 + .../entity/WarehousePalletEntity.java | 27 + .../entity/WarehouseResourceLockEntity.java | 29 + .../mapper/WarehouseLocationMapper.java | 7 + .../mapper/WarehousePalletMapper.java | 7 + .../mapper/WarehouseResourceLockMapper.java | 69 ++ .../repository/WarehouseRepository.java | 15 +- .../WarehouseResourceLockRepository.java | 66 ++ .../WarehouseResourceRepository.java | 119 ++++ .../security/InternalServiceTokenGuard.java | 26 + .../service/WarehouseResourceLockService.java | 121 ++++ .../service/WarehouseResourceService.java | 171 +++++ warehouse/src/main/resources/application.yml | 17 +- warehouse/src/main/resources/db/warehouse.sql | 170 ++++- ...rehouseDatabaseGuardConfigurationTest.java | 29 + .../WarehouseResourceControllerTest.java | 69 ++ .../WarehouseResourceLockSchemaTest.java | 24 + .../InternalServiceTokenGuardTest.java | 32 + .../WarehouseResourceLockServiceTest.java | 194 ++++++ .../service/WarehouseResourceServiceTest.java | 164 +++++ 128 files changed, 7053 insertions(+), 76 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/completed-features-report-20260815.md create mode 100644 event-bridge-service/AGENTS.md create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java create mode 100644 gateway/AGENTS.md create mode 100644 gateway/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java create mode 100644 gateway/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java create mode 100644 lastleg/AGENTS.md create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java create mode 100644 lastleg/src/main/resources/db/lastleg.sql create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java create mode 100644 scripts/db/incremental.sql create mode 100644 warehouse/AGENTS.md create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..66b98e8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# 海外尾程后端项目指引 + +## 适用范围 + +- 本文件适用于 `baiyue-lastleg/` Maven 多模块仓库。修改具体服务前,继续读取该模块的 `AGENTS.md`。 +- 本层维护服务边界、跨服务协作、数据库变更入口和通用验证;类、接口和表的入口以模块级指引为准。 +- 海外服务不得跨库查询国内头程数据。跨区事实必须通过版本化事件和不可变快照传递。 + +## 服务路由 + +| 任务关键词 | 首选模块 | 模块指引 | +|---|---|---| +| RS256、JWKS、Audience、Claims、海外统一入口 | `gateway/` | `gateway/AGENTS.md` | +| 尾程订单、装柜完成事件、Inbox、订单明细、状态历史 | `lastleg/` | `lastleg/AGENTS.md` | +| 入仓计划、托盘分配、PDA 入仓作业、合托 | `lastleg/` | `lastleg/AGENTS.md` 的入仓计划索引 | +| 跨区事件接收、重试、死信、人工补投、投递记录 | `event-bridge-service/` | `event-bridge-service/AGENTS.md` | +| 海外仓、库位、托盘、资源锁定与释放 | `warehouse/` | `warehouse/AGENTS.md` | +| 文件、打印、审计、第三方集成 | `file/`、`print/`、`audit-service/`、`integration-service/` | 对应模块源码;补充业务能力时先新增模块指引 | + +## 固定契约与边界 + +- 头程装柜完成事件固定为 `FirstLegLoadingPlanCompleted` V1,`CN -> OVERSEAS`,聚合类型为 `LOADING_PLAN`。 +- 一个事件只承载一个 `tenantId/accountId`;`payload.orders` 中每个最终实装 `sourceInboundId` 独立生成一条尾程订单记录,不按 FBA 仓、渠道或海外仓再次合并。 +- 尾程消费者不得回查头程。订单、明细、重量、体积、目的仓、SKU、FBA tracking 和 shipment 等均使用事件冻结快照。 +- 事件幂等和业务幂等必须同时成立:相同 `eventId + payloadHash` 重投返回已有结果;`(tenant_id, source_region, source_inbound_id)` 防止重复建单。 +- 内部事件接口必须校验 `X-Internal-Service-Token`。外部用户接口由海外网关校验 RS256/JWKS、`aud=lastleg-api` 和必要身份声明。 +- 调用方不得通过请求参数任意扩大 `tenantId` 范围;租户范围必须来自可信认证身份或平台权限。 + +## 数据库与验证 + +- 模块基线 SQL 位于 `/src/main/resources/db/`。已部署环境的后续字段、注释和索引变更统一追加到 `scripts/db/incremental.sql`,不得依赖修改 `CREATE TABLE IF NOT EXISTS` 自动更新旧表。 +- SQL 必须可重复执行,新增索引前检查 `information_schema.statistics`,新增/修改字段前检查 `information_schema.columns`,并保留已有业务数据原文。 +- 单模块优先运行 `mvn -pl -am test`;跨事件链路至少运行 `lastleg` 与 `event-bridge-service` 的定向测试。 +- 不提交 `target/`、运行日志、临时联调脚本或本地密钥。 diff --git a/README.md b/README.md index e34041c..f7e2607 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,14 @@ 1. 用户在国内统一入口登录,获得使用非对称算法签名的短时效 JWT。 2. JWT 包含用户、租户、应用端、角色、按钮权限和权限版本等快照信息。 -3. 海外网关通过 `JWT_JWK_SET_URI` 获取并缓存公钥,只负责验签、签发方、有效期和应用端校验,不保存签名私钥。 +3. 海外网关通过 `JWT_JWK_SET_URI` 获取并缓存公钥,校验 RS256/ES256 签名、签发方、有效期、`lastleg-api` Audience、应用端和必要身份声明,不保存签名私钥。 4. 尾程业务服务通过公共安全组件读取权限快照并执行接口、按钮和租户边界校验,不在每次请求时跨区查询国内权限数据。 5. 用户停用、租户冻结和权限版本变化通过跨区事件同步到海外本地投影;高风险操作可依据本地冻结投影立即拒绝。 海外服务不得依赖国内 Redis 中的登录会话。正式上线前,头程公共安全制品必须完成 JWKS 解码器和本地权限快照校验改造。 +生产令牌必须包含 `sub`、`tenant_id`、`app_code`、`permission_version`,且 `aud` 必须包含配置的 `JWT_AUDIENCE`。认证中心应将访问令牌有效期控制在 5 至 15 分钟,并通过权限事件及时同步用户停用、租户冻结和权限版本变化。 + ## 公共制品依赖 本仓库不复制 `common-*` 源码。公共组件由头程仓发布到 Maven 私有仓库,本项目通过以下制品导入版本: @@ -48,7 +50,7 @@ com.baiyue.shared:common-event-contract | 服务 | 独立数据库 | |---|---| | `lastleg-service` | `baiyue_lastleg` | -| `warehouse-service` | `baiyue_warehouse` | +| `warehouse-service` | `baiyue_lastlag_warehouse` | | `file-service` | `baiyue_file` | | `print-service` | `baiyue_print` | | `audit-service` | `baiyue_audit` | @@ -56,6 +58,8 @@ com.baiyue.shared:common-event-contract 服务之间不得跨库查询。国内订单和头程事实通过版本化事件进入尾程本地投影,尾程状态和签收事实也通过事件回传国内。 +事件桥的海外本地投递使用数据库租约避免多实例重复领取,失败后指数退避并在达到上限后进入 `DEAD`。每次投递写入 `bridge_delivery_attempt`;经审批后可调用内部补投接口重新进入重试队列。生产环境必须将事件桥内部接口置于来源白名单和 mTLS 之后,并把 `LASTLEG_CONSUMER_URL` 配置为受信任的 HTTPS 内部地址。 + ## 文件服务说明 原工程中企业资质上传后同步调用国内权限服务的链路已从尾程仓移除。海外文件中心当前只保留基础骨架;提单、装箱单、清关文件和签收证明的对象存储、授权下载、病毒扫描及保留期限策略属于后续必做能力。 diff --git a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java index acc5669..1382672 100644 --- a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java +++ b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java @@ -4,17 +4,14 @@ import com.baiyue.audit.dto.AuditIngestRequest; import com.baiyue.audit.entity.AuditLogEntity; import com.baiyue.audit.mapper.AuditLogMapper; import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; /** * 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。 */ @Repository public class AuditEventRepository { - private final AuditLogMapper mapper; - - public AuditEventRepository(AuditLogMapper mapper) { - this.mapper = mapper; - } + @Resource private AuditLogMapper mapper; public void save(AuditIngestRequest event) { AuditLogEntity entity = new AuditLogEntity(); diff --git a/config/nacos/baiyue-common.yml b/config/nacos/baiyue-common.yml index e1db9a8..4233733 100644 --- a/config/nacos/baiyue-common.yml +++ b/config/nacos/baiyue-common.yml @@ -5,6 +5,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} security: issuer: ${JWT_ISSUER:https://auth.example.com} + audience: ${JWT_AUDIENCE:lastleg-api} jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} internal-service-token: ${INTERNAL_SERVICE_TOKEN} permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/config/nacos/event-bridge-service.yml b/config/nacos/event-bridge-service.yml index f50a01e..9d91128 100644 --- a/config/nacos/event-bridge-service.yml +++ b/config/nacos/event-bridge-service.yml @@ -8,3 +8,13 @@ baiyue: region: ${REGION_CODE:OVERSEAS} deployment-cell: ${DEPLOYMENT_CELL:prod-overseas} data-residency: ${DATA_RESIDENCY:OVERSEAS} + event-dispatcher: + consumer-url: ${LASTLEG_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} + fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s} + batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} + max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} + lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} + initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} + max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} + connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} + request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} diff --git a/docs/completed-features-report-20260815.md b/docs/completed-features-report-20260815.md new file mode 100644 index 0000000..9b178be --- /dev/null +++ b/docs/completed-features-report-20260815.md @@ -0,0 +1,121 @@ +# 尾程平台已完成功能报告(2026-08-15) + +## 1. 交付结论 + +头程装柜完成到尾程订单的真实链路已打通。头程按客户发布 +`FirstLegLoadingPlanCompleted V1` 事件,经 Event Bridge 可靠投递后,尾程按事件中 +每一笔最终实装入仓单分别落一条订单记录。同一柜的订单记录共享尾程单号, +`sourceInboundId` 作为来源建单唯一标识。 + +本报告仅描述当前代码和已经取得证据的能力,不把规划中的功能列为已完成。 + +## 2. 已完成功能 + +### 2.1 跨区域事件桥 + +- 接收并持久化版本化事件信封,保存幂等键、负载摘要和冻结 JSON。 +- 使用数据库租约领取待投递事件,支持多实例竞争和超时租约恢复。 +- `RECEIVED` 与 `RETRY` 都可重新调度,失败采用指数退避,达到上限进入 `DEAD`。 +- 每次投递写入 `bridge_delivery_attempt`,记录投递序号、结果、响应码和错误。 +- 支持经过内部令牌保护的死信人工补投;成功后才把 Inbox 标记为 `PROCESSED`。 +- Event Bridge 与尾程内部接口使用统一的 `X-Internal-Service-Token`。 + +### 2.2 装柜完成事件消费与尾程建单 + +- 只接受 `CN -> OVERSEAS`、`LOADING_PLAN`、版本为 1 的装柜完成事件。 +- 强制校验实际 `containerNo` 和非空 `orders`,尾程不回查头程数据。 +- 事件 Inbox 以 `eventId` 防重复,并校验重复事件的 `payloadHash`。 +- 按 `orders` 逐笔创建尾程订单;同柜入仓单共享一个不可碰撞的尾程单号。 +- 以 `(tenant_id, source_region, source_inbound_id)` 保证来源入仓单建单幂等。 +- 保存根入仓单、来源订单、装柜计划版本、交接版本和来源摘要等追溯字段。 +- 保存商品、箱数、数量、托数、重量、体积、目的仓、SKU、FBA 跟踪号、 + shipmentNo 和唛头等实装冻结明细。 +- 新订单初始化为 `PENDING_ASSIGNMENT`,同步写入状态历史。 +- 同一来源入仓单摘要不一致时拒绝静默覆盖。 + +### 2.3 尾程订单查询与转仓 + +- 按租户分页查询,支持尾程单号、入仓码、头程订单号、柜号、状态、转仓、 + 异常、暂扣和创建时间筛选。 +- 查询订单详情、实装明细和状态历史。 +- 导出 UTF-8 CSV,并处理公式注入风险。 +- 支持单笔和批量转仓,更新订单及明细目的仓快照并写状态历史。 +- 已生成入库计划的订单禁止再次修改目的仓。 +- 普通客户只能查询认证身份对应租户;平台身份必须显式指定客户租户, + 不允许通过请求参数任意跨客户查询。 + +### 2.4 尾程入库计划与 PDA 作业 + +- 按租户和实际集装箱生成唯一入库计划。 +- 保存入仓码明细、目的仓快照、计划托盘和托盘分配关系。 +- 按目的仓隔离合托,支持固定容量托盘分配。 +- 提供 PDA 任务、操作幂等记录、托盘合并记录及仓库资源锁关联字段。 + +### 2.5 仓库资源和海外认证边界 + +- 仓库维护库位、托盘及资源锁,资源锁使用租户幂等键并记录业务归属。 +- 仓库数据源启动保护避免 schema 接错后误执行建表脚本。 +- 海外 Gateway 使用 JWKS 验证非对称 JWT,校验 `lastleg-api` Audience 及必需 claims。 +- 尾程内部事件入口使用常量时间令牌比较;未配置或令牌错误时拒绝消费。 + +### 2.6 工程规范与数据库交付 + +- 生产 Spring Bean 的依赖注入已统一为 `jakarta.annotation.Resource`;仅保留配置值 + `@Value` 和普通值对象/校验器构造器参数,不把不可变业务值误当作 Bean 注入。 +- 新增统一增量入口 `scripts/db/incremental.sql`,按 `001-006` 归档 Event Bridge、尾程订单与入库计划/PDA、仓库资源及仓库主数据的字段、中文注释和索引变更。 +- 增量脚本使用 `information_schema` 守卫,45 个动态 DDL 均成对释放,且不包含业务数据写入、删除或清理语句。 + +## 3. 真实链路验收证据 + +| 项目 | 结果 | +|---|---| +| loadingPlanId | `2091000814000009002` | +| containerNo | `LLSYNC-CNTR-20260814` | +| tenantId/accountId | `10001` | +| eventId | `88cd0f63-dc9e-4c16-bdb7-c32b20a83c3b` | +| payloadHash | `sha256:23011feff2ff91203fb94ebab7cab8306f58e3e128f61d17913d2f74af5c87f4` | +| Event Bridge 状态 | `PROCESSED` | +| 尾程单号 | `LMO43831679558828067566368238939` | + +同一事件实际生成两条入仓单维度订单记录: + +| sourceInboundId | 入仓码 | 箱数 | 托数 | 明细数 | +|---|---|---:|---:|---:| +| `2091000814000000301` | `LLSYNC-20260814-IN-01` | 2 | 1 | 1 | +| `2091000814000001301` | `LLSYNC-20260814-IN-02` | 2 | 1 | 1 | + +重复投递相同 `eventId` 后接口仍返回成功,数据库订单记录总数保持为 2,未重复建单。 + +## 4. 数据库交付约定 + +全量初始化脚本继续由各服务维护: + +- `lastleg/src/main/resources/db/lastleg.sql` +- `event-bridge-service/src/main/resources/db/event-bridge.sql` +- `warehouse/src/main/resources/db/warehouse.sql` + +从本次交付开始,所有字段和索引变更同时追加到 +`scripts/db/incremental.sql`。该文件按日期和序号只追加,已发布段落不得修改; +新增字段和索引必须查询 `information_schema` 后再执行,禁止放入测试数据、 +业务数据修复、`DELETE` 或 `TRUNCATE`。 + +当前归档段为 `001` Event Bridge、`002` 仓库资源、`003` 入库计划/PDA、 +`004` Event Bridge 存量字段升级、`005` 尾程订单投影和 `006` 仓库主数据注释/索引。 +脚本静态契约校验结果:UTF-8、614 行、45 个 `PREPARE` 与 45 个释放操作, +未包含业务 DML,也未检测到乱码字符。 + +## 5. 验证结果 + +- `mvn -pl lastleg -am test`:34 tests passed。 +- `mvn -pl event-bridge-service -am test`:15 tests passed。 +- `mvn -pl warehouse -am test`:26 tests passed。 +- `mvn -pl gateway -am test`:7 tests passed。 +- 注入改造额外验证:`integration-service` 1 test passed,`audit-service` 编译通过。 +- 生产代码扫描无 `@Autowired`、`@Inject` 或 Lombok 构造器注入残留;文档和 SQL UTF-8 乱码扫描通过。 + +## 6. 当前未声明完成的范围 + +- 到港、清关、拆柜、正式入库、出库、派送和签收的完整状态命令链路。 +- 前端尾程订单页面。 +- 权限冻结事件的海外本地投影和完整生产密钥轮换演练。 +- 生产级 mTLS、来源网络白名单、监控告警和灾备演练。 diff --git a/docs/repository-boundary.md b/docs/repository-boundary.md index ca555a0..1b7c430 100644 --- a/docs/repository-boundary.md +++ b/docs/repository-boundary.md @@ -62,10 +62,9 @@ - 已完成独立根 POM、业务模块抽取、公共依赖坐标调整和海外配置收敛。 - 已移除文件服务中依赖国内权限服务的企业资质链路。 -- 海外网关已改为 JWKS 验签并默认启用海外显式路由。 +- 海外网关已改为 JWKS 验签,强制校验 Audience 和必要身份声明,并默认启用海外显式路由。 - 公共安全组件的 JWKS 解码、本地权限快照和冻结事件处理仍需在公共制品中完成。 - 海外提单、装箱单、清关文件和签收证明能力仍待实现。 -- 跨区发送器、消息中间件接入、重试调度和死信补投仍待实现。 +- 海外事件桥到尾程消费者的本地调度已具备租约、指数退避、投递审计、死信和人工补投;头程跨区发送端、尾程反向回传和生产 mTLS 仍需完成联调验收。 只有上述待办完成并通过故障注入、跨区断网、重复事件、乱序事件和密钥轮换验收后,尾程服务才允许迁移到海外生产环境。 - diff --git a/env.example b/env.example index 3d2a233..86cdf54 100644 --- a/env.example +++ b/env.example @@ -4,7 +4,7 @@ DEPLOYMENT_CELL=prod-overseas DATA_RESIDENCY=OVERSEAS # 海外注册中心和配置中心 -NACOS_SERVER_ADDR=nacos-overseas.internal:8848 +NACOS_SERVER_ADDR=nacos-overseas.internal:8858 NACOS_NAMESPACE=prod-overseas NACOS_GROUP=BAIYUE NACOS_USERNAME= @@ -12,13 +12,14 @@ NACOS_PASSWORD= # 海外本地缓存,不得连接国内 Redis REDIS_HOST=redis-overseas.internal -REDIS_PORT=6379 +REDIS_PORT=6380 REDIS_PASSWORD= REDIS_DATABASE=0 PERMISSION_SNAPSHOT_KEY_PREFIX=baiyue:permission-snapshot # 国内认证中心只公开非对称签名公钥,海外不得保存签名私钥 JWT_ISSUER=https://auth.example.com +JWT_AUDIENCE=lastleg-api JWT_JWK_SET_URI=https://auth.example.com/.well-known/jwks.json INTERNAL_SERVICE_TOKEN= @@ -26,7 +27,7 @@ INTERNAL_SERVICE_TOKEN= LASTLEG_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC LASTLEG_DB_USERNAME= LASTLEG_DB_PASSWORD= -WAREHOUSE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC +WAREHOUSE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC WAREHOUSE_DB_USERNAME= WAREHOUSE_DB_PASSWORD= FILE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC @@ -42,6 +43,15 @@ EVENT_BRIDGE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_event_bridg EVENT_BRIDGE_DB_USERNAME= EVENT_BRIDGE_DB_PASSWORD= EVENT_BRIDGE_PORT=9300 +LASTLEG_CONSUMER_URL=https://lastleg-service.internal/internal/lastleg/events +EVENT_DISPATCH_FIXED_DELAY=3s +EVENT_DISPATCH_BATCH_SIZE=50 +EVENT_DISPATCH_MAX_ATTEMPTS=10 +EVENT_DISPATCH_LEASE_DURATION=30s +EVENT_DISPATCH_INITIAL_BACKOFF=5s +EVENT_DISPATCH_MAX_BACKOFF=15m +EVENT_DISPATCH_CONNECT_TIMEOUT=3s +EVENT_DISPATCH_REQUEST_TIMEOUT=10s # 海外对象存储和外部渠道适配 FILE_STORAGE_PROVIDER=s3 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md new file mode 100644 index 0000000..c239eac --- /dev/null +++ b/event-bridge-service/AGENTS.md @@ -0,0 +1,34 @@ +# Event Bridge Service 模块指引 + +## 职责边界 + +- 负责跨区事件接收、Inbox 持久化、向海外尾程投递、失败重试、投递记录和人工补投。 +- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。 +- 事件结构和 Payload 哈希以 `common-event-contract` 为准。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | +| Inbox 接收与幂等 | `service/EventInboxService.java` | +| 海外调度与重试 | `service/OverseasEventDispatcher.java` | +| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | +| 人工补投 | `service/EventReplayService.java` | +| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | +| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | +| 数据库 | `src/main/resources/db/event-bridge.sql` | +| 测试 | `src/test/java/com/baiyue/eventbridge/` | + +## 可靠投递规则 + +- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。 +- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 +- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 +- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 + +## 修改与验证 + +- 定向验证:`mvn -pl event-bridge-service -am test`。 +- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。 + diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java index dd0e0b0..a79fbbe 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java @@ -1,14 +1,19 @@ package com.baiyue.eventbridge; +import com.baiyue.eventbridge.config.EventDispatcherProperties; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 跨区域事件桥启动入口。 */ @MapperScan("com.baiyue.eventbridge.mapper") @SpringBootApplication +@EnableScheduling +@EnableConfigurationProperties(EventDispatcherProperties.class) public class EventBridgeApplication { public static void main(String[] args) { SpringApplication.run(EventBridgeApplication.class, args); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java new file mode 100644 index 0000000..fffbbea --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java @@ -0,0 +1,34 @@ +package com.baiyue.eventbridge.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.net.URI; +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.event-dispatcher") +public record EventDispatcherProperties( + URI consumerUrl, + int batchSize, + int maxAttempts, + Duration leaseDuration, + Duration initialBackoff, + Duration maxBackoff, + Duration connectTimeout, + Duration requestTimeout) { + + public EventDispatcherProperties { + consumerUrl = consumerUrl == null + ? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl; + batchSize = batchSize <= 0 ? 50 : batchSize; + maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts; + leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30)); + initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5)); + maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15)); + connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3)); + requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10)); + } + + private static Duration positiveOrDefault(Duration value, Duration fallback) { + return value == null || value.isNegative() || value.isZero() ? fallback : value; + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java index 4b4f4fe..e26f922 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java @@ -4,13 +4,15 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; import com.baiyue.eventbridge.dto.BridgeStatus; import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.dto.EventReplayRequest; import com.baiyue.eventbridge.service.EventInboxService; +import com.baiyue.eventbridge.service.EventReplayService; import com.baiyue.eventbridge.security.InternalServiceTokenGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PathVariable; /** * 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。 @@ -25,12 +28,12 @@ import org.springframework.web.bind.annotation.RestController; @Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态") @RestController @RequestMapping("/internal/event-bridge") -@RequiredArgsConstructor public class EventBridgeController { - private final EventInboxService eventInboxService; - private final InternalServiceTokenGuard internalServiceTokenGuard; + @Resource private EventInboxService eventInboxService; + @Resource private EventReplayService eventReplayService; + @Resource private InternalServiceTokenGuard internalServiceTokenGuard; - @Value("${baiyue.region:CN}") + @Value("${baiyue.region:OVERSEAS}") private String region; @Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通") @@ -48,6 +51,18 @@ public class EventBridgeController { return ApiResponse.success(eventInboxService.receive(envelope)); } + @Operation(summary = "人工补投死信事件") + @PostMapping("/events/{eventId}/replay") + public ApiResponse replay( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") String operatorId, + @PathVariable String eventId, + @RequestBody EventReplayRequest request) { + internalServiceTokenGuard.verify(token); + eventReplayService.replayDead(eventId, operatorId, request.reason()); + return ApiResponse.success(null); + } + @Operation(summary = "查询事件桥能力状态") @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功")) @GetMapping("/status") diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java new file mode 100644 index 0000000..eaf57c5 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java @@ -0,0 +1,24 @@ +package com.baiyue.eventbridge.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("bridge_delivery_attempt") +public class BridgeDeliveryAttempt { + @TableId(type = IdType.AUTO) + private Long id; + private String eventId; + private String sourceRegion; + private String targetRegion; + private Integer attemptNo; + private String status; + private String responseCode; + private String errorMessage; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java index 4094be1..92d4151 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java @@ -32,5 +32,11 @@ public class BridgeInbox { private String payloadHash; private String payloadJson; private String status; + private String leaseOwner; + private LocalDateTime leaseUntil; + private Integer retryCount; + private LocalDateTime nextRetryAt; + private String lastError; private LocalDateTime receivedAt; + private LocalDateTime processedAt; } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java new file mode 100644 index 0000000..25c4bb3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java @@ -0,0 +1,4 @@ +package com.baiyue.eventbridge.dto; + +public record EventReplayRequest(String reason) { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java new file mode 100644 index 0000000..7553414 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.eventbridge.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface BridgeDeliveryAttemptMapper extends BaseMapper { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index 5ec5360..f93a34f 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -3,10 +3,62 @@ package com.baiyue.eventbridge.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.eventbridge.domain.BridgeInbox; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; /** * 入站事件持久化映射。 */ @Mapper public interface BridgeInboxMapper extends BaseMapper { + @Select(""" + SELECT event_id FROM bridge_inbox + WHERE target_region = 'OVERSEAS' + AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) + ORDER BY received_at LIMIT #{limit} + """) + List dispatchCandidates(@Param("limit") int limit); + + @Update(""" + UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, + lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) + WHERE event_id=#{eventId} AND target_region='OVERSEAS' + AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) + """) + int claim(@Param("eventId") String eventId, @Param("owner") String owner, + @Param("leaseSeconds") long leaseSeconds); + + @Update(""" + UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL + WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} + """) + int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner); + + @Update(""" + UPDATE bridge_inbox SET retry_count=retry_count+1, + status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END, + next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL + ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END, + lease_owner=NULL, lease_until=NULL, last_error=#{error} + WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} + """) + int markFailed(@Param("eventId") String eventId, @Param("owner") String owner, + @Param("maxAttempts") int maxAttempts, + @Param("backoffSeconds") long backoffSeconds, @Param("error") String error); + + @Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}") + int nextAttemptNo(@Param("eventId") String eventId); + + @Update(""" + UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL, lease_until=NULL, last_error=#{reason} + WHERE event_id=#{eventId} AND status='DEAD' + """) + int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 8803d0a..252ed0d 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; import java.time.LocalDateTime; @@ -19,16 +20,11 @@ import java.time.LocalDateTime; */ @Service public class EventInboxService { - private final InboxStore inboxStore; - private final ObjectMapper objectMapper; - private final String localRegion; + @Resource private InboxStore inboxStore; + @Resource private ObjectMapper objectMapper; - public EventInboxService(InboxStore inboxStore, ObjectMapper objectMapper, - @Value("${baiyue.region:CN}") String localRegion) { - this.inboxStore = inboxStore; - this.objectMapper = objectMapper; - this.localRegion = localRegion; - } + @Value("${baiyue.region:CN}") + private String localRegion; @Transactional public EventReceiveResult receive(EventEnvelope envelope) { @@ -36,7 +32,9 @@ public class EventInboxService { if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); } - if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash()); + boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash()); + if (!currentHash && !legacyHash) { throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); } BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java new file mode 100644 index 0000000..bc8b76d --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java @@ -0,0 +1,27 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +@Service +public class EventReplayService { + @Resource private BridgeInboxMapper inboxMapper; + + public void replayDead(String eventId, String operatorId, String reason) { + if (operatorId == null || operatorId.isBlank()) { + throw new IllegalArgumentException("人工补投必须提供操作人"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("人工补投必须提供原因"); + } + String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000); + if (inboxMapper.replayDead(eventId, auditReason) != 1) { + throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); + } + } + + private static String abbreviate(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java new file mode 100644 index 0000000..def4b42 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -0,0 +1,55 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpLastlegEventClient implements LastlegEventClient { + @Resource private ObjectMapper objectMapper; + @Resource private EventDispatcherProperties properties; + + @Value("${baiyue.security.internal-service-token:}") + private String serviceToken; + + private HttpClient httpClient; + + @PostConstruct + void initializeHttpClient() { + this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getTenantId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + String body = objectMapper.writeValueAsString(envelope); + HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl()) + .timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode responseBody = objectMapper.readTree(response.body()); + if (response.statusCode() / 100 != 2 || responseBody == null + || !"200".equals(responseBody.path("code").asText())) { + throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java new file mode 100644 index 0000000..7fa21d3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface LastlegEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java index b397cae..d68c0f9 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java @@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; /** * 基于 MyBatis-Plus 的入站幂等存储。 */ @Repository -@RequiredArgsConstructor public class MybatisInboxStore implements InboxStore { - private final BridgeInboxMapper mapper; + @Resource private BridgeInboxMapper mapper; @Override public BridgeInbox findByEventId(String eventId) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java new file mode 100644 index 0000000..39317db --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -0,0 +1,88 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.lang.management.ManagementFactory; +import java.time.LocalDateTime; +import java.util.UUID; + +@Component +@Slf4j +public class OverseasEventDispatcher { + @Resource private BridgeInboxMapper inboxMapper; + @Resource private BridgeDeliveryAttemptMapper attemptMapper; + @Resource private LastlegEventClient eventClient; + @Resource private EventDispatcherProperties properties; + private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); + + @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3s}") + public void dispatch() { + for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) { + dispatchOne(eventId); + } + } + + void dispatchOne(String eventId) { + if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) { + return; + } + + BridgeInbox event = inboxMapper.selectById(eventId); + int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; + int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); + BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); + try { + String responseCode = eventClient.deliver(event); + if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { + throw new IllegalStateException("事件租约已失效,无法确认投递结果"); + } + finishAttempt(attempt, "DELIVERED", responseCode, null); + } catch (Exception exception) { + String error = abbreviate(exception.getMessage(), 1000); + inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); + finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); + log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", + eventId, retryAttempt, deliveryAttemptNo, error); + } + } + + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { + BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); + attempt.setEventId(event.getEventId()); + attempt.setSourceRegion(event.getSourceRegion()); + attempt.setTargetRegion(event.getTargetRegion()); + attempt.setAttemptNo(attemptNo); + attempt.setStatus("PROCESSING"); + attempt.setStartedAt(LocalDateTime.now()); + attemptMapper.insert(attempt); + return attempt; + } + + private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) { + attempt.setStatus(status); + attempt.setResponseCode(responseCode); + attempt.setErrorMessage(error); + attempt.setFinishedAt(LocalDateTime.now()); + attemptMapper.updateById(attempt); + } + + private long backoffSeconds(int attemptNo) { + long initial = properties.initialBackoff().toSeconds(); + int shift = Math.min(Math.max(attemptNo - 1, 0), 20); + long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift; + return Math.min(delay, properties.maxBackoff().toSeconds()); + } + + private static String abbreviate(String value, int maxLength) { + String safe = value == null || value.isBlank() ? "未知投递错误" : value; + return safe.length() <= maxLength ? safe : safe.substring(0, maxLength); + } +} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 03c7162..c2f16ae 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -16,7 +16,7 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} username: ${EVENT_BRIDGE_DB_USERNAME:root} - password: ${EVENT_BRIDGE_DB_PASSWORD:} + password: ${EVENT_BRIDGE_DB_PASSWORD:123456} cloud: nacos: server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} @@ -29,7 +29,17 @@ spring: namespace: ${NACOS_NAMESPACE:} baiyue: - region: ${REGION_CODE:CN} + region: ${REGION_CODE:OVERSEAS} + event-dispatcher: + consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} + fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s} + batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} + max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} + lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} + initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} + max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} + connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} + request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 97fc883..1d5d451 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.time.LocalDateTime; @@ -23,7 +24,7 @@ import static org.mockito.Mockito.when; class EventInboxServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final InboxStore store = mock(InboxStore.class); - private final EventInboxService service = new EventInboxService(store, objectMapper, "OVERSEAS"); + private final EventInboxService service = service("OVERSEAS"); @Test void 首次事件落库并返回已接收() { @@ -34,6 +35,19 @@ class EventInboxServiceTest { verify(store).insert(any(BridgeInbox.class)); } + @Test + void 数字规范化上线前的历史摘要仍可接收() { + EventEnvelope source = envelope("sha256:placeholder"); + source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150); + String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload())); + EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash); + + assertThat(service.receive(envelope).result()).isEqualTo("已接收"); + } + @Test void 相同事件和摘要重复接收不再次写入() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); @@ -64,7 +78,7 @@ class EventInboxServiceTest { @Test void 目标区域与当前部署区域不一致时拒绝接收() { - EventInboxService cnService = new EventInboxService(store, objectMapper, "CN"); + EventInboxService cnService = service("CN"); EventEnvelope envelope = withHash(envelope("sha256:placeholder")); assertThatThrownBy(() -> cnService.receive(envelope)) @@ -79,6 +93,14 @@ class EventInboxServiceTest { objectMapper.createObjectNode().put("orderId", "90001"), hash); } + private EventInboxService service(String region) { + EventInboxService service = new EventInboxService(); + ReflectionTestUtils.setField(service, "inboxStore", store); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "localRegion", region); + return service; + } + private EventEnvelope withHash(EventEnvelope source) { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java new file mode 100644 index 0000000..66b740d --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -0,0 +1,36 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class EventReplayServiceTest { + private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + private final EventReplayService service = service(); + + @Test + void requeuesDeadEventWithAuditReason() { + when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); + + service.replayDead("event-1", "operator-7", "incident-42 approved"); + + verify(mapper).replayDead(eq("event-1"), contains("operator-7")); + } + + @Test + void rejectsEventThatIsNotDead() { + when(mapper.replayDead(anyString(), anyString())).thenReturn(0); + + assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry")) + .isInstanceOf(IllegalStateException.class); + } + + private EventReplayService service() { + EventReplayService service = new EventReplayService(); + ReflectionTestUtils.setField(service, "inboxMapper", mapper); + return service; + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java new file mode 100644 index 0000000..f3f727f --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -0,0 +1,94 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasEventDispatcherTest { + private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); + private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); + private final LastlegEventClient eventClient = mock(LastlegEventClient.class); + private OverseasEventDispatcher dispatcher; + private BridgeInbox event; + + @BeforeEach + void setUp() { + EventDispatcherProperties properties = new EventDispatcherProperties( + URI.create("https://lastleg.internal/events"), 50, 3, + Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), + Duration.ofSeconds(3), Duration.ofSeconds(10)); + dispatcher = new OverseasEventDispatcher(); + ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); + ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); + ReflectionTestUtils.setField(dispatcher, "properties", properties); + event = new BridgeInbox(); + event.setEventId("event-1"); + event.setSourceRegion("CN"); + event.setTargetRegion("OVERSEAS"); + event.setRetryCount(0); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + } + + @Test + void successfulDeliveryIsConfirmedAndAudited() throws Exception { + when(eventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markProcessed(eq("event-1"), anyString()); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + } + + @Test + void failedDeliveryIsScheduledForRetry() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + } + + @Test + void finalFailureIsMovedToDeadState() throws Exception { + event.setRetryCount(2); + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + + dispatcher.dispatchOne("event-1"); + + assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); + } + + @Test + void eventClaimedByAnotherInstanceIsSkipped() { + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); + } + + private BridgeDeliveryAttempt updatedAttempt() { + ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper).updateById(captor.capture()); + return captor.getValue(); + } +} diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md new file mode 100644 index 0000000..8d4aff6 --- /dev/null +++ b/gateway/AGENTS.md @@ -0,0 +1,30 @@ +# Gateway 模块指引 + +## 职责边界 + +- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。 +- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` | +| Audience 校验 | `config/AudienceJwtValidator.java` | +| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | +| 安全配置项 | `config/GatewaySecurityProperties.java` | +| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | +| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | +| 测试 | `src/test/java/com/baiyue/gateway/config/` | + +## 安全规则 + +- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。 +- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。 +- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。 + +## 修改与验证 + +- 定向验证:`mvn -pl gateway -am test`。 +- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。 + diff --git a/gateway/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java b/gateway/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java new file mode 100644 index 0000000..0305a74 --- /dev/null +++ b/gateway/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java @@ -0,0 +1,23 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +final class AudienceJwtValidator implements OAuth2TokenValidator { + private final String requiredAudience; + + AudienceJwtValidator(String requiredAudience) { + this.requiredAudience = requiredAudience; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + if (jwt.getAudience().contains(requiredAudience)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "JWT audience 不允许访问海外 API", null)); + } +} diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index a26f7c6..50a208b 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -7,6 +7,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; @@ -48,8 +50,16 @@ public class GatewaySecurityConfig { @Bean ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) { NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder - .withJwkSetUri(properties.jwkSetUri()).build(); - decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer())); + .withJwkSetUri(properties.jwkSetUri()) + .jwsAlgorithms(algorithms -> { + algorithms.add(SignatureAlgorithm.RS256); + algorithms.add(SignatureAlgorithm.ES256); + }) + .build(); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.issuer()), + new AudienceJwtValidator(properties.audience()), + new RequiredClaimsJwtValidator())); return decoder; } diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java index c8ba9d1..3dab56b 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java @@ -8,7 +8,10 @@ import java.util.List; * 海外网关安全配置,只保存验签所需的签发方和公钥集地址。 */ @ConfigurationProperties(prefix = "baiyue.security") -public record GatewaySecurityProperties(String jwkSetUri, String issuer, List permitPaths) { +public record GatewaySecurityProperties(String jwkSetUri, + String issuer, + String audience, + List permitPaths) { public GatewaySecurityProperties { if (jwkSetUri == null || jwkSetUri.isBlank()) { throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI"); @@ -16,6 +19,9 @@ public record GatewaySecurityProperties(String jwkSetUri, String issuer, List { + private static final List REQUIRED_CLAIMS = List.of( + "sub", "tenant_id", "app_code", "permission_version"); + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + for (String claim : REQUIRED_CLAIMS) { + Object value = jwt.getClaims().get(claim); + if (value == null || value.toString().isBlank()) { + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "JWT 缺少必要声明: " + claim, null)); + } + } + return OAuth2TokenValidatorResult.success(); + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index e0d26c0..221e961 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -37,6 +37,7 @@ spring: baiyue: security: issuer: ${JWT_ISSUER:https://auth.example.com} + audience: ${JWT_AUDIENCE:lastleg-api} jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} permit-paths: - /actuator/health diff --git a/gateway/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java b/gateway/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java new file mode 100644 index 0000000..474c2ec --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java @@ -0,0 +1,33 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AudienceJwtValidatorTest { + private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api"); + + @Test + void acceptsTokenForLastlegApi() { + assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse(); + } + + @Test + void rejectsTokenForAnotherApi() { + assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue(); + } + + private Jwt jwt(List audiences) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("1001") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .audience(audiences) + .build(); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java b/gateway/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java new file mode 100644 index 0000000..db83eae --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -0,0 +1,36 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class RequiredClaimsJwtValidatorTest { + private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator(); + + @Test + void acceptsCompleteIdentityClaims() { + assertThat(validator.validate(jwt(true)).hasErrors()).isFalse(); + } + + @Test + void rejectsMissingPermissionVersion() { + assertThat(validator.validate(jwt(false)).hasErrors()).isTrue(); + } + + private Jwt jwt(boolean includePermissionVersion) { + Jwt.Builder builder = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("1001") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "2001") + .claim("app_code", "PLATFORM"); + if (includePermissionVersion) { + builder.claim("permission_version", 3L); + } + return builder.build(); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java index 759db0b..8267b22 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java @@ -3,14 +3,11 @@ package com.baiyue.integration.provider; import com.baiyue.integration.config.IntegrationProperties; import com.baiyue.integration.dto.ProviderStatus; import org.springframework.stereotype.Component; +import jakarta.annotation.Resource; @Component public class AmazonLogisticsAdapter implements IntegrationProviderAdapter { - private final IntegrationProperties properties; - - public AmazonLogisticsAdapter(IntegrationProperties properties) { - this.properties = properties; - } + @Resource private IntegrationProperties properties; @Override public ProviderStatus status() { diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java index 651d5d0..69ef76d 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java @@ -3,14 +3,11 @@ package com.baiyue.integration.provider; import com.baiyue.integration.config.IntegrationProperties; import com.baiyue.integration.dto.ProviderStatus; import org.springframework.stereotype.Component; +import jakarta.annotation.Resource; @Component public class BaiduOcrAdapter implements IntegrationProviderAdapter { - private final IntegrationProperties properties; - - public BaiduOcrAdapter(IntegrationProperties properties) { - this.properties = properties; - } + @Resource private IntegrationProperties properties; @Override public ProviderStatus status() { diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java index 63b23fe..f2252ad 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java @@ -3,16 +3,13 @@ package com.baiyue.integration.service; import com.baiyue.integration.dto.ProviderStatus; import com.baiyue.integration.provider.IntegrationProviderAdapter; import org.springframework.stereotype.Service; +import jakarta.annotation.Resource; import java.util.List; @Service public class ProviderStatusService { - private final List adapters; - - public ProviderStatusService(List adapters) { - this.adapters = adapters; - } + @Resource private List adapters; public List providers() { return adapters.stream().map(IntegrationProviderAdapter::status).toList(); diff --git a/lastleg/AGENTS.md b/lastleg/AGENTS.md new file mode 100644 index 0000000..cff7ce4 --- /dev/null +++ b/lastleg/AGENTS.md @@ -0,0 +1,47 @@ +# Lastleg Service 模块指引 + +## 职责与边界 + +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 +- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。 +- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 事件接收、订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` | +| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | +| 订单查询与更新 | `service/LastlegOrderService.java` | +| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | +| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | +| 内部令牌保护 | `security/InternalServiceTokenGuard.java` | +| 入仓计划转换/查询 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/service/InboundPlanService.java` | +| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | +| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` | +| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | +| 数据库 | `src/main/resources/db/lastleg.sql` | +| 测试 | `src/test/java/com/baiyue/lastleg/` | + +## 事件建单规则 + +- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 +- 同一事件/loading plan 生成一个共享尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条 `ll_order`,并保存对应明细和初始状态历史。 +- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 +- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 + +## 查询与租户约束 + +- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 +- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 + +## 修改与验证 + +- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 +- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 +- 定向验证:`mvn -pl lastleg -am test`。 +- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 + diff --git a/lastleg/pom.xml b/lastleg/pom.xml index 8b9f8f9..53a8c11 100644 --- a/lastleg/pom.xml +++ b/lastleg/pom.xml @@ -5,6 +5,7 @@ com.baiyue.lastlegbaiyue-lastleg-platform1.0.0-SNAPSHOT../pom.xml lastleg-service + com.baiyue.sharedcommon-event-contract com.baiyue.sharedbaiyue-common-openapi com.baomidoumybatis-plus-spring-boot3-starter com.baiyue.sharedbaiyue-common-web diff --git a/lastleg/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java new file mode 100644 index 0000000..fbfaf25 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -0,0 +1,113 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; + +@RestController +@Tag(name = "Tail-leg orders") +public class LastlegOrderController { + @Resource private LoadingCompletedConsumer consumer; + @Resource private LastlegOrderService orderService; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/internal/lastleg/events") + @Operation(summary = "Receive a first-leg loading-completed event") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope event) { + tokenGuard.verify(token); + return ApiResponse.success(consumer.consume(event)); + } + + @GetMapping("/api/platform/v1/last-leg/orders") + @Operation(summary = "Page tail-leg order rows") + public ApiResponse> list( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(scope(user, tenantId), query)); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}") + @Operation(summary = "Get one inbound-code-level tail-leg order") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @PathVariable long id) { + return ApiResponse.success(orderService.detail(scope(user, tenantId), id)); + } + + @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") + @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV") + public ResponseEntity export( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid LastlegOrderRequests.Query query) { + byte[] body = orderService.exportCsv(scope(user, tenantId), query); + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") + .body(body); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") + @Operation(summary = "Transfer one order to a destination warehouse") + public ApiResponse transferOne( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @PathVariable long id, + @Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) { + return ApiResponse.success(orderService.transferDestination(scope(user, tenantId), request.withOrderId(id))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/destination") + @Operation(summary = "Transfer multiple orders to a destination warehouse") + public ApiResponse transferBatch( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody LastlegOrderRequests.TransferDestination request) { + return ApiResponse.success(orderService.transferDestination(scope(user, tenantId), request)); + } + + private String scope(AuthenticatedUser user, String requestedTenant) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform queries must specify a customer tenant"); + } + return requestedTenant; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java new file mode 100644 index 0000000..8807051 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dto; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; + +import java.util.List; + +public record LastlegOrderDetailResponse( + LastlegOrder order, + List cargo, + List statusHistory +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java new file mode 100644 index 0000000..712d2b8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderRequests { + private LastlegOrderRequests() { + } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String orderNo, + String inboundCode, + String sourceOrderNo, + String containerNo, + String status, + String transferWarehouseCode, + Boolean abnormal, + Boolean detained, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + public record TransferDestination( + @NotEmpty @Size(max = 200) List orderIds, + @NotBlank @Size(max = 128) String warehouseId, + @NotBlank @Size(max = 128) String warehouseCode, + @NotBlank @Size(max = 255) String warehouseName, + @Size(max = 500) String remark + ) { + } + + public record SingleTransferDestination( + @NotBlank @Size(max = 128) String warehouseId, + @NotBlank @Size(max = 128) String warehouseCode, + @NotBlank @Size(max = 255) String warehouseName, + @Size(max = 500) String remark + ) { + public TransferDestination withOrderId(long orderId) { + return new TransferDestination(List.of(orderId), warehouseId, warehouseCode, warehouseName, remark); + } + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java new file mode 100644 index 0000000..88c6f73 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "Tail-leg order row at inbound-code granularity") +public record LastlegOrderSummary( + Long id, + String orderNo, + String orderType, + String sourceInboundId, + String inboundCode, + String sourceOrderNo, + String customerName, + String overseasWarehouseId, + String overseasWarehouseName, + String transferWarehouseId, + String transferWarehouseCode, + String transferWarehouseName, + String fbaWarehouseCode, + String containerNo, + String containerStatus, + Boolean abnormal, + Boolean detained, + Integer palletCount, + Integer packageCount, + BigDecimal volumeCbm, + Integer productQuantity, + BigDecimal volumeWeightKg, + LocalDateTime estimatedArrivalAt, + LocalDateTime actualArrivalAt, + LocalDateTime estimatedWarehouseAt, + String inboundOrderNo, + String outboundOrderNo, + String deliveryOrderNo, + String relabelOrderNo, + String relabelStatus, + String palletMergeFlag, + String currentStatus, + String privateRemark, + String customerRemark, + String warehouseRemark, + LocalDateTime createdAt +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java b/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java new file mode 100644 index 0000000..199bbca --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +@Data @TableName("ll_loading_event_inbox") public class LastlegEventInbox { @TableId private String eventId; private String tenantId; private String eventType; private Integer eventVersion; private String payloadHash; private String payloadJson; private String status; private LocalDateTime receivedAt; private LocalDateTime processedAt; private String lastError; } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java new file mode 100644 index 0000000..ddc5cfa --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order") +public class LastlegOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String orderNo; + private String tenantId; + private String orderType; + private String inboundCode; + private String sourceRegion; + private String sourceInboundId; + private String rootInboundId; + private String sourceOrderId; + private String sourceOrderNo; + private String rootOrderId; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String fbaWarehouseCode; + private String containerNo; + private String transferWarehouseId; + private String transferWarehouseCode; + private String transferWarehouseName; + private String containerStatus; + private Boolean abnormal; + private Boolean detained; + private Integer palletCount; + private Integer packageCount; + private BigDecimal volumeCbm; + private Integer productQuantity; + private BigDecimal volumeWeightKg; + private LocalDateTime estimatedArrivalAt; + private LocalDateTime actualArrivalAt; + private LocalDateTime estimatedWarehouseAt; + private String inboundOrderNo; + private String outboundOrderNo; + private String deliveryOrderNo; + private String relabelOrderNo; + private String relabelStatus; + private String palletMergeFlag; + private String currentStatus; + private String privateRemark; + private String customerRemark; + private String warehouseRemark; + private String sourceEventId; + private String sourceLoadingPlanId; + private Long sourceLoadingPlanVersion; + private Long sourceHandoverVersion; + private String sourcePayloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java b/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java new file mode 100644 index 0000000..557f7d7 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.math.BigDecimal; +@Data @TableName("ll_order_detail") public class LastlegOrderDetail { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String sourceInboundDetailId; private String skuCode; private String productNameZh; private String productNameEn; private Integer packageCount; private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java b/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java new file mode 100644 index 0000000..4ca0f92 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +@Data @TableName("ll_loading_order_status_history") public class LastlegOrderStatusHistory { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String fromStatus; private String toStatus; private String eventId; private String remark; private LocalDateTime createdAt; } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java new file mode 100644 index 0000000..a165bf4 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan({"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper"}) +public class InboundPlanMapperConfiguration { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java new file mode 100644 index 0000000..59ba597 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans") +@Tag(name = "Tail-leg inbound plans") +public class InboundPlanController { + @Resource private InboundPlanService service; + + @PostMapping("/convert") + @Operation(summary = "Convert the entire container to one inbound plan") + public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ConvertInboundPlanRequest request) { + return ApiResponse.success(service.convert(scope(user, request.tenantId()), request.orderId())); + } + + @GetMapping("/{planNo}") + @Operation(summary = "Get an inbound plan with lines, pallets and allocations") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String tenantId) { + return ApiResponse.success(service.detailByPlanNo(scope(user, tenantId), planNo)); + } + + private String scope(AuthenticatedUser user, String requestedTenant) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedTenant; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java new file mode 100644 index 0000000..f78813a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotNull; + +public record ConvertInboundPlanRequest( + @NotNull Long orderId, + String tenantId +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java new file mode 100644 index 0000000..3dcc527 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record InboundPlanResponse( + Long id, + String planNo, + String tenantId, + String containerNo, + String status, + int lineCount, + int palletCount, + BigDecimal totalVolumeCbm, + List lines, + List pallets, + List allocations +) { + public record Line(Long id, Long orderId, String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { + } + + public record Pallet(Long id, String palletNo, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo, + Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId, + String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + int actualPackageCount) { + } + + public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm, + int plannedPackageCount, int countedPackageCount, int pickedPackageCount, + BigDecimal actualVolumeCbm, String status, int version) { + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java new file mode 100644 index 0000000..ce95e1c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_plan") +public class InboundPlan { + @TableId(type = IdType.INPUT) + private Long id; + private String planNo; + private String tenantId; + private String containerNo; + private String status; + private Integer lineCount; + private Integer palletCount; + private BigDecimal totalVolumeCbm; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java new file mode 100644 index 0000000..a50b965 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@TableName("ll_inbound_plan_line") +public class InboundPlanLine { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private Long orderId; + private String inboundCode; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal volumeCbm; + private Integer requiredPackageCount; + private Integer plannedPalletCount; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java new file mode 100644 index 0000000..b998ed6 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_plan_pallet") +public class InboundPlanPallet { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal capacityCbm; + private BigDecimal allocatedVolumeCbm; + private Integer sequenceNo; + private Long warehousePalletId; + private Long warehouseLocationId; + private Long warehouseResourceLockId; + private String assignmentStatus; + private LocalDateTime lockedAt; + private BigDecimal actualVolumeCbm; + private Integer actualPackageCount; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java new file mode 100644 index 0000000..e716cbe --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@TableName("ll_inbound_plan_pallet_allocation") +public class InboundPlanPalletAllocation { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private Long lineId; + private Long palletId; + private BigDecimal allocatedVolumeCbm; + private Integer plannedPackageCount; + private Integer countedPackageCount; + private Integer pickedPackageCount; + private BigDecimal actualVolumeCbm; + private String status; + private Integer version; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java new file mode 100644 index 0000000..d7480e0 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanLineMapper extends BaseMapper { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java new file mode 100644 index 0000000..fc668aa --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +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; + +@Mapper +public interface InboundPlanMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_plan WHERE tenant_id=#{tenantId} AND container_no=#{containerNo} LIMIT 1") + InboundPlan findByContainer(@Param("tenantId") String tenantId, @Param("containerNo") String containerNo); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java new file mode 100644 index 0000000..3355627 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface InboundPlanOrderMapper { + @Select("SELECT container_no FROM ll_order WHERE tenant_id=#{tenantId} AND id=#{orderId} LIMIT 1") + String findContainerNo(@Param("tenantId") String tenantId, @Param("orderId") Long orderId); + + @Select("SELECT o.id AS orderId,o.tenant_id AS tenantId,o.container_no AS containerNo," + + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + + "COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," + + "SUM(CASE WHEN d.id IS NOT NULL " + + "AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " + + "AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " + + "<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " + + "AS destinationMismatchCount " + + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + + "WHERE o.tenant_id=#{tenantId} AND o.container_no=#{containerNo} " + + "GROUP BY o.id,o.tenant_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count," + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + + "ORDER BY o.id FOR UPDATE") + List lockContainerOrders(@Param("tenantId") String tenantId, + @Param("containerNo") String containerNo); + + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + + "WHERE tenant_id=#{tenantId} AND id=#{orderId}") + int markPlanned(@Param("tenantId") String tenantId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("updatedAt") LocalDateTime updatedAt); + + @Insert("INSERT INTO ll_loading_order_status_history(order_id,from_status,to_status,remark,created_at) " + + "VALUES(#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})") + int insertHistory(@Param("orderId") Long orderId, @Param("fromStatus") String fromStatus, + @Param("remark") String remark, @Param("createdAt") LocalDateTime createdAt); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java new file mode 100644 index 0000000..a2c825b --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanPalletAllocationMapper extends BaseMapper { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java new file mode 100644 index 0000000..c700504 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanPalletMapper extends BaseMapper { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java new file mode 100644 index 0000000..560d847 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.inboundplan.model; + +import java.math.BigDecimal; + +public record SourceOrderSnapshot( + Long orderId, + String tenantId, + String containerNo, + String inboundCode, + String currentStatus, + String inboundOrderNo, + BigDecimal volumeCbm, + int packageCount, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + int destinationCount, + int destinationMismatchCount +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java new file mode 100644 index 0000000..5f8d0e7 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java @@ -0,0 +1,43 @@ +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/platform/v1/last-leg/inbound-plans/{planNo}/pallets") +public class InboundPalletAssignmentController { + @Resource private InboundPdaWorkflowService service; + + @PostMapping("/{planPalletId}/assign") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, @PathVariable long planPalletId, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody PdaRequests.Assign request) { + service.assign(scope(user, tenantId), planNo, planPalletId, request); + return ApiResponse.success(null); + } + + private String scope(AuthenticatedUser user, String requestedTenant) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedTenant; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java new file mode 100644 index 0000000..11b188c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -0,0 +1,94 @@ +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.dto.PdaResponses; +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.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda") +public class InboundPdaController { + @Resource private InboundPdaWorkflowService service; + + @PostMapping("/push") + public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody PdaRequests.Push request) { + return ApiResponse.success(service.push(scope(user, tenantId), planNo, request)); + } + + @GetMapping + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String tenantId) { + return ApiResponse.success(service.task(scope(user, tenantId), planNo)); + } + + @PostMapping("/allocations/{allocationId}/count") + public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody PdaRequests.PackageOperation request) { + return ApiResponse.success(service.count(scope(user, tenantId), planNo, allocationId, request)); + } + + @PostMapping("/allocations/{allocationId}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody PdaRequests.PackageOperation request) { + return ApiResponse.success(service.pick(scope(user, tenantId), planNo, allocationId, request)); + } + + @GetMapping("/allocations/{allocationId}/merge-candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @PathVariable long allocationId, @RequestParam(required = false) String tenantId) { + return ApiResponse.success(service.mergeCandidates(scope(user, tenantId), planNo, allocationId)); + } + + @PostMapping("/allocations/{allocationId}/merge") + public ApiResponse merge(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody PdaRequests.ConfirmMerge request) { + return ApiResponse.success(service.merge(scope(user, tenantId), planNo, allocationId, request)); + } + + @PostMapping("/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String tenantId) { + return ApiResponse.success(service.complete(scope(user, tenantId), planNo)); + } + + private String scope(AuthenticatedUser user, String requestedTenant) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedTenant; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java new file mode 100644 index 0000000..3a1d388 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class PdaRequests { + private PdaRequests() {} + + public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record Assign(@Positive long warehouseId, @Positive long palletId, @Positive long locationId, + @NotBlank String idempotencyKey) {} + + public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} + + public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, + @NotBlank String idempotencyKey, String operatorId, String operatorName) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java new file mode 100644 index 0000000..0a9b262 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaResponses { + private PdaResponses() {} + + public record Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt) {} + + public record Operation(String result, long allocationId, int requiredPackageCount, + int countedPackageCount, int pickedPackageCount, + BigDecimal actualVolumeCbm, Integer remainingPackageCount, + Integer requestedPackageCount, Integer mergePackageCount, + List candidates) {} + + public record MergeCandidate(long allocationId, long palletId, String palletNo, long lineId, + long orderId, String inboundCode, int availablePackageCount, + BigDecimal availableVolumeCbm) {} + + public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId, + int movedPackageCount, BigDecimal movedVolumeCbm, + boolean sourcePalletScrapped) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java new file mode 100644 index 0000000..834a377 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java @@ -0,0 +1,32 @@ +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.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_pallet_merge") +public class InboundPalletMerge { + @TableId(type = IdType.INPUT) + private Long id; + private String tenantId; + private Long taskId; + private String mergeGroupNo; + private Long sourcePalletId; + private Long targetPalletId; + private Long sourceAllocationId; + private Long targetAllocationId; + private Long sourceLineId; + private Long targetLineId; + private Long sourceOrderId; + private Long targetOrderId; + private Integer movedPackageCount; + private BigDecimal movedVolumeCbm; + private Boolean sourcePalletScrapped; + private String status; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java new file mode 100644 index 0000000..84e6c7e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java @@ -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_inbound_pda_operation") +public class InboundPdaOperation { + @TableId(type = IdType.INPUT) + private Long id; + private String tenantId; + private Long taskId; + private Long allocationId; + private String action; + private String idempotencyKey; + private String requestHash; + private String requestJson; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java new file mode 100644 index 0000000..2cf1fb8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java @@ -0,0 +1,27 @@ +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_inbound_pda_task") +public class InboundPdaTask { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private String tenantId; + private String taskNo; + private String status; + private String operatorId; + private String operatorName; + private LocalDateTime pushedAt; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer version; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java new file mode 100644 index 0000000..484c975 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.util.Map; + +@Component +public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { + @Resource private RestClient.Builder restClientBuilder; + + @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 initializeClient() { + this.client = restClientBuilder.baseUrl(baseUrl).build(); + } + + @Override + public long reserve(String tenantId, long warehouseId, long palletId, long locationId, + String businessType, String businessId, String reservationKey) { + ReserveEnvelope response = client.post() + .uri("/internal/v1/warehouse-resource-locks") + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("tenantId", Long.parseLong(tenantId), "warehouseId", warehouseId, + "palletId", palletId, "locationId", locationId, "businessType", businessType, + "businessId", businessId, "reservationKey", reservationKey)) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode()); + }) + .body(ReserveEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response"); + return response.data().lockId(); + } + + @Override + public void scrap(String tenantId, long resourceLockId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/{lockId}/scrap", resourceLockId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("tenantId", Long.parseLong(tenantId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + + private record ReserveEnvelope(ReserveData data) {} + private record ReserveData(long lockId) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java new file mode 100644 index 0000000..b5ceb7f --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +public interface WarehouseResourceGateway { + long reserve(String tenantId, long warehouseId, long palletId, long locationId, + String businessType, String businessId, String reservationKey); + void scrap(String tenantId, long resourceLockId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java new file mode 100644 index 0000000..515ce95 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPalletMergeMapper extends BaseMapper { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java new file mode 100644 index 0000000..719b206 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPdaOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_pda_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1") + InboundPdaOperation findByKey(@Param("tenantId") String tenantId, @Param("key") String key); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java new file mode 100644 index 0000000..4547945 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_pda_task WHERE tenant_id=#{tenantId} AND plan_id=#{planId} LIMIT 1 FOR UPDATE") + InboundPdaTask lockByPlan(@Param("tenantId") String tenantId, @Param("planId") Long planId); + + @Select("SELECT * FROM ll_inbound_pda_task WHERE tenant_id=#{tenantId} AND plan_id=#{planId} LIMIT 1") + InboundPdaTask findByPlan(@Param("tenantId") String tenantId, @Param("planId") Long planId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java new file mode 100644 index 0000000..f77e285 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -0,0 +1,79 @@ +package com.baiyue.lastleg.inboundplan.pda.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +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.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.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +import java.util.List; + +@Repository +public class PdaWorkflowRepository { + @Resource private InboundPlanMapper plans; + @Resource private InboundPlanLineMapper lines; + @Resource private InboundPlanPalletMapper pallets; + @Resource private InboundPlanPalletAllocationMapper allocations; + @Resource private InboundPdaTaskMapper tasks; + @Resource private InboundPdaOperationMapper operations; + @Resource private InboundPalletMergeMapper merges; + + public InboundPlan lockPlan(String tenantId, String planNo) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getTenantId, tenantId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); + } + + public InboundPdaTask lockTask(String tenantId, Long planId) { return tasks.lockByPlan(tenantId, planId); } + public InboundPdaTask task(String tenantId, Long planId) { return tasks.findByPlan(tenantId, planId); } + public InboundPdaOperation operation(String tenantId, String key) { return operations.findByKey(tenantId, key); } + + public InboundPlanPalletAllocation lockAllocation(Long planId, Long allocationId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId) + .eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE")); + } + + public InboundPlanPallet lockPallet(Long planId, Long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE")); + } + + public InboundPlanLine line(Long planId, Long lineId) { + return lines.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, planId).eq(InboundPlanLine::getId, lineId)); + } + + public List lines(Long planId) { + return lines.selectList(new LambdaQueryWrapper().eq(InboundPlanLine::getPlanId, planId)); + } + + public List pallets(Long planId) { + return pallets.selectList(new LambdaQueryWrapper().eq(InboundPlanPallet::getPlanId, planId)); + } + + public List allocations(Long planId) { + return allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId)); + } + + public void insert(InboundPdaTask value) { tasks.insert(value); } + public void insert(InboundPdaOperation value) { operations.insert(value); } + public void insert(InboundPalletMerge value) { merges.insert(value); } + public void insert(InboundPlanPalletAllocation value) { allocations.insert(value); } + public void update(InboundPlan value) { plans.updateById(value); } + public void update(InboundPdaTask value) { tasks.updateById(value); } + public void update(InboundPlanPallet value) { pallets.updateById(value); } + public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java new file mode 100644 index 0000000..d19b06c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -0,0 +1,527 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +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.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.math.BigDecimal; +import java.math.RoundingMode; +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.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class InboundPdaWorkflowService { + private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); + + @Resource private PdaWorkflowRepository repository; + @Resource private WarehouseResourceGateway warehouse; + @Resource private ObjectMapper sourceObjectMapper; + + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + this.objectMapper = sourceObjectMapper.copy() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + @Transactional + public PdaResponses.Task push(String tenantId, String planNo, PdaRequests.Push request) { + InboundPlan plan = requirePlan(tenantId, planNo); + InboundPdaOperation replay = replay(tenantId, "PUSH", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + + List pallets = repository.pallets(plan.getId()); + if (pallets.isEmpty() || pallets.stream().anyMatch(this::isNotAssigned)) { + throw conflict("Every planned pallet must have an assigned and locked warehouse pallet/location"); + } + InboundPdaTask task = repository.lockTask(tenantId, plan.getId()); + LocalDateTime now = LocalDateTime.now(); + if (task == null) { + task = new InboundPdaTask(); + task.setId(IdWorker.getId()); + task.setPlanId(plan.getId()); + task.setTenantId(tenantId); + task.setTaskNo("PDA" + task.getId()); + task.setStatus("PDA_PENDING"); + task.setOperatorId(request.operatorId()); + task.setOperatorName(request.operatorName()); + task.setPushedAt(now); + task.setCreatedAt(now); + task.setUpdatedAt(now); + task.setVersion(0); + repository.insert(task); + } + plan.setStatus("PDA_PENDING"); + plan.setUpdatedAt(now); + repository.update(plan); + PdaResponses.Task result = task(task, planNo); + saveOperation(tenantId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public void assign(String tenantId, String planNo, long planPalletId, PdaRequests.Assign request) { + InboundPlan plan = requirePlan(tenantId, planNo); + InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId); + validateDestinationWarehouse(pallet, request.warehouseId()); + if (pallet.getWarehouseResourceLockId() != null) { + if (Objects.equals(pallet.getWarehousePalletId(), request.palletId()) + && Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; + throw conflict("Planned pallet is already assigned to another warehouse resource"); + } + String businessId = planNo + ":" + planPalletId; + long lockId = warehouse.reserve(tenantId, request.warehouseId(), request.palletId(), request.locationId(), + "INBOUND_PLAN_PALLET", businessId, request.idempotencyKey()); + pallet.setWarehousePalletId(request.palletId()); + pallet.setWarehouseLocationId(request.locationId()); + pallet.setWarehouseResourceLockId(lockId); + pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); + repository.update(pallet); + List pallets = repository.pallets(plan.getId()); + if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) { + plan.setStatus("ASSIGNED"); + plan.setUpdatedAt(LocalDateTime.now()); + repository.update(plan); + } + } + + @Transactional + public PdaResponses.Task task(String tenantId, String planNo) { + InboundPlan plan = requirePlan(tenantId, planNo); + InboundPdaTask task = repository.task(tenantId, plan.getId()); + if (task == null) throw notFound("PDA task not found"); + return task(task, planNo); + } + + @Transactional + public PdaResponses.Operation count(String tenantId, String planNo, long allocationId, + PdaRequests.PackageOperation request) { + InboundPlan plan = requirePlan(tenantId, planNo); + InboundPdaOperation replay = replay(tenantId, "COUNT", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(tenantId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + int picked = value(allocation.getPickedPackageCount()); + if (request.packageCount() < picked) { + throw conflict("Counted package count cannot be less than already picked packages"); + } + if (request.packageCount() > value(allocation.getPlannedPackageCount())) { + throw conflict("Counted package count cannot exceed this pallet allocation"); + } + allocation.setCountedPackageCount(request.packageCount()); + allocation.setStatus(request.packageCount() == value(allocation.getPlannedPackageCount()) + ? "COUNTED" : "COUNTING"); + bump(allocation); + repository.update(allocation); + startPicking(task, plan, request.operatorId(), request.operatorName()); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + PdaResponses.Operation result = operation("COUNTED", allocation, line, List.of(), null, null, null); + saveOperation(tenantId, task.getId(), allocationId, "COUNT", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Operation pick(String tenantId, String planNo, long allocationId, + PdaRequests.PackageOperation request) { + InboundPlan plan = requirePlan(tenantId, planNo); + InboundPdaOperation replay = replay(tenantId, "PICK", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(tenantId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + int picked = value(allocation.getPickedPackageCount()); + int countedRemaining = value(allocation.getCountedPackageCount()) - picked; + if (countedRemaining <= 0) throw conflict("Count this pallet allocation before picking"); + int plannedRemaining = value(allocation.getPlannedPackageCount()) - picked; + if (request.packageCount() > plannedRemaining) { + int mergeCount = request.packageCount() - Math.max(0, plannedRemaining); + List candidates = candidates(plan, allocation, mergeCount); + PdaResponses.Operation result = operation("MERGE_CONFIRMATION_REQUIRED", allocation, line, + candidates, plannedRemaining, request.packageCount(), mergeCount); + saveOperation(tenantId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result); + return result; + } + if (request.packageCount() > countedRemaining) { + throw conflict("Picked package count cannot exceed the counted remaining packages"); + } + BigDecimal perPackage = perPackageVolume(line); + int newPicked = picked + request.packageCount(); + allocation.setPickedPackageCount(newPicked); + allocation.setActualVolumeCbm(perPackage.multiply(BigDecimal.valueOf(newPicked)).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(newPicked == value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING"); + bump(allocation); + repository.update(allocation); + InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId()); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + request.packageCount()); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add( + perPackage.multiply(BigDecimal.valueOf(request.packageCount()))).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); + startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Operation result = operation("PICKED", allocation, line, List.of(), null, null, null); + saveOperation(tenantId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public List mergeCandidates(String tenantId, String planNo, + long allocationId) { + InboundPlan plan = requirePlan(tenantId, planNo); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + return candidates(plan, allocation, 1); + } + + @Transactional + public PdaResponses.Merge merge(String tenantId, String planNo, long targetAllocationId, + PdaRequests.ConfirmMerge request) { + InboundPlan plan = requirePlan(tenantId, planNo); + InboundPdaOperation replay = replay(tenantId, "MERGE", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Merge.class); + InboundPdaTask task = requireActiveTask(tenantId, plan); + if (request.sourceAllocationId() == targetAllocationId) throw conflict("Source and target allocations differ"); + InboundPlanPalletAllocation target = requireAllocation(plan.getId(), targetAllocationId); + InboundPlanPalletAllocation source = requireAllocation(plan.getId(), request.sourceAllocationId()); + InboundPlanPallet targetPallet = requirePallet(plan.getId(), target.getPalletId()); + InboundPlanPallet sourcePallet = requirePallet(plan.getId(), source.getPalletId()); + if (Objects.equals(targetPallet.getId(), sourcePallet.getId())) throw conflict("Source and target pallets differ"); + if (!sameWarehouse(targetPallet, sourcePallet)) throw conflict("Pallet merge requires the same destination warehouse"); + if (value(source.getPickedPackageCount()) < request.packageCount()) { + throw conflict("Source pallet does not contain enough picked packages"); + } + InboundPlanLine sourceLine = requireLine(plan.getId(), source.getLineId()); + InboundPlanLine targetLine = requireLine(plan.getId(), target.getLineId()); + BigDecimal movedVolume = perPackageVolume(sourceLine) + .multiply(BigDecimal.valueOf(request.packageCount())).setScale(4, RoundingMode.HALF_UP); + if (value(sourcePallet.getActualPackageCount()) < request.packageCount()) { + throw conflict("Source pallet actual package count is insufficient"); + } + if (targetPallet.getCapacityCbm() != null + && volume(targetPallet.getActualVolumeCbm()).add(movedVolume).compareTo(targetPallet.getCapacityCbm()) > 0) { + throw conflict("Merged volume exceeds target pallet capacity"); + } + + moveSourceAllocation(plan.getId(), source, targetPallet.getId(), request.packageCount(), movedVolume); + sourcePallet.setActualPackageCount(value(sourcePallet.getActualPackageCount()) - request.packageCount()); + sourcePallet.setActualVolumeCbm(nonNegative(volume(sourcePallet.getActualVolumeCbm()).subtract(movedVolume))); + targetPallet.setActualPackageCount(value(targetPallet.getActualPackageCount()) + request.packageCount()); + targetPallet.setActualVolumeCbm(volume(targetPallet.getActualVolumeCbm()).add(movedVolume)); + boolean scrapped = sourcePallet.getActualPackageCount() == 0; + if (scrapped) { + if (sourcePallet.getWarehouseResourceLockId() == null) { + throw conflict("Source pallet has no warehouse resource lock"); + } + warehouse.scrap(tenantId, sourcePallet.getWarehouseResourceLockId()); + sourcePallet.setAssignmentStatus("SCRAPPED"); + sourcePallet.setWarehouseLocationId(null); + sourcePallet.setWarehouseResourceLockId(null); + } + repository.update(sourcePallet); + repository.update(targetPallet); + + String groupNo = "PMG" + IdWorker.getId(); + InboundPalletMerge merge = new InboundPalletMerge(); + merge.setId(IdWorker.getId()); + merge.setTenantId(tenantId); + merge.setTaskId(task.getId()); + merge.setMergeGroupNo(groupNo); + merge.setSourcePalletId(sourcePallet.getId()); + merge.setTargetPalletId(targetPallet.getId()); + merge.setSourceAllocationId(source.getId()); + merge.setTargetAllocationId(target.getId()); + merge.setSourceLineId(sourceLine.getId()); + merge.setTargetLineId(targetLine.getId()); + merge.setSourceOrderId(sourceLine.getOrderId()); + merge.setTargetOrderId(targetLine.getOrderId()); + merge.setMovedPackageCount(request.packageCount()); + merge.setMovedVolumeCbm(movedVolume); + merge.setSourcePalletScrapped(scrapped); + merge.setStatus("CONFIRMED"); + merge.setCreatedAt(LocalDateTime.now()); + repository.insert(merge); + PdaResponses.Merge result = new PdaResponses.Merge("MERGED", groupNo, sourceLine.getOrderId(), + targetLine.getOrderId(), request.packageCount(), movedVolume, scrapped); + saveOperation(tenantId, task.getId(), targetAllocationId, "MERGE", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Task complete(String tenantId, String planNo) { + InboundPlan plan = requirePlan(tenantId, planNo); + InboundPdaTask task = requireActiveTask(tenantId, plan); + Map pickedByLine = new HashMap<>(); + for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) { + pickedByLine.merge(allocation.getLineId(), value(allocation.getPickedPackageCount()), Integer::sum); + } + List lines = repository.lines(plan.getId()); + if (lines.isEmpty()) throw conflict("Inbound plan has no lines"); + for (InboundPlanLine line : lines) { + if (pickedByLine.getOrDefault(line.getId(), 0) != value(line.getRequiredPackageCount())) { + throw conflict("All line picked package totals must exactly equal required package totals"); + } + } + LocalDateTime now = LocalDateTime.now(); + task.setStatus("COMPLETED"); + task.setCompletedAt(now); + task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("COMPLETED"); + plan.setUpdatedAt(now); + repository.update(plan); + return task(task, planNo); + } + + private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, + int packageCount, BigDecimal movedVolume) { + source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); + source.setCountedPackageCount(Math.max(source.getPickedPackageCount(), + value(source.getCountedPackageCount()) - packageCount)); + source.setActualVolumeCbm(nonNegative(volume(source.getActualVolumeCbm()).subtract(movedVolume))); + source.setStatus(source.getPickedPackageCount() == 0 ? "MERGED_OUT" : "PICKED"); + bump(source); + repository.update(source); + + InboundPlanPalletAllocation moved = repository.allocations(planId).stream() + .filter(value -> Objects.equals(value.getLineId(), source.getLineId())) + .filter(value -> Objects.equals(value.getPalletId(), targetPalletId)) + .findFirst().orElse(null); + if (moved == null) { + moved = new InboundPlanPalletAllocation(); + moved.setId(IdWorker.getId()); + moved.setPlanId(planId); + moved.setLineId(source.getLineId()); + moved.setPalletId(targetPalletId); + moved.setAllocatedVolumeCbm(ZERO_VOLUME); + moved.setPlannedPackageCount(0); + moved.setCountedPackageCount(packageCount); + moved.setPickedPackageCount(packageCount); + moved.setActualVolumeCbm(movedVolume); + moved.setStatus("MERGED_IN"); + moved.setVersion(0); + repository.insert(moved); + } else { + moved.setCountedPackageCount(value(moved.getCountedPackageCount()) + packageCount); + moved.setPickedPackageCount(value(moved.getPickedPackageCount()) + packageCount); + moved.setActualVolumeCbm(volume(moved.getActualVolumeCbm()).add(movedVolume)); + moved.setStatus("MERGED_IN"); + bump(moved); + repository.update(moved); + } + } + + private List candidates(InboundPlan plan, + InboundPlanPalletAllocation target, + int needed) { + Map palletById = new HashMap<>(); + for (InboundPlanPallet pallet : repository.pallets(plan.getId())) palletById.put(pallet.getId(), pallet); + Map lineById = new HashMap<>(); + for (InboundPlanLine line : repository.lines(plan.getId())) lineById.put(line.getId(), line); + InboundPlanPallet targetPallet = palletById.get(target.getPalletId()); + if (targetPallet == null) throw notFound("Target pallet not found"); + List result = new ArrayList<>(); + for (InboundPlanPalletAllocation candidate : repository.allocations(plan.getId())) { + if (Objects.equals(candidate.getPalletId(), target.getPalletId())) continue; + int available = value(candidate.getPickedPackageCount()); + if (available <= 0) continue; + InboundPlanPallet pallet = palletById.get(candidate.getPalletId()); + InboundPlanLine line = lineById.get(candidate.getLineId()); + if (pallet == null || line == null || !sameWarehouse(targetPallet, pallet)) continue; + if (targetPallet.getCapacityCbm() != null) { + BigDecimal remainingVolume = targetPallet.getCapacityCbm() + .subtract(volume(targetPallet.getActualVolumeCbm())); + BigDecimal packageVolume = perPackageVolume(line); + if (packageVolume.signum() > 0) { + int capacityPackages = remainingVolume.signum() <= 0 ? 0 + : remainingVolume.divide(packageVolume, 0, RoundingMode.DOWN).intValue(); + available = Math.min(available, capacityPackages); + if (available <= 0) continue; + } + } + result.add(new PdaResponses.MergeCandidate(candidate.getId(), pallet.getId(), pallet.getPalletNo(), + line.getId(), line.getOrderId(), line.getInboundCode(), available, + perPackageVolume(line).multiply(BigDecimal.valueOf(available)).setScale(4, RoundingMode.HALF_UP))); + } + result.sort(Comparator.comparingInt(PdaResponses.MergeCandidate::availablePackageCount).reversed()); + return result; + } + + private boolean sameWarehouse(InboundPlanPallet left, InboundPlanPallet right) { + String leftKey = left.getDestinationWarehouseId() != null + ? left.getDestinationWarehouseId() : left.getDestinationWarehouseCode(); + String rightKey = right.getDestinationWarehouseId() != null + ? right.getDestinationWarehouseId() : right.getDestinationWarehouseCode(); + return leftKey != null && leftKey.equals(rightKey); + } + + private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) { + String destinationId = pallet.getDestinationWarehouseId(); + if (destinationId == null || destinationId.isBlank()) return; + try { + if (Long.parseLong(destinationId) != warehouseId) { + throw conflict("Warehouse resource must belong to the planned destination warehouse"); + } + } catch (NumberFormatException exception) { + throw conflict("Planned destination warehouse ID is not a numeric warehouse resource ID"); + } + } + + private void startPicking(InboundPdaTask task, InboundPlan plan, String operatorId, String operatorName) { + LocalDateTime now = LocalDateTime.now(); + if (task.getStartedAt() == null) task.setStartedAt(now); + task.setStatus("PICKING"); + task.setOperatorId(operatorId); + task.setOperatorName(operatorName); + task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("PICKING"); + plan.setUpdatedAt(now); + repository.update(plan); + } + + private boolean isNotAssigned(InboundPlanPallet pallet) { + return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null + || pallet.getWarehouseResourceLockId() == null + || !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())); + } + + private InboundPlan requirePlan(String tenantId, String planNo) { + InboundPlan plan = repository.lockPlan(tenantId, planNo); + if (plan == null) throw notFound("Inbound plan not found"); + return plan; + } + + private InboundPdaTask requireActiveTask(String tenantId, InboundPlan plan) { + InboundPdaTask task = repository.lockTask(tenantId, plan.getId()); + if (task == null) throw conflict("Push the inbound plan to PDA before operating"); + if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed"); + return task; + } + + private InboundPlanPalletAllocation requireAllocation(Long planId, Long id) { + InboundPlanPalletAllocation value = repository.lockAllocation(planId, id); + if (value == null) throw notFound("Pallet allocation not found"); + return value; + } + + private InboundPlanPallet requirePallet(Long planId, Long id) { + InboundPlanPallet value = repository.lockPallet(planId, id); + if (value == null) throw notFound("Planned pallet not found"); + return value; + } + + private InboundPlanLine requireLine(Long planId, Long id) { + InboundPlanLine value = repository.line(planId, id); + if (value == null) throw notFound("Inbound plan line not found"); + return value; + } + + private PdaResponses.Operation operation(String result, InboundPlanPalletAllocation allocation, + InboundPlanLine line, + List candidates, + Integer remaining, Integer requested, Integer merge) { + return new PdaResponses.Operation(result, allocation.getId(), value(line.getRequiredPackageCount()), + value(allocation.getCountedPackageCount()), value(allocation.getPickedPackageCount()), + volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates); + } + + private PdaResponses.Task task(InboundPdaTask task, String planNo) { + return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt()); + } + + private BigDecimal perPackageVolume(InboundPlanLine line) { + int required = value(line.getRequiredPackageCount()); + if (required <= 0) throw conflict("Inbound line required package count must be positive"); + return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP); + } + + private InboundPdaOperation replay(String tenantId, String action, String key, Object request) { + InboundPdaOperation operation = repository.operation(tenantId, key); + if (operation == null) return null; + if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different request"); + } + return operation; + } + + private void saveOperation(String tenantId, Long taskId, Long allocationId, String action, + String key, Object request, Object result) { + InboundPdaOperation operation = new InboundPdaOperation(); + operation.setId(IdWorker.getId()); + operation.setTenantId(tenantId); + operation.setTaskId(taskId); + operation.setAllocationId(allocationId); + operation.setAction(action); + operation.setIdempotencyKey(key); + operation.setRequestHash(hash(request)); + operation.setRequestJson(write(request)); + operation.setResultJson(write(result)); + operation.setCreatedAt(LocalDateTime.now()); + repository.insert(operation); + } + + private String hash(Object value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize PDA operation", exception); + } + } + + private T read(InboundPdaOperation operation, Class type) { + try { + return objectMapper.readValue(operation.getResultJson(), type); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read persisted PDA operation result", exception); + } + } + + private void bump(InboundPlanPalletAllocation allocation) { + allocation.setVersion(value(allocation.getVersion()) + 1); + } + + private static int value(Integer value) { return value == null ? 0 : value; } + private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; } + private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); } + 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); } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java new file mode 100644 index 0000000..afbdccd --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -0,0 +1,238 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +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.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +public class InboundPlanService { + @Resource private InboundPlanMapper planMapper; + @Resource private InboundPlanLineMapper lineMapper; + @Resource private InboundPlanPalletMapper palletMapper; + @Resource private InboundPlanPalletAllocationMapper allocationMapper; + @Resource private InboundPlanOrderMapper orderMapper; + + @Transactional + public InboundPlanResponse convert(String tenantId, Long selectedOrderId) { + String containerNo = trimToNull(orderMapper.findContainerNo(tenantId, selectedOrderId)); + if (containerNo == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Order does not exist in this tenant or container number is empty"); + } + + List orders = orderMapper.lockContainerOrders(tenantId, containerNo); + if (orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found"); + } + InboundPlan existing = planMapper.findByContainer(tenantId, containerNo); + if (existing != null) { + return detail(tenantId, existing); + } + validateOrders(orders); + + LocalDateTime now = LocalDateTime.now(); + InboundPlan plan = new InboundPlan(); + plan.setId(IdWorker.getId()); + plan.setPlanNo("LIP" + plan.getId()); + plan.setTenantId(tenantId); + plan.setContainerNo(containerNo); + plan.setStatus("PENDING_ASSIGNMENT"); + plan.setLineCount(orders.size()); + plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + plan.setCreatedAt(now); + plan.setUpdatedAt(now); + + Map orderById = orders.stream() + .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); + Map lineByOrder = createLines(plan, orders); + PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream() + .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), + order.packageCount())) + .toList()); + plan.setPalletCount(allocation.pallets().size()); + planMapper.insert(plan); + + lineByOrder.values().forEach(lineMapper::insert); + Map persistedPallets = createPallets(plan, allocation, orderById); + createAllocations(plan, allocation, lineByOrder, persistedPallets); + + Map palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( + PalletAllocator.AllocationDraft::orderId, + Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + for (SourceOrderSnapshot order : orders) { + int count = palletCounts.getOrDefault(order.orderId(), 0); + InboundPlanLine line = lineByOrder.get(order.orderId()); + line.setPlannedPalletCount(count); + lineMapper.updateById(line); + if (orderMapper.markPlanned(tenantId, order.orderId(), plan.getPlanNo(), count, now) != 1) { + throw new IllegalStateException("Failed to update order " + order.orderId()); + } + orderMapper.insertHistory(order.orderId(), order.currentStatus(), + "Container converted to inbound plan " + plan.getPlanNo(), now); + } + return detail(tenantId, plan); + } + + @Transactional(readOnly = true) + public InboundPlanResponse detailByPlanNo(String tenantId, String planNo) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getTenantId, tenantId).eq(InboundPlan::getPlanNo, planNo)); + if (plan == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + return detail(tenantId, plan); + } + + private Map createLines(InboundPlan plan, List orders) { + Map result = new HashMap<>(); + for (SourceOrderSnapshot order : orders) { + InboundPlanLine line = new InboundPlanLine(); + line.setId(IdWorker.getId()); + line.setPlanId(plan.getId()); + line.setOrderId(order.orderId()); + line.setInboundCode(order.inboundCode()); + line.setDestinationWarehouseId(order.destinationWarehouseId()); + line.setDestinationWarehouseCode(order.destinationWarehouseCode()); + line.setDestinationWarehouseName(order.destinationWarehouseName()); + line.setVolumeCbm(volume(order)); + line.setRequiredPackageCount(order.packageCount()); + line.setPlannedPalletCount(0); + result.put(order.orderId(), line); + } + return result; + } + + private Map createPallets( + InboundPlan plan, PalletAllocator.Result allocation, Map orderById) { + Map warehouseSamples = orderById.values().stream() + .collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left)); + Map result = new HashMap<>(); + for (PalletAllocator.PalletDraft draft : allocation.pallets()) { + SourceOrderSnapshot sample = warehouseSamples.get(draft.warehouseKey()); + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); + pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1)); + pallet.setDestinationWarehouseId(sample.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(sample.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); + pallet.setAllocatedVolumeCbm(draft.allocatedVolumeCbm()); + pallet.setSequenceNo(result.size() + 1); + pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); + pallet.setActualPackageCount(0); + palletMapper.insert(pallet); + result.put(draft, pallet); + } + return result; + } + + private void createAllocations(InboundPlan plan, PalletAllocator.Result allocation, + Map lineByOrder, + Map pallets) { + for (PalletAllocator.AllocationDraft draft : allocation.allocations()) { + InboundPlanPalletAllocation item = new InboundPlanPalletAllocation(); + item.setId(IdWorker.getId()); + item.setPlanId(plan.getId()); + item.setLineId(lineByOrder.get(draft.orderId()).getId()); + item.setPalletId(pallets.get(draft.pallet()).getId()); + item.setAllocatedVolumeCbm(draft.allocatedVolumeCbm()); + item.setPlannedPackageCount(draft.plannedPackageCount()); + item.setCountedPackageCount(0); + item.setPickedPackageCount(0); + item.setActualVolumeCbm(BigDecimal.ZERO); + item.setStatus("PENDING_COUNT"); + item.setVersion(0); + allocationMapper.insert(item); + } + } + + private void validateOrders(List orders) { + for (SourceOrderSnapshot order : orders) { + if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + + " must have one destination warehouse consistent with all cargo details"); + } + if (order.inboundOrderNo() != null && !order.inboundOrderNo().isBlank()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code " + order.inboundCode() + " already belongs to an inbound plan"); + } + if (volume(order).signum() < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + " has negative volume"); + } + if (order.packageCount() < 0 || (order.packageCount() > 0 && volume(order).signum() == 0)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + " has invalid package count or volume"); + } + } + } + + private InboundPlanResponse detail(String tenantId, InboundPlan plan) { + if (!tenantId.equals(plan.getTenantId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId)); + List pallets = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, plan.getId()).orderByAsc(InboundPlanPallet::getSequenceNo)); + List allocations = allocationMapper.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, plan.getId()) + .orderByAsc(InboundPlanPalletAllocation::getId)); + return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getTenantId(), plan.getContainerNo(), + plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), + lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), + line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(), + line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(), + pallets.stream().map(pallet -> new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), + pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), + pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), + pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(), + pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), + pallet.getActualPackageCount())).toList(), + allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), + item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), + item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList()); + } + + private BigDecimal volume(SourceOrderSnapshot order) { + return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO); + } + + private String warehouseKey(SourceOrderSnapshot order) { + String id = trimToNull(order.destinationWarehouseId()); + String code = trimToNull(order.destinationWarehouseCode()); + return id != null ? "ID:" + id : code == null ? null : "CODE:" + code; + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java new file mode 100644 index 0000000..74f6f0a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.inboundplan.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class PalletAllocator { + public static final BigDecimal PALLET_CAPACITY_CBM = new BigDecimal("2.0000"); + + private PalletAllocator() { + } + + public static Result allocate(List lines) { + Map> groups = new LinkedHashMap<>(); + for (LineInput line : lines) { + if (line.volumeCbm() == null || line.volumeCbm().signum() < 0) { + throw new IllegalArgumentException("Volume must be zero or positive"); + } + if (line.packageCount() < 0) { + throw new IllegalArgumentException("Package count must be zero or positive"); + } + if (line.volumeCbm().signum() == 0 && line.packageCount() > 0) { + throw new IllegalArgumentException("Positive package count requires positive volume"); + } + groups.computeIfAbsent(line.warehouseKey(), ignored -> new ArrayList<>()).add(line); + } + + List pallets = new ArrayList<>(); + List allocations = new ArrayList<>(); + for (List group : groups.values()) { + PalletDraft current = null; + int sequence = 0; + for (LineInput line : group) { + BigDecimal remaining = line.volumeCbm(); + while (remaining.signum() > 0) { + if (current == null || current.remainingCapacity().signum() == 0) { + current = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO); + pallets.add(current); + } + BigDecimal allocated = remaining.min(current.remainingCapacity()); + current.add(allocated); + allocations.add(new AllocationDraft(line.orderId(), current, allocated, 0)); + remaining = remaining.subtract(allocated); + } + } + } + return new Result(List.copyOf(pallets), distributePackages(lines, allocations)); + } + + private static List distributePackages(List lines, + List allocations) { + Map linesByOrder = new LinkedHashMap<>(); + for (LineInput line : lines) { + if (linesByOrder.put(line.orderId(), line) != null) { + throw new IllegalArgumentException("Duplicate order id " + line.orderId()); + } + } + Map> allocationsByOrder = new LinkedHashMap<>(); + for (AllocationDraft allocation : allocations) { + allocationsByOrder.computeIfAbsent(allocation.orderId(), ignored -> new ArrayList<>()).add(allocation); + } + + List result = new ArrayList<>(allocations.size()); + Map replacements = new LinkedHashMap<>(); + for (LineInput line : lines) { + List orderAllocations = allocationsByOrder.getOrDefault(line.orderId(), List.of()); + int assigned = 0; + for (int index = 0; index < orderAllocations.size(); index++) { + AllocationDraft allocation = orderAllocations.get(index); + int packages = index == orderAllocations.size() - 1 + ? line.packageCount() - assigned + : allocation.allocatedVolumeCbm().multiply(BigDecimal.valueOf(line.packageCount())) + .divide(line.volumeCbm(), 0, RoundingMode.DOWN).intValueExact(); + assigned += packages; + replacements.put(allocation, new AllocationDraft(allocation.orderId(), allocation.pallet(), + allocation.allocatedVolumeCbm(), packages)); + } + } + for (AllocationDraft allocation : allocations) { + result.add(replacements.get(allocation)); + } + return List.copyOf(result); + } + + public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) { + } + + public static final class PalletDraft { + private final String warehouseKey; + private final int sequenceNo; + private BigDecimal allocatedVolumeCbm; + + private PalletDraft(String warehouseKey, int sequenceNo, BigDecimal allocatedVolumeCbm) { + this.warehouseKey = warehouseKey; + this.sequenceNo = sequenceNo; + this.allocatedVolumeCbm = allocatedVolumeCbm; + } + + private void add(BigDecimal volume) { + allocatedVolumeCbm = allocatedVolumeCbm.add(volume); + } + + public String warehouseKey() { + return warehouseKey; + } + + public int sequenceNo() { + return sequenceNo; + } + + public BigDecimal allocatedVolumeCbm() { + return allocatedVolumeCbm; + } + + public BigDecimal remainingCapacity() { + return PALLET_CAPACITY_CBM.subtract(allocatedVolumeCbm); + } + } + + public record AllocationDraft(Long orderId, PalletDraft pallet, BigDecimal allocatedVolumeCbm, + int plannedPackageCount) { + } + + public record Result(List pallets, List allocations) { + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java new file mode 100644 index 0000000..ec4dc39 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegEventInbox; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegEventInboxMapper extends BaseMapper {} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java new file mode 100644 index 0000000..1b5a957 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderDetail; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegOrderDetailMapper extends BaseMapper {} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java new file mode 100644 index 0000000..0d474db --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface LastlegOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_order WHERE tenant_id=#{tenantId} AND source_region=#{sourceRegion} " + + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") + LastlegOrder findSource(@Param("tenantId") String tenantId, + @Param("sourceRegion") String sourceRegion, + @Param("sourceInboundId") String sourceInboundId); + + @Select("SELECT COUNT(*) FROM ll_order WHERE tenant_id=#{tenantId} AND container_no=#{containerNo}") + long countContainer(@Param("tenantId") String tenantId, @Param("containerNo") String containerNo); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java new file mode 100644 index 0000000..4a52568 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegOrderStatusHistoryMapper extends BaseMapper {} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java b/lastleg/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java new file mode 100644 index 0000000..ba4ef39 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final String expectedToken; + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { this.expectedToken=expectedToken; } + public void verify(String actualToken) { + if(expectedToken==null||expectedToken.isBlank()) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"内部事件接收令牌未配置"); + byte[] expected=expectedToken.getBytes(StandardCharsets.UTF_8); + byte[] actual=actualToken==null?new byte[0]:actualToken.getBytes(StandardCharsets.UTF_8); + if(!MessageDigest.isEqual(expected,actual)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部服务令牌无效"); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java new file mode 100644 index 0000000..86b8976 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Component +public class LastlegOrderNoGenerator { + public String next() { + return "LMO" + IdWorker.getIdStr(); + } + + /** The physical container number is the tail-leg business order grouping boundary. */ + public String forContainerNo(String containerNo) { + if (containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("containerNo is required"); + } + String normalized = containerNo.trim(); + if (normalized.matches("[0-9]{1,29}")) { + return "LMO" + normalized; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(normalized.getBytes(StandardCharsets.UTF_8)); + String digits = new BigInteger(1, digest).toString(); + return "LMO" + digits.substring(0, Math.min(29, digits.length())); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java new file mode 100644 index 0000000..6be392c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -0,0 +1,171 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.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.ArrayList; +import java.util.List; +import java.util.Objects; + +@Service +public class LastlegOrderService { + public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; + public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED"; + public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; + + @Resource private LastlegOrderMapper orderMapper; + @Resource private LastlegOrderDetailMapper detailMapper; + @Resource private LastlegOrderStatusHistoryMapper historyMapper; + + public PageResult page(String tenantId, LastlegOrderRequests.Query query) { + Page page = orderMapper.selectPage( + new Page<>(query.page(), query.size()), filter(tenantId, query).orderByDesc(LastlegOrder::getCreatedAt)); + return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), + page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public LastlegOrderDetailResponse detail(String tenantId, long orderId) { + LastlegOrder order = findOwned(tenantId, orderId); + List cargo = detailMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); + List history = historyMapper.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderStatusHistory::getOrderId, orderId) + .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); + return new LastlegOrderDetailResponse(order, cargo, history); + } + + @Transactional + public int transferDestination(String tenantId, LastlegOrderRequests.TransferDestination request) { + List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); + } + List orders = orderMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getTenantId, tenantId).in(LastlegOrder::getId, ids).last("FOR UPDATE")); + if (orders.size() != ids.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); + } + for (LastlegOrder order : orders) { + ensureTransferable(order); + } + LocalDateTime now = LocalDateTime.now(); + for (LastlegOrder order : orders) { + String fromStatus = order.getCurrentStatus(); + order.setTransferWarehouseId(request.warehouseId()); + order.setTransferWarehouseCode(request.warehouseCode()); + order.setTransferWarehouseName(request.warehouseName()); + order.setCurrentStatus(DESTINATION_ASSIGNED); + order.setUpdatedAt(now); + orderMapper.updateById(order); + + LastlegOrderDetail detailUpdate = new LastlegOrderDetail(); + detailUpdate.setDestinationWarehouseId(request.warehouseId()); + detailUpdate.setDestinationWarehouseCode(request.warehouseCode()); + detailUpdate.setDestinationWarehouseName(request.warehouseName()); + detailMapper.update(detailUpdate, new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, order.getId())); + + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setOrderId(order.getId()); + history.setFromStatus(fromStatus); + history.setToStatus(DESTINATION_ASSIGNED); + history.setRemark(StringUtils.hasText(request.remark()) + ? request.remark() : "Destination warehouse assigned: " + request.warehouseCode()); + history.setCreatedAt(now); + historyMapper.insert(history); + } + return orders.size(); + } + + public byte[] exportCsv(String tenantId, LastlegOrderRequests.Query query) { + List orders = orderMapper.selectList(filter(tenantId, query) + .orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000")); + List lines = new ArrayList<>(); + lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,当前状态,备注(仅自己),备注(客户),备注(仓库)"); + for (LastlegOrder order : orders) { + lines.add(String.join(",", List.of( + csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()), + csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), + csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), + csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), + csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()), + csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), + csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getPrivateRemark()), + csv(order.getCustomerRemark()), csv(order.getWarehouseRemark())))); + } + return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8); + } + + private LambdaQueryWrapper filter(String tenantId, LastlegOrderRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); + } + return new LambdaQueryWrapper() + .eq(LastlegOrder::getTenantId, tenantId) + .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) + .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) + .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) + .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) + .eq(StringUtils.hasText(query.status()), LastlegOrder::getCurrentStatus, query.status()) + .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, + query.transferWarehouseCode()) + .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) + .eq(query.detained() != null, LastlegOrder::getDetained, query.detained()) + .ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo()); + } + + private LastlegOrder findOwned(String tenantId, long orderId) { + LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper() + .eq(LastlegOrder::getTenantId, tenantId).eq(LastlegOrder::getId, orderId)); + if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); + return order; + } + + private void ensureTransferable(LastlegOrder order) { + if (StringUtils.hasText(order.getInboundOrderNo()) || INBOUND_PLANNED.equals(order.getCurrentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Destination warehouse cannot be changed after inbound plan creation: " + order.getId()); + } + } + + private LastlegOrderSummary summary(LastlegOrder o) { + return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), o.getSourceInboundId(), + o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(), + o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), + o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), + o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), + o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(), + o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(), + o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt()); + } + + private static String csv(Object value) { + if (value == null) return ""; + String text = String.valueOf(value); + if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; + return '"' + text.replace("\"", "\"\"") + '"'; + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java new file mode 100644 index 0000000..a583d26 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -0,0 +1,114 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.common.event.*; +import com.baiyue.lastleg.entity.*; +import com.baiyue.lastleg.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; + +@Service +public class LoadingCompletedConsumer { + @Resource private LastlegEventInboxMapper inboxMapper; + @Resource private LastlegOrderMapper orderMapper; + @Resource private LastlegOrderDetailMapper detailMapper; + @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private ObjectMapper objectMapper; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + + @Transactional + public int consume(EventEnvelope envelope) { + envelope.requireValid(); + if(!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())||envelope.eventVersion()!=1) + throw new IllegalArgumentException("不支持的尾程交接事件"); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) + throw new IllegalArgumentException("尾程交接事件区域无效"); + if (!"LOADING_PLAN".equals(envelope.aggregateType())) + throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); + if(payload.containerNo()==null||payload.containerNo().isBlank()) + throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); + if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); + if(existing!=null) { + if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); + return countOrders(envelope,payload.containerNo()); + } + + LastlegEventInbox inbox=new LastlegEventInbox(); + inbox.setEventId(envelope.eventId()); inbox.setTenantId(envelope.tenantId()); inbox.setEventType(envelope.eventType()); + inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash()); + inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now()); + try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) { + return countOrders(envelope,payload.containerNo()); + } + + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + for(var source:payload.orders()) createOrder(envelope,payload,source,orderNo); + inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); + return payload.orders().size(); + } + + private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) { + try { return objectMapper.treeToValue(envelope.payload(),FirstLegLoadingPlanCompletedV1.class); } + catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); } + } + + private int countOrders(EventEnvelope envelope,String containerNo) { + return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo)); + } + + private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order source,String orderNo) { + String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(source))); + LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); + if(existing!=null) { + if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId()); + return; + } + LastlegOrder order=new LastlegOrder(); + order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setTenantId(event.tenantId()); order.setOrderType("FIRST_LEG"); + order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); + order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); + order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); + order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName()); + order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); + order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); + order.setPackageCount(source.packageCount()); order.setPalletCount(Objects.requireNonNullElse(source.palletCount(),0)); + order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); + order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); + order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); + order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion()); + order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); + order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); + try { + orderMapper.insert(order); + } catch (DuplicateKeyException exception) { + LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(), + source.sourceInboundId()); + if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return; + throw exception; + } + + for(var item:source.items()) { + LastlegOrderDetail detail=new LastlegOrderDetail(); + detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode()); + detail.setProductNameZh(item.productNameZh()); detail.setProductNameEn(item.productNameEn()); detail.setPackageCount(item.packageCount()); + detail.setProductQuantity(item.productQuantity()); detail.setPalletCount(item.palletCount()); detail.setGrossWeightKg(item.grossWeightKg()); + detail.setNetWeightKg(item.netWeightKg()); detail.setDimensionalWeightKg(item.dimensionalWeightKg()); detail.setVolumeCbm(item.volumeCbm()); + detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); + detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); + detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); + } + LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); + history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); + history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history); + } +} diff --git a/lastleg/src/main/resources/application.yml b/lastleg/src/main/resources/application.yml index 1f9abb2..250cdcb 100644 --- a/lastleg/src/main/resources/application.yml +++ b/lastleg/src/main/resources/application.yml @@ -1,6 +1,11 @@ server: port: ${LASTLEG_PORT:9303} spring: + sql: + init: + mode: always + encoding: UTF-8 + schema-locations: classpath:db/lastleg.sql data: redis: host: ${REDIS_HOST:127.0.0.1} @@ -26,5 +31,14 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} username: ${LASTLEG_DB_USERNAME:root} - password: ${LASTLEG_DB_PASSWORD:} + password: ${LASTLEG_DB_PASSWORD:123456} management.endpoints.web.exposure.include: health,info,prometheus + +baiyue: + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + jwt-mode: jwks + session-mode: claims + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/lastleg/src/main/resources/db/lastleg.sql b/lastleg/src/main/resources/db/lastleg.sql new file mode 100644 index 0000000..3091523 --- /dev/null +++ b/lastleg/src/main/resources/db/lastleg.sql @@ -0,0 +1,505 @@ +CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY(event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (tenant_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花 ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) COMMENT '来源订单 ID', + source_order_no VARCHAR(128) COMMENT '来源订单号', + root_order_id VARCHAR(128) COMMENT '根订单 ID', + customer_name VARCHAR(255) COMMENT '货主名称', + overseas_warehouse_id VARCHAR(128) COMMENT '海外仓 ID', + overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称', + transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID', + transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码', + container_no VARCHAR(64) COMMENT '集装箱号', + container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + product_quantity INT COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) COMMENT '入库单号', + outbound_order_no VARCHAR(128) COMMENT '出库单号', + delivery_order_no VARCHAR(128) COMMENT '派送单号', + relabel_order_no VARCHAR(128) COMMENT '换标单号', + relabel_status VARCHAR(32) COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) COMMENT '合托标记', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + private_remark VARCHAR(1000) COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', + source_handover_version BIGINT COMMENT '交接版本', + source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_source (tenant_id,source_region,source_inbound_id), + KEY idx_ll_order_group(tenant_id,order_no), + KEY idx_ll_order_list(tenant_id,current_status,created_at) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + sku_code VARCHAR(128) COMMENT 'SKU 编码', + product_name_zh VARCHAR(255) COMMENT '中文品名', + product_name_en VARCHAR(255) COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT COMMENT '数量', + pallet_count INT COMMENT '托数', + gross_weight_kg DECIMAL(18,4) COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) COMMENT '货件编号', + shipping_mark VARCHAR(255) COMMENT '唛头', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_detail_source(order_id,source_inbound_detail_id), + KEY idx_ll_detail_order(order_id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + from_status VARCHAR(32) COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) COMMENT '触发事件 ID', + remark VARCHAR(500) COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY(id), + KEY idx_ll_loading_history_order(order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; + +CREATE TABLE IF NOT EXISTS ll_event_conflict ( + id BIGINT NOT NULL COMMENT '冲突记录主键', event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + tenant_id BIGINT NOT NULL COMMENT '租户 ID', conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_conflict_event(event_id,tenant_id) +) ENGINE=InnoDB COMMENT='事件冲突记录'; + +CREATE TABLE IF NOT EXISTS ll_event_inbox ( + id BIGINT NOT NULL COMMENT '事件接收记录主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', + tenant_id BIGINT NOT NULL COMMENT '租户 ID', event_type VARCHAR(128) NOT NULL COMMENT '事件类型', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '消费状态', error_message VARCHAR(512) NULL COMMENT '错误信息', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbox_event(event_id), KEY idx_ll_inbox_source(tenant_id,aggregate_id) +) ENGINE=InnoDB COMMENT='通用事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order ( + id BIGINT NOT NULL COMMENT '入仓单主键', tenant_id BIGINT NOT NULL COMMENT '租户 ID', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', + source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', handover_version BIGINT NOT NULL COMMENT '交接版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '入仓单状态', exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_source(tenant_id,source_region,source_inbound_id), UNIQUE KEY uk_ll_order_event(source_event_id,source_inbound_id) +) ENGINE=InnoDB COMMENT='尾程入仓单快照'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order_item ( + id BIGINT NOT NULL COMMENT '入仓明细主键', tenant_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '入仓单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', package_count INT NOT NULL COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', shipment_no VARCHAR(128) NULL COMMENT '货件编号', shipping_mark VARCHAR(255) NULL COMMENT '唛头', + sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', product_name_en VARCHAR(255) NULL COMMENT '英文品名', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_item_order(tenant_id,order_id) +) ENGINE=InnoDB COMMENT='尾程入仓商品明细快照'; + +CREATE TABLE IF NOT EXISTS ll_order_status_history ( + id BIGINT NOT NULL COMMENT '订单状态历史主键', tenant_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '订单 ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', reason VARCHAR(255) NULL COMMENT '变更原因', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', PRIMARY KEY(id) +) ENGINE=InnoDB COMMENT='尾程订单状态历史'; + +-- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 +ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; +ALTER TABLE ll_order COMMENT='尾程订单主表'; +ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; +ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; +ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; +ALTER TABLE ll_event_inbox COMMENT='通用事件接收幂等箱'; +ALTER TABLE ll_inbound_order COMMENT='尾程入仓单快照'; +ALTER TABLE ll_inbound_order_item COMMENT='尾程入仓商品明细快照'; +ALTER TABLE ll_order_status_history COMMENT='尾程订单状态历史'; + +ALTER TABLE ll_event_conflict + MODIFY COLUMN id BIGINT NOT NULL COMMENT '冲突记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + MODIFY COLUMN conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_event_inbox + MODIFY COLUMN id BIGINT NOT NULL COMMENT '事件接收记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户 ID', + MODIFY COLUMN event_type VARCHAR(128) NOT NULL COMMENT '事件类型', MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', MODIFY COLUMN aggregate_version BIGINT NOT NULL COMMENT '聚合版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '消费状态', MODIFY COLUMN error_message VARCHAR(512) NULL COMMENT '错误信息', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓单主键', MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域', MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '入仓码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', MODIFY COLUMN source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', MODIFY COLUMN handover_version BIGINT NOT NULL COMMENT '交接版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '入仓单状态', MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order_item + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓明细主键', MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '入仓单ID', MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', MODIFY COLUMN package_count INT NOT NULL COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号', MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头', MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码', MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_order_status_history + MODIFY COLUMN id BIGINT NOT NULL COMMENT '订单状态历史主键', MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '订单 ID', MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', MODIFY COLUMN reason VARCHAR(255) NULL COMMENT '变更原因', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间'; + +ALTER TABLE ll_loading_event_inbox + MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + MODIFY COLUMN tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + MODIFY COLUMN event_version INT NOT NULL COMMENT '事件契约版本', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + MODIFY COLUMN payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + MODIFY COLUMN processed_at DATETIME(3) NULL COMMENT '处理完成时间', + MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误'; + +ALTER TABLE ll_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '雪花 ID', + MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + MODIFY COLUMN tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', + MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓 ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', + MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号', + MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN product_quantity INT NULL COMMENT '货物数量', + MODIFY COLUMN volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + MODIFY COLUMN actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + MODIFY COLUMN estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + MODIFY COLUMN delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + MODIFY COLUMN relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + MODIFY COLUMN relabel_status VARCHAR(32) NULL COMMENT '换标状态', + MODIFY COLUMN pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + MODIFY COLUMN private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + MODIFY COLUMN customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', + MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Existing installations: convert the row identity to an assigned Snowflake ID and +-- make order_no a container/loading-plan grouping number rather than a row-unique key. +SET @ll_add_transfer_warehouse_id = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_id' +); +PREPARE ll_add_transfer_warehouse_id_stmt FROM @ll_add_transfer_warehouse_id; +EXECUTE ll_add_transfer_warehouse_id_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_id_stmt; + +SET @ll_add_transfer_warehouse_code = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_code' +); +PREPARE ll_add_transfer_warehouse_code_stmt FROM @ll_add_transfer_warehouse_code; +EXECUTE ll_add_transfer_warehouse_code_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_code_stmt; + +SET @ll_add_transfer_warehouse_name = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_name' +); +PREPARE ll_add_transfer_warehouse_name_stmt FROM @ll_add_transfer_warehouse_name; +EXECUTE ll_add_transfer_warehouse_name_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_name_stmt; + +ALTER TABLE ll_order + MODIFY COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + MODIFY COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + MODIFY COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称'; + +SET @ll_drop_order_no_unique = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'uk_ll_order_no' AND non_unique = 0 +); +PREPARE ll_drop_order_no_unique_stmt FROM @ll_drop_order_no_unique; +EXECUTE ll_drop_order_no_unique_stmt; +DEALLOCATE PREPARE ll_drop_order_no_unique_stmt; + +SET @ll_add_order_group_index = ( + SELECT IF(COUNT(*) = 0, + 'CREATE INDEX idx_ll_order_group ON ll_order(tenant_id,order_no)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'idx_ll_order_group' +); +PREPARE ll_add_order_group_index_stmt FROM @ll_add_order_group_index; +EXECUTE ll_add_order_group_index_stmt; +DEALLOCATE PREPARE ll_add_order_group_index_stmt; + +ALTER TABLE ll_order_detail + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', + MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN product_quantity INT NULL COMMENT '数量', + MODIFY COLUMN pallet_count INT NULL COMMENT '托数', + MODIFY COLUMN gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + MODIFY COLUMN net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + MODIFY COLUMN fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + MODIFY COLUMN shipment_no VARCHAR(255) NULL COMMENT '货件编号', + MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_container (tenant_id, container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id, order_id), + KEY idx_ll_inbound_line_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id, pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- 修复既有入库计划表中的英文注释。 +ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; +ALTER TABLE ll_inbound_plan + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + MODIFY COLUMN tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +ALTER TABLE ll_inbound_plan_line + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + MODIFY COLUMN planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量'; + +ALTER TABLE ll_inbound_plan_pallet COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; +ALTER TABLE ll_inbound_plan_pallet + MODIFY COLUMN id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + MODIFY COLUMN sequence_no INT NOT NULL COMMENT '计划内托盘序号'; + +ALTER TABLE ll_inbound_plan_pallet_allocation COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; +ALTER TABLE ll_inbound_plan_pallet_allocation + MODIFY COLUMN id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)'; + +-- Additive inbound picking schema. Guarded statements preserve existing rows on restart. +ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, plan_id BIGINT NOT NULL, + task_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + operator_id VARCHAR(128) NULL, operator_name VARCHAR(255) NULL, + pushed_at DATETIME(3) NULL, started_at DATETIME(3) NULL, completed_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (tenant_id, plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL, + allocation_id BIGINT NULL, action VARCHAR(32) NOT NULL, idempotency_key VARCHAR(255) NOT NULL, + request_hash VARCHAR(128) NOT NULL, request_json LONGTEXT NOT NULL, result_json LONGTEXT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (tenant_id, idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL, + merge_group_no VARCHAR(64) NOT NULL, source_pallet_id BIGINT NOT NULL, target_pallet_id BIGINT NOT NULL, + source_allocation_id BIGINT NOT NULL, target_allocation_id BIGINT NOT NULL, + source_line_id BIGINT NOT NULL, target_line_id BIGINT NOT NULL, + source_order_id BIGINT NOT NULL, target_order_id BIGINT NOT NULL, + moved_package_count INT NOT NULL, moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0, + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (tenant_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB; + +ALTER TABLE ll_loading_order_status_history + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', + MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + MODIFY COLUMN event_id VARCHAR(128) NULL COMMENT '触发事件 ID', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '状态变更备注', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间'; diff --git a/lastleg/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java new file mode 100644 index 0000000..490f83b --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class LastlegOrderControllerTest { + private final LastlegOrderService service = mock(LastlegOrderService.class); + private final LastlegOrderController controller = controller(); + private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( + 1, 20, null, null, null, null, null, null, null, null, null, null); + + @Test + void customerCannotOverrideItsTenantScope() { + controller.list(user(100L, "MERCHANT"), "200", query); + verify(service).page(eq("100"), eq(query)); + } + + @Test + void platformOperatorMustExplicitlyChooseCustomerTenant() { + assertThrows(ResponseStatusException.class, + () -> controller.list(user(0L, "PLATFORM"), null, query)); + } + + @Test + void platformOperatorCanQueryExplicitCustomerTenant() { + controller.list(user(0L, "PLATFORM"), "200", query); + verify(service).page(eq("200"), eq(query)); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(1L, tenantId, tenantId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private LastlegOrderController controller() { + LastlegOrderController controller = new LastlegOrderController(); + ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class)); + ReflectionTestUtils.setField(controller, "orderService", service); + ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class)); + return controller; + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java new file mode 100644 index 0000000..9ccee6c --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inboundplan; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InboundPlanSchemaContractTest { + @Test + void planIsUniqueByTenantAndActualContainerNumber() throws IOException { + try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { + assertTrue(stream != null, "lastleg schema must be available"); + String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(); + assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (tenant_id, container_no)")); + assertTrue(schema.contains("default 'pending_assignment'")); + assertTrue(schema.contains("required_package_count")); + assertTrue(schema.contains("planned_package_count")); + assertTrue(schema.contains("warehouse_resource_lock_id")); + assertTrue(schema.contains("create table if not exists ll_inbound_pda_task")); + assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation")); + assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge")); + assertFalse(schema.contains("cabinet_no")); + } + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java new file mode 100644 index 0000000..aa25ab1 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -0,0 +1,357 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +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.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPdaWorkflowServiceTest { + @Mock PdaWorkflowRepository repository; + @Mock WarehouseResourceGateway warehouse; + private InboundPdaWorkflowService service; + + private InboundPlan plan; + private InboundPdaTask task; + private InboundPlanLine targetLine; + private InboundPlanLine sourceLine; + private InboundPlanPallet targetPallet; + private InboundPlanPallet sourcePallet; + private InboundPlanPallet otherWarehousePallet; + private InboundPlanPalletAllocation target; + private InboundPlanPalletAllocation source; + private InboundPlanPalletAllocation otherWarehouse; + + @BeforeEach + void setUp() { + service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); + service.initializeObjectMapper(); + plan = plan(); + task = task(); + targetLine = line(11L, 101L, "RK-A", "1.0000", 5); + sourceLine = line(12L, 102L, "RK-B", "0.5000", 2); + targetPallet = pallet(21L, "WH-A", 3, "0.6000", 901L); + sourcePallet = pallet(22L, "WH-A", 2, "0.5000", 902L); + otherWarehousePallet = pallet(23L, "WH-B", 4, "0.8000", 903L); + target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0); + source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); + otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); + when(repository.lockPlan("100", "LIP1")).thenReturn(plan); + } + + @Test + void pushRequiresEveryPalletToBeAssignedAndLocked() { + targetPallet.setWarehouseResourceLockId(null); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + assertThrows(ResponseStatusException.class, + () -> service.push("100", "LIP1", new PdaRequests.Push("push-1", "7", "worker"))); + + verify(repository, never()).insert(any(InboundPdaTask.class)); + } + + @Test + void assignmentReservesWarehouseResourcesAndPersistsTheLock() { + targetPallet.setDestinationWarehouseId("88"); + targetPallet.setWarehousePalletId(null); + targetPallet.setWarehouseLocationId(null); + targetPallet.setWarehouseResourceLockId(null); + targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT"); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1")) + .thenReturn(999L); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, 188L, 288L, "assign-1")); + + assertEquals(188L, targetPallet.getWarehousePalletId()); + assertEquals(288L, targetPallet.getWarehouseLocationId()); + assertEquals(999L, targetPallet.getWarehouseResourceLockId()); + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + assertEquals("ASSIGNED", plan.getStatus()); + verify(repository).update(targetPallet); + } + + @Test + void repeatedAssignmentIsIdempotentAndWrongDestinationIsRejected() { + targetPallet.setDestinationWarehouseId("88"); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, targetPallet.getWarehousePalletId(), + targetPallet.getWarehouseLocationId(), "assign-retry")); + + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), + any(), any(), any()); + assertThrows(ResponseStatusException.class, () -> service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(99L, targetPallet.getWarehousePalletId(), + targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse"))); + } + + @Test + void countMustPrecedePickAndPickedBoxesDriveActualVolume() { + activeTask(); + target.setCountedPackageCount(0); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + + assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(1, "pick-before-count", "7", "worker"))); + + service.count("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(3, "count-3", "7", "worker")); + var result = service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(2, "pick-2", "7", "worker")); + + assertEquals("PICKED", result.result()); + assertEquals(2, target.getPickedPackageCount()); + assertEquals("0.4000", target.getActualVolumeCbm().toPlainString()); + assertEquals(5, targetPallet.getActualPackageCount()); + assertEquals("1.0000", targetPallet.getActualVolumeCbm().toPlainString()); + } + + @Test + void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + candidateQueries(); + + var result = service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(5, "pick-over", "7", "worker")); + + assertEquals("MERGE_CONFIRMATION_REQUIRED", result.result()); + assertEquals(3, result.remainingPackageCount()); + assertEquals(2, result.mergePackageCount()); + assertEquals(1, result.candidates().size()); + assertEquals(source.getId(), result.candidates().get(0).allocationId()); + assertEquals(0, target.getPickedPackageCount()); + verify(repository, never()).update(target); + verify(repository, never()).update(targetPallet); + verify(repository).insert(any(InboundPdaOperation.class)); + } + + @Test + void mergeCandidatesOnlyIncludeSameDestinationPallets() { + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + candidateQueries(); + + var result = service.mergeCandidates("100", "LIP1", target.getId()); + + assertEquals(1, result.size()); + assertEquals("RK-B", result.get(0).inboundCode()); + assertEquals("0.5000", result.get(0).availableVolumeCbm().toPlainString()); + } + + @Test + void confirmedMergePersistsBothOrdersAndScrapsAnEmptiedSourcePallet() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet); + when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, source)); + + var result = service.merge("100", "LIP1", target.getId(), + new PdaRequests.ConfirmMerge(source.getId(), 2, "merge-1", "7", "worker")); + + assertEquals("MERGED", result.result()); + assertEquals(sourceLine.getOrderId(), result.sourceOrderId()); + assertEquals(targetLine.getOrderId(), result.targetOrderId()); + assertTrue(result.sourcePalletScrapped()); + assertEquals(0, sourcePallet.getActualPackageCount()); + assertEquals(5, targetPallet.getActualPackageCount()); + verify(warehouse).scrap("100", 902L); + ArgumentCaptor merge = ArgumentCaptor.forClass(InboundPalletMerge.class); + verify(repository).insert(merge.capture()); + assertEquals(sourceLine.getOrderId(), merge.getValue().getSourceOrderId()); + assertEquals(targetLine.getOrderId(), merge.getValue().getTargetOrderId()); + assertTrue(merge.getValue().getSourcePalletScrapped()); + } + + @Test + void mergeCannotExceedTargetPalletVolumeCapacity() { + activeTask(); + targetPallet.setActualVolumeCbm(new BigDecimal("1.9000")); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet); + when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + + assertThrows(ResponseStatusException.class, () -> service.merge("100", "LIP1", target.getId(), + new PdaRequests.ConfirmMerge(source.getId(), 1, "merge-over-capacity", "7", "worker"))); + + verify(warehouse, never()).scrap(any(), anyLong()); + verify(repository, never()).insert(any(InboundPalletMerge.class)); + } + + @Test + void completionRequiresExactPickedTotalForEveryLine() { + activeTask(); + when(repository.allocations(plan.getId())).thenReturn(List.of(target)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + + assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1")); + assertFalse("COMPLETED".equals(task.getStatus())); + verify(repository, never()).update(plan); + } + + @Test + void completesWhenMergedAllocationsPreserveExactLineTotals() { + activeTask(); + target.setPickedPackageCount(3); + InboundPlanPalletAllocation moved = allocation(34L, targetLine.getId(), sourcePallet.getId(), 0, 2, 2); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, moved)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + + var result = service.complete("100", "LIP1"); + + assertEquals("COMPLETED", result.status()); + assertEquals("COMPLETED", plan.getStatus()); + verify(repository).update(task); + verify(repository).update(plan); + } + + @Test + void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { + InboundPdaOperation existing = new InboundPdaOperation(); + existing.setAction("COUNT"); + existing.setRequestHash("different"); + when(repository.operation("100", "same-key")).thenReturn(existing); + + assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(1, "same-key", "7", "worker"))); + } + + @Test + void completedTaskStillReplaysAnAlreadyPersistedPick() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + PdaRequests.PackageOperation request = new PdaRequests.PackageOperation(1, "pick-replay", "7", "worker"); + + var first = service.pick("100", "LIP1", target.getId(), request); + ArgumentCaptor operation = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(operation.capture()); + task.setStatus("COMPLETED"); + when(repository.operation("100", "pick-replay")).thenReturn(operation.getValue()); + + var replay = service.pick("100", "LIP1", target.getId(), request); + + assertEquals(first, replay); + assertEquals(1, target.getPickedPackageCount()); + verify(repository, times(1)).lockAllocation(plan.getId(), target.getId()); + } + + private void activeTask() { when(repository.lockTask("100", plan.getId())).thenReturn(task); } + + private void candidateQueries() { + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet, sourcePallet, otherWarehousePallet)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine, sourceLine)); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, source, otherWarehouse)); + } + + private InboundPlan plan() { + InboundPlan value = new InboundPlan(); + value.setId(1L); + value.setTenantId("100"); + value.setPlanNo("LIP1"); + value.setStatus("ASSIGNED"); + return value; + } + + private InboundPdaTask task() { + InboundPdaTask value = new InboundPdaTask(); + value.setId(2L); + value.setPlanId(1L); + value.setTenantId("100"); + value.setTaskNo("PDA2"); + value.setStatus("PDA_PENDING"); + value.setVersion(0); + return value; + } + + private InboundPlanLine line(long id, long orderId, String inboundCode, String volume, int packages) { + InboundPlanLine value = new InboundPlanLine(); + value.setId(id); + value.setPlanId(1L); + value.setOrderId(orderId); + value.setInboundCode(inboundCode); + value.setDestinationWarehouseId("WH-A"); + value.setVolumeCbm(new BigDecimal(volume)); + value.setRequiredPackageCount(packages); + return value; + } + + private InboundPlanPallet pallet(long id, String warehouseId, int actualPackages, + String actualVolume, long lockId) { + InboundPlanPallet value = new InboundPlanPallet(); + value.setId(id); + value.setPlanId(1L); + value.setPalletNo("PLT-" + id); + value.setDestinationWarehouseId(warehouseId); + value.setCapacityCbm(new BigDecimal("2.0000")); + value.setWarehousePalletId(id + 1000); + value.setWarehouseLocationId(id + 2000); + value.setWarehouseResourceLockId(lockId); + value.setAssignmentStatus("ASSIGNED"); + value.setActualPackageCount(actualPackages); + value.setActualVolumeCbm(new BigDecimal(actualVolume)); + return value; + } + + private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, + int planned, int counted, int picked) { + InboundPlanPalletAllocation value = new InboundPlanPalletAllocation(); + value.setId(id); + value.setPlanId(1L); + value.setLineId(lineId); + value.setPalletId(palletId); + value.setPlannedPackageCount(planned); + value.setCountedPackageCount(counted); + value.setPickedPackageCount(picked); + value.setActualVolumeCbm(BigDecimal.ZERO); + value.setVersion(0); + return value; + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java new file mode 100644 index 0000000..e693e37 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +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.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPlanServiceTest { + @Mock InboundPlanMapper planMapper; + @Mock InboundPlanLineMapper lineMapper; + @Mock InboundPlanPalletMapper palletMapper; + @Mock InboundPlanPalletAllocationMapper allocationMapper; + @Mock InboundPlanOrderMapper orderMapper; + + private InboundPlanService service; + + @BeforeEach + void setUp() { + service = new InboundPlanService(); + ReflectionTestUtils.setField(service, "planMapper", planMapper); + ReflectionTestUtils.setField(service, "lineMapper", lineMapper); + ReflectionTestUtils.setField(service, "palletMapper", palletMapper); + ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper); + ReflectionTestUtils.setField(service, "orderMapper", orderMapper); + org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); + org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); + org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + void convertsWholeContainerInOneTransaction() throws Exception { + assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class) + .getAnnotation(Transactional.class)); + when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1"); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of( + order(1L, "RK-1", "1.5000", "WH-A"), + order(2L, "RK-2", "1.0000", "WH-A"))); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convert("100", 2L); + + ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); + verify(planMapper).insert(plan.capture()); + assertEquals(2, plan.getValue().getLineCount()); + assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus()); + assertEquals(2, plan.getValue().getPalletCount()); + assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm()); + verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class)); + ArgumentCaptor lines = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(lineMapper, times(2)).insert(lines.capture()); + assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum()); + ArgumentCaptor allocations = + ArgumentCaptor.forClass(InboundPlanPalletAllocation.class); + verify(allocationMapper, times(3)).insert(allocations.capture()); + assertEquals(25, allocations.getAllValues().stream() + .mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum()); + assertEquals(0, allocations.getAllValues().stream() + .mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum()); + assertEquals(3, allocations.getAllValues().stream() + .filter(item -> "PENDING_COUNT".equals(item.getStatus())).count()); + verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any()); + verify(orderMapper).lockContainerOrders("100", "CONT-1"); + assertEquals("CONT-1", result.containerNo()); + } + + @Test + void repeatedConversionReturnsExistingPlanWithoutWriting() { + InboundPlan existing = new InboundPlan(); + existing.setId(9L); + existing.setPlanNo("LIP9"); + existing.setTenantId("100"); + existing.setContainerNo("CONT-1"); + existing.setStatus("PLANNED"); + existing.setLineCount(1); + existing.setPalletCount(1); + existing.setTotalVolumeCbm(BigDecimal.ONE); + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); + when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing); + + assertEquals("LIP9", service.convert("100", 1L).planNo()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + } + + @Test + void rejectsMissingDestinationWarehouse() { + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + + assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + @Test + void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", + "DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + + assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) { + return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, + new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), + warehouse, warehouse, warehouse, 1, 0); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java new file mode 100644 index 0000000..6634043 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.inboundplan.service; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PalletAllocatorTest { + @Test + void roundsEachDestinationGroupUpToTwoCbmPallets() { + assertEquals(2, allocate("4.0000").pallets().size()); + assertEquals(3, allocate("4.0100").pallets().size()); + assertEquals(1, allocate("0.1000").pallets().size()); + assertEquals(0, allocate("0.0000").pallets().size()); + } + + @Test + void neverMixesDestinationWarehousesAndCanSplitOneLine() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("2.5000"), 5), + new PalletAllocator.LineInput(2L, "WH-B", new BigDecimal("0.1000"), 1))); + + assertEquals(3, result.pallets().size()); + assertEquals(2, result.allocations().stream().filter(item -> item.orderId() == 1L).count()); + assertTrue(result.allocations().stream().allMatch(item -> + item.pallet().warehouseKey().equals(item.orderId() == 1L ? "WH-A" : "WH-B"))); + } + + @Test + void distributesPackagesProportionallyAndAssignsRemainderToLastPallet() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 10))); + + assertEquals(List.of(4, 4, 2), result.allocations().stream() + .map(PalletAllocator.AllocationDraft::plannedPackageCount).toList()); + assertEquals(10, result.allocations().stream() + .mapToInt(PalletAllocator.AllocationDraft::plannedPackageCount).sum()); + } + + @Test + void doesNotLosePackagesWhenEarlyProportionalSharesRoundDown() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 1))); + + assertEquals(List.of(0, 0, 1), result.allocations().stream() + .map(PalletAllocator.AllocationDraft::plannedPackageCount).toList()); + } + + private PalletAllocator.Result allocate(String volume) { + return PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume), + new BigDecimal(volume).signum() == 0 ? 0 : 1))); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java b/lastleg/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java new file mode 100644 index 0000000..49b0a41 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.security; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +class InternalServiceTokenGuardTest { + @Test void acceptsOnlyConfiguredToken() { + InternalServiceTokenGuard guard=new InternalServiceTokenGuard("secret-token"); + assertDoesNotThrow(()->guard.verify("secret-token")); + assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value()); + assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value()); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java new file mode 100644 index 0000000..410b215 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.service; +import org.junit.jupiter.api.Test; +import java.util.Set; +import java.util.concurrent.*; +import java.util.stream.IntStream; +import static org.junit.jupiter.api.Assertions.*; +class LastlegOrderNoGeneratorTest { + @Test void actualContainerNumberIsStableAndShared() { + LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); + assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633")); + assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1")); + assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1")); + assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32); + assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" ")); + } + + @Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception { + LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator(); + String first=beforeRestart.next(); + LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator(); + assertNotEquals(first,afterRestart.next()); + ExecutorService pool=Executors.newFixedThreadPool(8); + try { + var tasks=IntStream.range(0,1000).mapToObj(i->(Callable)afterRestart::next).toList(); + Set ids=pool.invokeAll(tasks).stream().map(future->{ + try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); } + }).collect(java.util.stream.Collectors.toSet()); + assertEquals(1000,ids.size()); + } finally { pool.shutdownNow(); } + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java new file mode 100644 index 0000000..a816875 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LastlegOrderServiceTest { + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper details; + @Mock LastlegOrderStatusHistoryMapper histories; + LastlegOrderService service; + + @BeforeEach + void setUp() { + service = new LastlegOrderService(); + ReflectionTestUtils.setField(service, "orderMapper", orders); + ReflectionTestUtils.setField(service, "detailMapper", details); + ReflectionTestUtils.setField(service, "historyMapper", histories); + } + + @Test + void transferUpdatesOrderAndEveryCargoSnapshot() { + LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); + when(orders.selectList(any())).thenReturn(List.of(order)); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null); + + assertEquals(1, service.transferDestination("100", request)); + + assertEquals("wh-2", order.getTransferWarehouseId()); + assertEquals("LAX-02", order.getTransferWarehouseCode()); + assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus()); + ArgumentCaptor cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class); + verify(details).update(cargo.capture(), any()); + assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode()); + ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(histories).insert(history.capture()); + assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus()); + assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus()); + } + + @Test + void transferRejectsAnOrderAlreadyBoundToInboundPlan() { + LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1"); + when(orders.selectList(any())).thenReturn(List.of(order)); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null); + + assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request)); + verify(orders, never()).updateById(any(LastlegOrder.class)); + verify(details, never()).update(any(), any()); + } + + @Test + void batchRejectsMissingOrCrossTenantIdsBeforeWriting() { + when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null))); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.transferDestination("100", request)); + assertTrue(error.getStatusCode().is4xxClientError()); + verify(orders, never()).updateById(any(LastlegOrder.class)); + } + + private static LastlegOrder order(long id, String tenantId, String status, String inboundOrderNo) { + LastlegOrder order = new LastlegOrder(); + order.setId(id); + order.setTenantId(tenantId); + order.setCurrentStatus(status); + order.setInboundOrderNo(inboundOrderNo); + return order; + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java new file mode 100644 index 0000000..8269a4d --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.service; +import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) +class LoadingCompletedConsumerTest { + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } + @Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } + @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } + @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } + private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } +} diff --git a/scripts/check-boundary.ps1 b/scripts/check-boundary.ps1 index 8480b1b..67a27ab 100644 --- a/scripts/check-boundary.ps1 +++ b/scripts/check-boundary.ps1 @@ -9,16 +9,12 @@ foreach ($directory in $forbiddenDirectories) { } } -$pomFiles = Get-ChildItem -Path $root -Recurse -Filter pom.xml +$ignoredDirectoryPattern = '[\\/](\.git|\.idea|target)[\\/]' +$pomFiles = Get-ChildItem -Path $root -Recurse -Filter pom.xml | + Where-Object { $_.FullName -notmatch $ignoredDirectoryPattern } foreach ($pom in $pomFiles) { $document = New-Object System.Xml.XmlDocument $document.Load($pom.FullName) } -$excluded = Get-ChildItem -Path $root -Recurse -Directory | - Where-Object { $_.Name -in @('.git', '.idea', 'target') } -if ($excluded) { - throw "EXCLUDED_DIRECTORY: $($excluded.FullName -join ',')" -} - Write-Host "BOUNDARY_AND_POM_XML_OK" diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql new file mode 100644 index 0000000..e2c917d --- /dev/null +++ b/scripts/db/incremental.sql @@ -0,0 +1,613 @@ +-- Baiyue last-leg platform incremental database archive +-- Encoding: UTF-8 without BOM +-- Policy: +-- 1. Append new numbered sections; never rewrite an applied section. +-- 2. DDL must be repeatable and must preserve existing business rows. +-- 3. Every new column and index must use an information_schema guard. +-- 4. Do not place test fixtures, DELETE, TRUNCATE or business-data UPDATE here. + +-- ============================================================================ +-- 2026-08-15 / 001 +-- Event Bridge inbox and delivery-attempt baseline +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id, attempt_no), + KEY idx_bridge_attempt_event (event_id, started_at) +) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录'; + +-- Upgrade an existing bridge_inbox without replacing payload JSON or other rows. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='lease_owner'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN lease_owner VARCHAR(128) NULL COMMENT ''消费租约持有者'' AFTER status')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 002 +-- Warehouse locations, pallets, resource locks and UTF-8 descriptions +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastlag_warehouse; + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (tenant_id,warehouse_id,location_code,deleted), + KEY idx_location_query (tenant_id,warehouse_id,zone_code,status,deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (tenant_id,warehouse_id,pallet_code,deleted), + KEY idx_pallet_query (tenant_id,warehouse_id,status,deleted), + KEY idx_pallet_location (tenant_id,warehouse_id,location_id,deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT '资源锁ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的托盘ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的库位ID', + business_type VARCHAR(50) NOT NULL COMMENT '调用方业务类型', + business_id VARCHAR(100) NOT NULL COMMENT '调用方业务标识', + reservation_key VARCHAR(160) NOT NULL COMMENT '调用方资源预留幂等键', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态:ACTIVE有效、RELEASED释放、SCRAPPED报废', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) NULL COMMENT '释放或报废时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_resource_lock_reservation (tenant_id,reservation_key), + KEY idx_resource_lock_pallet (tenant_id,warehouse_id,pallet_id,status), + KEY idx_resource_lock_location (tenant_id,warehouse_id,location_id,status), + KEY idx_resource_lock_business (tenant_id,business_type,business_id) +) ENGINE=InnoDB COMMENT='仓库托盘与库位资源锁表:支持跨服务幂等预留和释放'; + +-- Fix legacy mojibake/English descriptions without changing row values. +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_resource_lock COMMENT='仓库托盘与库位资源锁表:支持跨服务幂等预留和释放'; +ALTER TABLE wh_resource_lock + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '资源锁ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN pallet_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的托盘ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的库位ID', + MODIFY COLUMN business_type VARCHAR(50) NOT NULL COMMENT '调用方业务类型', + MODIFY COLUMN business_id VARCHAR(100) NOT NULL COMMENT '调用方业务标识', + MODIFY COLUMN reservation_key VARCHAR(160) NOT NULL COMMENT '调用方资源预留幂等键', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态:ACTIVE有效、RELEASED释放、SCRAPPED报废', + MODIFY COLUMN locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + MODIFY COLUMN released_at DATETIME(3) NULL COMMENT '释放或报废时间', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Guard every operational index for existing installations. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='uk_location_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_location_code ON wh_location(tenant_id,warehouse_id,location_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='idx_location_query'), 'SELECT 1', 'CREATE INDEX idx_location_query ON wh_location(tenant_id,warehouse_id,zone_code,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='uk_pallet_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_pallet_code ON wh_pallet(tenant_id,warehouse_id,pallet_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='idx_pallet_query'), 'SELECT 1', 'CREATE INDEX idx_pallet_query ON wh_pallet(tenant_id,warehouse_id,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='idx_pallet_location'), 'SELECT 1', 'CREATE INDEX idx_pallet_location ON wh_pallet(tenant_id,warehouse_id,location_id,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='uk_resource_lock_reservation'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_resource_lock_reservation ON wh_resource_lock(tenant_id,reservation_key)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_pallet'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_pallet ON wh_resource_lock(tenant_id,warehouse_id,pallet_id,status)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_location'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_location ON wh_resource_lock(tenant_id,warehouse_id,location_id,status)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_business'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_business ON wh_resource_lock(tenant_id,business_type,business_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 003 +-- Last-leg inbound plan, pallet allocation and PDA workflow +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_container (tenant_id,container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + required_package_count INT NOT NULL DEFAULT 0 COMMENT '计划应收箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id,order_id), + KEY idx_ll_inbound_line_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + warehouse_pallet_id BIGINT NULL COMMENT '仓库托盘ID', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_resource_lock_id BIGINT NULL COMMENT '仓库资源锁ID', + assignment_status VARCHAR(32) NOT NULL DEFAULT 'UNASSIGNED' COMMENT '仓库资源分配状态', + locked_at DATETIME(3) NULL COMMENT '资源锁定时间', + actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际入托体积(立方米)', + actual_package_count INT NOT NULL DEFAULT 0 COMMENT '实际入托箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id,sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '计划分配体积(立方米)', + planned_package_count INT NOT NULL DEFAULT 0 COMMENT '计划分配箱数', + counted_package_count INT NOT NULL DEFAULT 0 COMMENT '清点箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '上架箱数', + actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际分配体积(立方米)', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_COUNT' COMMENT 'PDA作业状态', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id,pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- Columns introduced after the initial inbound-plan release. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划应收箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库托盘ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''仓库库位ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''仓库资源锁ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED'' COMMENT ''仓库资源分配状态''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''资源锁定时间''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实际入托体积(立方米)''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实际入托箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''上架箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实际分配体积(立方米)''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT'' COMMENT ''PDA作业状态''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL COMMENT 'PDA任务ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + operator_name VARCHAR(255) NULL COMMENT '操作人名称', pushed_at DATETIME(3) NULL COMMENT '推送时间', + started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (tenant_id,plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='尾程入库PDA任务表'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( + id BIGINT NOT NULL COMMENT 'PDA操作记录ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NULL COMMENT '托盘分配记录ID', + action VARCHAR(32) NOT NULL COMMENT '操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求摘要', request_json LONGTEXT NOT NULL COMMENT '请求JSON', + result_json LONGTEXT NOT NULL COMMENT '结果JSON', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB COMMENT='尾程入库PDA幂等操作记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL COMMENT '合托记录ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + task_id BIGINT NOT NULL COMMENT 'PDA任务ID', merge_group_no VARCHAR(64) NOT NULL COMMENT '合托组号', + source_pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', target_pallet_id BIGINT NOT NULL COMMENT '目标托盘ID', + source_allocation_id BIGINT NOT NULL COMMENT '来源分配ID', target_allocation_id BIGINT NOT NULL COMMENT '目标分配ID', + source_line_id BIGINT NOT NULL COMMENT '来源计划明细ID', target_line_id BIGINT NOT NULL COMMENT '目标计划明细ID', + source_order_id BIGINT NOT NULL COMMENT '来源订单ID', target_order_id BIGINT NOT NULL COMMENT '目标订单ID', + moved_package_count INT NOT NULL COMMENT '移动箱数', moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '移动体积(立方米)', + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT '来源托盘是否报废', status VARCHAR(32) NOT NULL COMMENT '合托状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (tenant_id,task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB COMMENT='尾程入库PDA合托记录'; + +-- ============================================================================ +-- 2026-08-15 / 004 +-- Upgrade an existing Event Bridge inbox with retry/lease fields and indexes +-- ============================================================================ +USE baiyue_event_bridge; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='lease_until'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN lease_until DATETIME(3) NULL COMMENT ''消费租约截止时间'' AFTER lease_owner')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='retry_count'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT ''消费重试次数'' AFTER lease_until')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='next_retry_at'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN next_retry_at DATETIME(3) NULL COMMENT ''下次消费时间'' AFTER retry_count')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='last_error'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN last_error VARCHAR(1000) NULL COMMENT ''最后错误'' AFTER next_retry_at')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND index_name='idx_bridge_inbox_consume'), 'SELECT 1', 'CREATE INDEX idx_bridge_inbox_consume ON bridge_inbox(status,next_retry_at,lease_until)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND index_name='uk_bridge_inbox_idempotency'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_bridge_inbox_idempotency ON bridge_inbox(idempotency_key)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 005 +-- FirstLegLoadingPlanCompleted -> last-leg order projection +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (tenant_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号,同柜入仓单共享', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装LOADED分片ID,建单幂等键', + root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单ID', + source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + root_order_id VARCHAR(128) NULL COMMENT '根订单ID', + customer_name VARCHAR(255) NULL COMMENT '货主名称', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + container_no VARCHAR(64) NULL COMMENT '实际集装箱号', + container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + product_quantity INT NULL COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + relabel_status VARCHAR(32) NULL COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划ID', + source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + source_handover_version BIGINT NULL COMMENT '交接版本', + source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源入仓单快照摘要', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_order_source (tenant_id,source_region,source_inbound_id), + KEY idx_ll_order_group (tenant_id,order_no), + KEY idx_ll_order_list (tenant_id,current_status,created_at) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + product_name_en VARCHAR(255) NULL COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT NULL COMMENT '数量', + pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) NULL COMMENT '货件编号', + shipping_mark VARCHAR(255) NULL COMMENT '唛头', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_detail_source (order_id,source_inbound_detail_id), + KEY idx_ll_detail_order (order_id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) NULL COMMENT '触发事件ID', + remark VARCHAR(500) NULL COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY (id), + KEY idx_ll_loading_history_order (order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; + +-- Existing order installations: add transfer fields without replacing row data. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- One container/loading-plan number groups multiple inbound-code rows, so order_no +-- must not be unique. Source inbound identity remains uniquely constrained. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='uk_ll_order_no' AND non_unique=0), 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', 'SELECT 1')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_group'), 'SELECT 1', 'CREATE INDEX idx_ll_order_group ON ll_order(tenant_id,order_no)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='uk_ll_order_source'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_ll_order_source ON ll_order(tenant_id,source_region,source_inbound_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_list'), 'SELECT 1', 'CREATE INDEX idx_ll_order_list ON ll_order(tenant_id,current_status,created_at)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND index_name='uk_ll_loading_inbox_tenant_key'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_ll_loading_inbox_tenant_key ON ll_loading_event_inbox(tenant_id,event_type,event_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 006 +-- Warehouse master-data descriptions and query indexes +-- ============================================================================ + +USE baiyue_lastlag_warehouse; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', remark VARCHAR(500) NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_wh_tenant_code (tenant_id,warehouse_code,deleted), + KEY idx_wh_tenant_status (tenant_id,status,deleted), KEY idx_wh_country (tenant_id,country_code,deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', state_province VARCHAR(100) NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', district VARCHAR(100) NULL COMMENT '区县', postal_code VARCHAR(20) NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', address_line2 VARCHAR(255) NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) NULL COMMENT '纬度', longitude DECIMAL(10,7) NULL COMMENT '经度', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_address_type (tenant_id,warehouse_id,address_type,deleted), + KEY idx_address_warehouse (tenant_id,warehouse_id,deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', capability_code VARCHAR(50) NOT NULL COMMENT '能力编码', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME NULL COMMENT '当日作业截单时间', description VARCHAR(500) NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_capability (tenant_id,warehouse_id,capability_code,deleted), + KEY idx_capability_query (tenant_id,warehouse_id,enabled,deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; +ALTER TABLE wh_warehouse + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话', MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; +ALTER TABLE wh_warehouse_address + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码', MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州', + MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市', MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县', + MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码', MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行', MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度', + MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度', MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; +ALTER TABLE wh_service_capability + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货', + MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间', MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='uk_wh_tenant_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_wh_tenant_code ON wh_warehouse(tenant_id,warehouse_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='idx_wh_tenant_status'), 'SELECT 1', 'CREATE INDEX idx_wh_tenant_status ON wh_warehouse(tenant_id,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='idx_wh_country'), 'SELECT 1', 'CREATE INDEX idx_wh_country ON wh_warehouse(tenant_id,country_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND index_name='uk_address_type'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_address_type ON wh_warehouse_address(tenant_id,warehouse_id,address_type,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND index_name='idx_address_warehouse'), 'SELECT 1', 'CREATE INDEX idx_address_warehouse ON wh_warehouse_address(tenant_id,warehouse_id,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='uk_capability'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_capability ON wh_service_capability(tenant_id,warehouse_id,capability_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='idx_capability_query'), 'SELECT 1', 'CREATE INDEX idx_capability_query ON wh_service_capability(tenant_id,warehouse_id,enabled,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md new file mode 100644 index 0000000..fcdccfa --- /dev/null +++ b/warehouse/AGENTS.md @@ -0,0 +1,31 @@ +# Warehouse Service 模块指引 + +## 职责与边界 + +- 负责海外仓库、地址、服务能力、库位、托盘及业务资源锁定/释放。 +- 尾程入仓计划通过内部网关请求资源;本模块不读取尾程订单库或头程数据库。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 平台仓库管理 | `src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java` | +| 商户仓库查询 | `controller/MerchantWarehouseController.java` | +| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` | +| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | +| 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` | +| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | +| 数据库 | `src/main/resources/db/warehouse.sql` | +| 测试 | `src/test/java/com/baiyue/warehouse/` | + +## 数据与安全规则 + +- 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。 +- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。 +- 内部资源接口必须校验服务令牌;普通用户不得直接调用。 + +## 修改与验证 + +- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。 +- 定向验证:`mvn -pl warehouse -am test`。 + diff --git a/warehouse/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java b/warehouse/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java new file mode 100644 index 0000000..a21ffea --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.config; + +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Prevents the warehouse schema migration from ever running against another database. */ +@Configuration(proxyBeanMethods = false) +public class WarehouseDatabaseGuardConfiguration { + private static final Pattern MYSQL_SCHEMA = Pattern.compile( + "^jdbc:mysql://[^/]+/([^?;]+)(?:[?;].*)?$", Pattern.CASE_INSENSITIVE); + + @Bean + static BeanFactoryPostProcessor warehouseDatabaseGuard(Environment environment) { + String url = environment.getProperty("spring.datasource.url", "").trim(); + Matcher matcher = MYSQL_SCHEMA.matcher(url); + if (!matcher.matches() || !"baiyue_lastlag_warehouse".equalsIgnoreCase(matcher.group(1))) { + throw new IllegalStateException( + "warehouse-service requires spring.datasource.url to target the baiyue_lastlag_warehouse database"); + } + return beanFactory -> { }; + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java new file mode 100644 index 0000000..5f9d195 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -0,0 +1,48 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehouseResourceLockService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/warehouse-resource-locks") +public class InternalWarehouseResourceLockController { + @Resource private WarehouseResourceLockService service; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping + public ApiResponse reserve( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehouseResourceLockRequests.ReserveRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.reserve(request)); + } + + @PostMapping("/{lockId}/release") + public ApiResponse release( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long lockId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.release(request.tenantId(), lockId)); + } + + @PostMapping("/{lockId}/scrap") + public ApiResponse scrap( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long lockId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.scrap(request.tenantId(), lockId)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java new file mode 100644 index 0000000..0ba2439 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java @@ -0,0 +1,109 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}") +public class MerchantWarehouseResourceController { + @Resource private WarehouseResourceService service; + + @PostMapping("/locations") + public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody LocationRequests.SaveRequest request) { + return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request)); + } + + @PutMapping("/locations/{id}") + public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody LocationRequests.SaveRequest request) { + service.updateLocation(tenant(user), warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/locations") + public ApiResponse> locations( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, + @Valid LocationRequests.QueryRequest query) { + return ApiResponse.success(service.locations(tenant(user), warehouseId, query)); + } + + @GetMapping("/locations/{id}") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @PathVariable long id) { + return ApiResponse.success(service.location(tenant(user), warehouseId, id)); + } + + @DeleteMapping("/locations/{id}") + public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + service.deleteLocation(tenant(user), warehouseId, id); + return ApiResponse.success(null); + } + + @PostMapping("/pallets") + public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody PalletRequests.SaveRequest request) { + return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request)); + } + + @PutMapping("/pallets/{id}") + public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody PalletRequests.SaveRequest request) { + service.updatePallet(tenant(user), warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/pallets") + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, + @Valid PalletRequests.QueryRequest query) { + return ApiResponse.success(service.pallets(tenant(user), warehouseId, query)); + } + + @GetMapping("/pallets/{id}") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + return ApiResponse.success(service.pallet(tenant(user), warehouseId, id)); + } + + @DeleteMapping("/pallets/{id}") + public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + service.deletePallet(tenant(user), warehouseId, id); + return ApiResponse.success(null); + } + + private static long tenant(AuthenticatedUser user) { + if (user == null || user.tenantId() == null || user.tenantId() <= 0) { + throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失"); + } + return user.tenantId(); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java new file mode 100644 index 0000000..bf3e9ae --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -0,0 +1,131 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +public class PlatformWarehouseResourceController { + @Resource private WarehouseResourceService service; + + @PostMapping("/locations") + public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, + @Valid @RequestBody LocationRequests.SaveRequest request) { + requirePlatform(user, tenantId); + return ApiResponse.success(service.createLocation(tenantId, warehouseId, request)); + } + + @PutMapping("/locations/{id}") + public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody LocationRequests.SaveRequest request) { + requirePlatform(user, tenantId); + service.updateLocation(tenantId, warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/locations") + public ApiResponse> locations( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { + requirePlatform(user, tenantId); + return ApiResponse.success(service.locations(tenantId, warehouseId, query)); + } + + @GetMapping("/locations/{id}") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, + @PathVariable long id) { + requirePlatform(user, tenantId); + return ApiResponse.success(service.location(tenantId, warehouseId, id)); + } + + @DeleteMapping("/locations/{id}") + public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user, tenantId); + service.deleteLocation(tenantId, warehouseId, id); + return ApiResponse.success(null); + } + + @PostMapping("/pallets") + public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, + @Valid @RequestBody PalletRequests.SaveRequest request) { + requirePlatform(user, tenantId); + return ApiResponse.success(service.createPallet(tenantId, warehouseId, request)); + } + + @PutMapping("/pallets/{id}") + public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody PalletRequests.SaveRequest request) { + requirePlatform(user, tenantId); + service.updatePallet(tenantId, warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/pallets") + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { + requirePlatform(user, tenantId); + return ApiResponse.success(service.pallets(tenantId, warehouseId, query)); + } + + @GetMapping("/pallets/{id}") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user, tenantId); + return ApiResponse.success(service.pallet(tenantId, warehouseId, id)); + } + + @DeleteMapping("/pallets/{id}") + public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long tenantId, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user, tenantId); + service.deletePallet(tenantId, warehouseId, id); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user, Long tenantId) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + if (tenantId == null || tenantId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java new file mode 100644 index 0000000..2013ef0 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public final class LocationRequests { + private LocationRequests() { + } + + public record SaveRequest( + @NotBlank @Size(max = 50) String zoneCode, + @NotBlank @Size(max = 64) String locationCode, + @NotBlank @Size(max = 32) String locationType, + @NotNull @DecimalMin(value = "0.000", inclusive = false) BigDecimal maxLoadKg, + @NotBlank String status, + @Size(max = 500) String remark + ) { + } + + public record QueryRequest( + @Min(1) long page, + @Min(1) @Max(200) long size, + String zoneCode, + String locationType, + String status, + String keyword + ) { + public QueryRequest { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java new file mode 100644 index 0000000..8547cd8 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -0,0 +1,45 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public final class PalletRequests { + private static final BigDecimal DEFAULT_CAPACITY = new BigDecimal("2.0000"); + + private PalletRequests() { + } + + public record SaveRequest( + @NotBlank @Size(max = 64) String palletCode, + @NotBlank @Size(max = 32) String palletType, + @DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm, + @NotNull @DecimalMin("0.00") BigDecimal unitPrice, + @NotBlank String status, + Long locationId, + @Size(max = 500) String remark + ) { + public SaveRequest { + if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY; + } + } + + public record QueryRequest( + @Min(1) long page, + @Min(1) @Max(200) long size, + String palletType, + String status, + Long locationId, + String keyword + ) { + public QueryRequest { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java new file mode 100644 index 0000000..ea98ce9 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class WarehouseResourceLockRequests { + private WarehouseResourceLockRequests() {} + + public record ReserveRequest( + @Positive long tenantId, + @Positive long warehouseId, + @Positive long palletId, + @Positive long locationId, + @NotBlank String businessType, + @NotBlank String businessId, + @NotBlank String reservationKey + ) {} + + public record CloseRequest(@Positive long tenantId) {} +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java new file mode 100644 index 0000000..e848a03 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; + +import java.time.LocalDateTime; + +public record WarehouseResourceLockResponse( + long lockId, + long tenantId, + long warehouseId, + long palletId, + long locationId, + String businessType, + String businessId, + String reservationKey, + String status, + LocalDateTime lockedAt, + LocalDateTime releasedAt +) { + public static WarehouseResourceLockResponse from(WarehouseResourceLockEntity entity) { + return new WarehouseResourceLockResponse(entity.getId(), entity.getTenantId(), entity.getWarehouseId(), + entity.getPalletId(), entity.getLocationId(), entity.getBusinessType(), entity.getBusinessId(), + entity.getReservationKey(), entity.getStatus(), entity.getLockedAt(), entity.getReleasedAt()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java new file mode 100644 index 0000000..7d50708 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("wh_location") +public class WarehouseLocationEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long warehouseId; + private String zoneCode; + private String locationCode; + private String locationType; + private BigDecimal maxLoadKg; + private String status; + private String remark; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java new file mode 100644 index 0000000..eb769a7 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("wh_pallet") +public class WarehousePalletEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long warehouseId; + private Long locationId; + private String palletCode; + private String palletType; + private BigDecimal capacityCbm; + private BigDecimal unitPrice; + private String status; + private String remark; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java new file mode 100644 index 0000000..d2ebd64 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("wh_resource_lock") +public class WarehouseResourceLockEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long warehouseId; + private Long palletId; + private Long locationId; + private String businessType; + private String businessId; + private String reservationKey; + private String status; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java new file mode 100644 index 0000000..4d5a545 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java @@ -0,0 +1,7 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; + +public interface WarehouseLocationMapper extends BaseMapper { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java new file mode 100644 index 0000000..5e2f5ca --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java @@ -0,0 +1,7 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletEntity; + +public interface WarehousePalletMapper extends BaseMapper { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java new file mode 100644 index 0000000..70c9387 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java @@ -0,0 +1,69 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +public interface WarehouseResourceLockMapper extends BaseMapper { + @Select(""" + SELECT * FROM wh_resource_lock + WHERE tenant_id = #{tenantId} AND reservation_key = #{reservationKey} + LIMIT 1 + """) + WarehouseResourceLockEntity findByReservationKey(@Param("tenantId") long tenantId, + @Param("reservationKey") String reservationKey); + + @Update(""" + UPDATE wh_pallet + SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND status = 'IDLE' AND deleted = 0 + """) + int occupyPallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location + SET status = 'OCCUPIED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{locationId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND status = 'IDLE' AND deleted = 0 + """) + int occupyLocation(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'IDLE', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int releasePallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location + SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{locationId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND status = 'OCCUPIED' AND deleted = 0 + """) + int releaseLocation(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'SCRAPPED', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int scrapPallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_resource_lock + SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3) + WHERE id = #{id} AND status = 'ACTIVE' + """) + int closeLock(@Param("id") long id, @Param("status") String status); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 904e3f4..8beb9bf 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -12,6 +12,7 @@ import com.baiyue.warehouse.mapper.WarehouseAddressMapper; import com.baiyue.warehouse.mapper.WarehouseMapper; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; import java.util.List; @@ -20,17 +21,9 @@ import java.util.List; */ @Repository public class WarehouseRepository { - private final WarehouseMapper warehouses; - private final WarehouseAddressMapper addresses; - private final ServiceCapabilityMapper capabilities; - - public WarehouseRepository(WarehouseMapper warehouses, - WarehouseAddressMapper addresses, - ServiceCapabilityMapper capabilities) { - this.warehouses = warehouses; - this.addresses = addresses; - this.capabilities = capabilities; - } + @Resource private WarehouseMapper warehouses; + @Resource private WarehouseAddressMapper addresses; + @Resource private ServiceCapabilityMapper capabilities; public void insertWarehouse(WarehouseEntity warehouse) { warehouses.insert(warehouse); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java new file mode 100644 index 0000000..d989081 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java @@ -0,0 +1,66 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import com.baiyue.warehouse.mapper.WarehousePalletMapper; +import com.baiyue.warehouse.mapper.WarehouseResourceLockMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +@Repository +public class WarehouseResourceLockRepository { + @Resource private WarehouseResourceLockMapper locks; + @Resource private WarehousePalletMapper pallets; + @Resource private WarehouseLocationMapper locations; + + public WarehouseResourceLockEntity findByReservationKey(long tenantId, String reservationKey) { + return locks.findByReservationKey(tenantId, reservationKey); + } + + public WarehouseResourceLockEntity find(long tenantId, long lockId) { + return locks.selectOne(new LambdaQueryWrapper() + .eq(WarehouseResourceLockEntity::getTenantId, tenantId) + .eq(WarehouseResourceLockEntity::getId, lockId)); + } + + public WarehousePalletEntity findPallet(long tenantId, long warehouseId, long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getTenantId, tenantId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getId, palletId)); + } + + public WarehouseLocationEntity findLocation(long tenantId, long warehouseId, long locationId) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getTenantId, tenantId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getId, locationId)); + } + + public int occupyPallet(long tenantId, long warehouseId, long palletId, long locationId) { + return locks.occupyPallet(tenantId, warehouseId, palletId, locationId); + } + + public int occupyLocation(long tenantId, long warehouseId, long locationId) { + return locks.occupyLocation(tenantId, warehouseId, locationId); + } + + public void insert(WarehouseResourceLockEntity lock) { locks.insert(lock); } + + public int releasePallet(WarehouseResourceLockEntity lock) { + return locks.releasePallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + } + + public int scrapPallet(WarehouseResourceLockEntity lock) { + return locks.scrapPallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + } + + public int releaseLocation(WarehouseResourceLockEntity lock) { + return locks.releaseLocation(lock.getTenantId(), lock.getWarehouseId(), lock.getLocationId()); + } + + public int close(WarehouseResourceLockEntity lock, String status) { return locks.closeLock(lock.getId(), status); } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java new file mode 100644 index 0000000..8a8bfd4 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -0,0 +1,119 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import com.baiyue.warehouse.mapper.WarehousePalletMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; + +@Repository +public class WarehouseResourceRepository { + @Resource private WarehouseMapper warehouses; + @Resource private WarehouseLocationMapper locations; + @Resource private WarehousePalletMapper pallets; + + public boolean warehouseOwned(long tenantId, long warehouseId) { + return warehouses.selectCount(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getId, warehouseId)) > 0; + } + + public void insertLocation(WarehouseLocationEntity entity) { + locations.insert(entity); + } + + public void updateLocation(WarehouseLocationEntity entity) { + locations.updateById(entity); + } + + public WarehouseLocationEntity findLocation(long tenantId, long warehouseId, long id) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getTenantId, tenantId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getId, id)); + } + + public boolean locationCodeExists(long tenantId, long warehouseId, String code, Long excludedId) { + return locations.selectCount(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getTenantId, tenantId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, code) + .ne(excludedId != null, WarehouseLocationEntity::getId, excludedId)) > 0; + } + + public PageResult pageLocations(long tenantId, long warehouseId, + LocationRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getTenantId, tenantId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.zoneCode()), WarehouseLocationEntity::getZoneCode, query.zoneCode()) + .eq(StringUtils.hasText(query.locationType()), WarehouseLocationEntity::getLocationType, query.locationType()) + .eq(StringUtils.hasText(query.status()), WarehouseLocationEntity::getStatus, query.status()) + .and(StringUtils.hasText(query.keyword()), q -> q + .like(WarehouseLocationEntity::getLocationCode, query.keyword()) + .or().like(WarehouseLocationEntity::getZoneCode, query.keyword())) + .orderByAsc(WarehouseLocationEntity::getZoneCode) + .orderByAsc(WarehouseLocationEntity::getLocationCode); + return PageResult.from(locations.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public void deleteLocation(long id) { + locations.deleteById(id); + } + + public boolean locationHasPallets(long tenantId, long warehouseId, long locationId) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getTenantId, tenantId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getLocationId, locationId)) > 0; + } + + public void insertPallet(WarehousePalletEntity entity) { + pallets.insert(entity); + } + + public void updatePallet(WarehousePalletEntity entity) { + pallets.updateById(entity); + } + + public WarehousePalletEntity findPallet(long tenantId, long warehouseId, long id) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getTenantId, tenantId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getId, id)); + } + + public boolean palletCodeExists(long tenantId, long warehouseId, String code, Long excludedId) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getTenantId, tenantId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getPalletCode, code) + .ne(excludedId != null, WarehousePalletEntity::getId, excludedId)) > 0; + } + + public PageResult pagePallets(long tenantId, long warehouseId, + PalletRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getTenantId, tenantId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.palletType()), WarehousePalletEntity::getPalletType, query.palletType()) + .eq(StringUtils.hasText(query.status()), WarehousePalletEntity::getStatus, query.status()) + .eq(query.locationId() != null, WarehousePalletEntity::getLocationId, query.locationId()) + .like(StringUtils.hasText(query.keyword()), WarehousePalletEntity::getPalletCode, query.keyword()) + .orderByDesc(WarehousePalletEntity::getCreatedAt); + return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public void deletePallet(long id) { + pallets.deleteById(id); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java b/warehouse/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java new file mode 100644 index 0000000..34ec759 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final byte[] expected; + + public InternalServiceTokenGuard( + @Value("${baiyue.internal-service.token:phase1-local-internal-token-at-least-32-bytes}") String token) { + this.expected = token.getBytes(StandardCharsets.UTF_8); + } + + public void verify(String provided) { + byte[] actual = provided == null ? new byte[0] : provided.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, actual)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid internal service token"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java new file mode 100644 index 0000000..3175674 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java @@ -0,0 +1,121 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Service +public class WarehouseResourceLockService { + @Resource private WarehouseResourceLockRepository repository; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public WarehouseResourceLockResponse reserve(WarehouseResourceLockRequests.ReserveRequest request) { + WarehouseResourceLockEntity existing = repository.findByReservationKey(request.tenantId(), request.reservationKey()); + if (existing != null) return idempotentResult(existing, request); + + WarehousePalletEntity pallet = repository.findPallet(request.tenantId(), request.warehouseId(), request.palletId()); + WarehouseLocationEntity location = repository.findLocation(request.tenantId(), request.warehouseId(), request.locationId()); + if (pallet == null || location == null) { + WarehouseResourceLockEntity concurrent = + repository.findByReservationKey(request.tenantId(), request.reservationKey()); + if (concurrent != null) return idempotentResult(concurrent, request); + throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse"); + } + if (!"IDLE".equals(pallet.getStatus()) || !"IDLE".equals(location.getStatus())) { + WarehouseResourceLockEntity concurrent = + repository.findByReservationKey(request.tenantId(), request.reservationKey()); + if (concurrent != null) return idempotentResult(concurrent, request); + throw new BizException(ResultCode.CONFLICT, "Only IDLE pallet and location can be reserved"); + } + if (repository.occupyPallet(request.tenantId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) { + return concurrentIdempotentOrConflict(request); + } + if (repository.occupyLocation(request.tenantId(), request.warehouseId(), request.locationId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE"); + } + + WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity(); + lock.setTenantId(request.tenantId()); + lock.setWarehouseId(request.warehouseId()); + lock.setPalletId(request.palletId()); + lock.setLocationId(request.locationId()); + lock.setBusinessType(request.businessType()); + lock.setBusinessId(request.businessId()); + lock.setReservationKey(request.reservationKey()); + lock.setStatus("ACTIVE"); + lock.setLockedAt(LocalDateTime.now()); + repository.insert(lock); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehouseResourceLockResponse release(long tenantId, long lockId) { + WarehouseResourceLockEntity lock = requireLock(tenantId, lockId); + if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); + requireActive(lock); + if (repository.releasePallet(lock) != 1 || repository.releaseLocation(lock) != 1 + || repository.close(lock, "RELEASED") != 1) { + throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); + } + lock.setStatus("RELEASED"); + lock.setReleasedAt(LocalDateTime.now()); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehouseResourceLockResponse scrap(long tenantId, long lockId) { + WarehouseResourceLockEntity lock = requireLock(tenantId, lockId); + if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); + requireActive(lock); + if (repository.scrapPallet(lock) != 1 || repository.releaseLocation(lock) != 1 + || repository.close(lock, "SCRAPPED") != 1) { + throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); + } + lock.setStatus("SCRAPPED"); + lock.setReleasedAt(LocalDateTime.now()); + return WarehouseResourceLockResponse.from(lock); + } + + private WarehouseResourceLockResponse concurrentIdempotentOrConflict( + WarehouseResourceLockRequests.ReserveRequest request) { + WarehouseResourceLockEntity existing = repository.findByReservationKey(request.tenantId(), request.reservationKey()); + if (existing != null) return idempotentResult(existing, request); + throw new BizException(ResultCode.CONFLICT, "Pallet is no longer IDLE"); + } + + private WarehouseResourceLockResponse idempotentResult(WarehouseResourceLockEntity existing, + WarehouseResourceLockRequests.ReserveRequest request) { + if (existing.getWarehouseId() != request.warehouseId() + || existing.getPalletId() != request.palletId() + || existing.getLocationId() != request.locationId() + || !Objects.equals(existing.getBusinessType(), request.businessType()) + || !Objects.equals(existing.getBusinessId(), request.businessId())) { + throw new BizException(ResultCode.CONFLICT, "Reservation key was already used with different resources"); + } + return WarehouseResourceLockResponse.from(existing); + } + + private WarehouseResourceLockEntity requireLock(long tenantId, long lockId) { + WarehouseResourceLockEntity lock = repository.find(tenantId, lockId); + if (lock == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock not found"); + return lock; + } + + private static void requireActive(WarehouseResourceLockEntity lock) { + if (!"ACTIVE".equals(lock.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java new file mode 100644 index 0000000..a4329f2 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -0,0 +1,171 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; + +import java.util.Set; + +@Service +public class WarehouseResourceService { + private static final Set LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE"); + private static final Set PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED"); + + @Resource private WarehouseResourceRepository repository; + + @Transactional + public Long createLocation(long tenantId, long warehouseId, LocationRequests.SaveRequest request) { + requireWarehouse(tenantId, warehouseId); + validateLocation(request, tenantId, warehouseId, null); + WarehouseLocationEntity entity = locationEntity(tenantId, warehouseId, request); + repository.insertLocation(entity); + return entity.getId(); + } + + @Transactional + public void updateLocation(long tenantId, long warehouseId, long id, LocationRequests.SaveRequest request) { + WarehouseLocationEntity existing = requireLocation(tenantId, warehouseId, id); + validateLocation(request, tenantId, warehouseId, id); + WarehouseLocationEntity entity = locationEntity(tenantId, warehouseId, request); + entity.setId(existing.getId()); + repository.updateLocation(entity); + } + + public WarehouseLocationEntity location(long tenantId, long warehouseId, long id) { + requireWarehouse(tenantId, warehouseId); + return requireLocation(tenantId, warehouseId, id); + } + + public PageResult locations(long tenantId, long warehouseId, + LocationRequests.QueryRequest query) { + requireWarehouse(tenantId, warehouseId); + return repository.pageLocations(tenantId, warehouseId, query); + } + + @Transactional + public void deleteLocation(long tenantId, long warehouseId, long id) { + requireLocation(tenantId, warehouseId, id); + if (repository.locationHasPallets(tenantId, warehouseId, id)) { + throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除"); + } + repository.deleteLocation(id); + } + + @Transactional + public Long createPallet(long tenantId, long warehouseId, PalletRequests.SaveRequest request) { + requireWarehouse(tenantId, warehouseId); + validatePallet(request, tenantId, warehouseId, null); + WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request); + repository.insertPallet(entity); + return entity.getId(); + } + + @Transactional + public void updatePallet(long tenantId, long warehouseId, long id, PalletRequests.SaveRequest request) { + WarehousePalletEntity existing = requirePallet(tenantId, warehouseId, id); + validatePallet(request, tenantId, warehouseId, id); + WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request); + entity.setId(existing.getId()); + repository.updatePallet(entity); + } + + public WarehousePalletEntity pallet(long tenantId, long warehouseId, long id) { + requireWarehouse(tenantId, warehouseId); + return requirePallet(tenantId, warehouseId, id); + } + + public PageResult pallets(long tenantId, long warehouseId, + PalletRequests.QueryRequest query) { + requireWarehouse(tenantId, warehouseId); + return repository.pagePallets(tenantId, warehouseId, query); + } + + @Transactional + public void deletePallet(long tenantId, long warehouseId, long id) { + WarehousePalletEntity pallet = requirePallet(tenantId, warehouseId, id); + if ("OCCUPIED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除"); + } + repository.deletePallet(id); + } + + private void requireWarehouse(long tenantId, long warehouseId) { + if (!repository.warehouseOwned(tenantId, warehouseId)) { + throw new BizException(ResultCode.NOT_FOUND, "当前租户下仓库不存在"); + } + } + + private WarehouseLocationEntity requireLocation(long tenantId, long warehouseId, long id) { + requireWarehouse(tenantId, warehouseId); + WarehouseLocationEntity entity = repository.findLocation(tenantId, warehouseId, id); + if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下库位不存在"); + return entity; + } + + private WarehousePalletEntity requirePallet(long tenantId, long warehouseId, long id) { + requireWarehouse(tenantId, warehouseId); + WarehousePalletEntity entity = repository.findPallet(tenantId, warehouseId, id); + if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下托盘不存在"); + return entity; + } + + private void validateLocation(LocationRequests.SaveRequest request, long tenantId, + long warehouseId, Long excludedId) { + if (!LOCATION_STATUSES.contains(request.status())) { + throw new BizException("库位状态仅支持 ACTIVE、IDLE、OCCUPIED、INACTIVE"); + } + if (repository.locationCodeExists(tenantId, warehouseId, request.locationCode(), excludedId)) { + throw new BizException(ResultCode.CONFLICT, "当前仓库下库位编码已存在"); + } + } + + private void validatePallet(PalletRequests.SaveRequest request, long tenantId, + long warehouseId, Long excludedId) { + if (!PALLET_STATUSES.contains(request.status())) { + throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED"); + } + if (repository.palletCodeExists(tenantId, warehouseId, request.palletCode(), excludedId)) { + throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在"); + } + if (request.locationId() != null) { + requireLocation(tenantId, warehouseId, request.locationId()); + } + } + + private WarehouseLocationEntity locationEntity(long tenantId, long warehouseId, + LocationRequests.SaveRequest request) { + WarehouseLocationEntity entity = new WarehouseLocationEntity(); + entity.setTenantId(tenantId); + entity.setWarehouseId(warehouseId); + entity.setZoneCode(request.zoneCode()); + entity.setLocationCode(request.locationCode()); + entity.setLocationType(request.locationType()); + entity.setMaxLoadKg(request.maxLoadKg()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + return entity; + } + + private WarehousePalletEntity palletEntity(long tenantId, long warehouseId, + PalletRequests.SaveRequest request) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setTenantId(tenantId); + entity.setWarehouseId(warehouseId); + entity.setLocationId(request.locationId()); + entity.setPalletCode(request.palletCode()); + entity.setPalletType(request.palletType()); + entity.setCapacityCbm(request.capacityCbm()); + entity.setUnitPrice(request.unitPrice()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + return entity; + } +} diff --git a/warehouse/src/main/resources/application.yml b/warehouse/src/main/resources/application.yml index 763c8b9..c0c0d3c 100644 --- a/warehouse/src/main/resources/application.yml +++ b/warehouse/src/main/resources/application.yml @@ -1,6 +1,11 @@ server: port: ${WAREHOUSE_PORT:9305} spring: + sql: + init: + mode: always + encoding: UTF-8 + schema-locations: classpath:db/warehouse.sql data: redis: host: ${REDIS_HOST:127.0.0.1} @@ -24,7 +29,17 @@ spring: config.namespace: ${NACOS_NAMESPACE:public} datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} username: ${WAREHOUSE_DB_USERNAME:root} password: ${WAREHOUSE_DB_PASSWORD:} management.endpoints.web.exposure.include: health,info,prometheus + +baiyue: + internal-service: + token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + security: + jwt-mode: jwks + session-mode: claims + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index e9718cd..a22a904 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -1,6 +1,72 @@ -CREATE DATABASE IF NOT EXISTS baiyue_warehouse - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_warehouse; +-- This script is run through warehouse-service's datasource only. +-- The datasource guard requires the selected schema to be baiyue_lastlag_warehouse; +-- database creation and USE are intentionally forbidden here so a miswired +-- service account cannot create or switch schemas during startup. + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (tenant_id, warehouse_id, location_code, deleted), + KEY idx_location_query (tenant_id, warehouse_id, zone_code, status, deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (tenant_id, warehouse_id, pallet_code, deleted), + KEY idx_pallet_query (tenant_id, warehouse_id, status, deleted), + KEY idx_pallet_location (tenant_id, warehouse_id, location_id, deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT 'Snowflake resource lock ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT 'Owning tenant ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT 'Owning warehouse ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Reserved pallet ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT 'Reserved location ID', + business_type VARCHAR(50) NOT NULL COMMENT 'Calling business type', + business_id VARCHAR(100) NOT NULL COMMENT 'Calling business identifier', + reservation_key VARCHAR(160) NOT NULL COMMENT 'Caller supplied idempotency key', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE, RELEASED or SCRAPPED', + locked_at DATETIME(3) NOT NULL COMMENT 'Reservation time', + released_at DATETIME(3) DEFAULT NULL COMMENT 'Release or scrap time', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_resource_lock_reservation (tenant_id, reservation_key), + KEY idx_resource_lock_pallet (tenant_id, warehouse_id, pallet_id, status), + KEY idx_resource_lock_location (tenant_id, warehouse_id, location_id, status), + KEY idx_resource_lock_business (tenant_id, business_type, business_id) +) ENGINE=InnoDB COMMENT='Atomic pallet and location reservation records'; CREATE TABLE IF NOT EXISTS wh_warehouse ( id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', @@ -73,3 +139,101 @@ CREATE TABLE IF NOT EXISTS wh_service_capability ( ) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; -- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 tenant_id 与 warehouse_id 共同校验。 + +-- 修复既有数据库中因历史脚本编码错误或英文初始化产生的表与字段注释。 +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; +ALTER TABLE wh_warehouse + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话', + MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; +ALTER TABLE wh_warehouse_address + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码', + MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州', + MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市', + MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县', + MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码', + MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行', + MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度', + MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; +ALTER TABLE wh_service_capability + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货', + MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', + MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间', + MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; diff --git a/warehouse/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java b/warehouse/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java new file mode 100644 index 0000000..0717eca --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.config; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WarehouseDatabaseGuardConfigurationTest { + @Test + void acceptsDedicatedLastlagWarehouseDatabase() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.url", + "jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?serverTimezone=Asia/Shanghai"); + + assertThatCode(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) + .doesNotThrowAnyException(); + } + + @Test + void rejectsAnyOtherDatabaseBeforeSchemaInitialization() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.url", "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse"); + + assertThatThrownBy(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("baiyue_lastlag_warehouse"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java new file mode 100644 index 0000000..58545db --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java @@ -0,0 +1,69 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WarehouseResourceControllerTest { + private final WarehouseResourceService service = mock(WarehouseResourceService.class); + + @Test + void merchantTenantAlwaysComesFromAuthenticatedUser() { + MerchantWarehouseResourceController controller = merchantController(); + AuthenticatedUser merchant = user(10001L, "MERCHANT"); + WarehouseLocationEntity expected = new WarehouseLocationEntity(); + when(service.location(10001L, 20001L, 30001L)).thenReturn(expected); + + controller.location(merchant, 20001L, 30001L); + + verify(service).location(10001L, 20001L, 30001L); + } + + @Test + void platformApiRejectsNonPlatformIdentity() { + PlatformWarehouseResourceController controller = platformController(); + + assertThatThrownBy(() -> controller.location(user(10001L, "MERCHANT"), + 10002L, 20001L, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("平台端"); + } + + @Test + void platformApiUsesExplicitTargetTenant() { + PlatformWarehouseResourceController controller = platformController(); + WarehouseLocationEntity expected = new WarehouseLocationEntity(); + when(service.location(10002L, 20001L, 30001L)).thenReturn(expected); + + controller.location(user(1L, "PLATFORM"), 10002L, 20001L, 30001L); + + verify(service).location(10002L, 20001L, 30001L); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(1L, tenantId, tenantId, 1L, + "tester", appCode, "WEB", Set.of()); + } + + private MerchantWarehouseResourceController merchantController() { + MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } + + private PlatformWarehouseResourceController platformController() { + PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java b/warehouse/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java new file mode 100644 index 0000000..035b049 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseResourceLockSchemaTest { + @Test + void schemaContainsIdempotentResourceLockContract() throws IOException { + String sql; + try (var stream = getClass().getResourceAsStream("/db/warehouse.sql")) { + assertThat(stream).isNotNull(); + sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_resource_lock"); + assertThat(sql).contains("UNIQUE KEY uk_resource_lock_reservation (tenant_id, reservation_key)"); + assertThat(sql).contains("KEY idx_resource_lock_pallet (tenant_id, warehouse_id, pallet_id, status)"); + assertThat(sql).contains("KEY idx_resource_lock_location (tenant_id, warehouse_id, location_id, status)"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java b/warehouse/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java new file mode 100644 index 0000000..0f95713 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.security; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InternalServiceTokenGuardTest { + private final InternalServiceTokenGuard guard = + new InternalServiceTokenGuard("phase1-local-internal-token-at-least-32-bytes"); + + @Test + void acceptsConfiguredToken() { + assertThatCode(() -> guard.verify("phase1-local-internal-token-at-least-32-bytes")) + .doesNotThrowAnyException(); + } + + @Test + void rejectsMissingOrWrongToken() { + assertUnauthorized(null); + assertUnauthorized("wrong"); + } + + private void assertUnauthorized(String token) { + assertThatThrownBy(() -> guard.verify(token)) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java new file mode 100644 index 0000000..79575a9 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java @@ -0,0 +1,194 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseResourceLockServiceTest { + private static final long TENANT = 101L; + private static final long WAREHOUSE = 201L; + private static final long PALLET = 301L; + private static final long LOCATION = 401L; + + @Mock WarehouseResourceLockRepository repository; + private WarehouseResourceLockService service; + + @BeforeEach + void setUp() { + service = new WarehouseResourceLockService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void atomicallyOccupiesIdlePalletAndLocation() { + var request = request("reserve-1"); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(1); + doAnswer(call -> { + ((WarehouseResourceLockEntity) call.getArgument(0)).setId(501L); + return null; + }).when(repository).insert(any()); + + var result = service.reserve(request); + + assertThat(result.lockId()).isEqualTo(501L); + assertThat(result.status()).isEqualTo("ACTIVE"); + ArgumentCaptor lock = ArgumentCaptor.forClass(WarehouseResourceLockEntity.class); + verify(repository).insert(lock.capture()); + assertThat(lock.getValue().getReservationKey()).isEqualTo("reserve-1"); + assertThat(lock.getValue().getPalletId()).isEqualTo(PALLET); + assertThat(lock.getValue().getLocationId()).isEqualTo(LOCATION); + } + + @Test + void duplicateReservationReturnsExistingLockWithoutMutatingResources() { + WarehouseResourceLockEntity existing = activeLock(); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing); + + var result = service.reserve(request("reserve-1")); + + assertThat(result.lockId()).isEqualTo(existing.getId()); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + verify(repository, never()).insert(any()); + } + + @Test + void concurrentDuplicateReturnsExistingAfterResourcesBecomeOccupied() { + WarehouseResourceLockEntity existing = activeLock(); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(null, existing); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + + var result = service.reserve(request("reserve-1")); + + assertThat(result.lockId()).isEqualTo(existing.getId()); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + } + + @Test + void reservationKeyCannotBeReusedForDifferentResources() { + WarehouseResourceLockEntity existing = activeLock(); + existing.setPalletId(999L); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("different resources"); + } + + @Test + void rejectsResourceOutsideTenantWarehouse() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(null); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("tenant warehouse"); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + } + + @Test + void rejectsNonIdleResources() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("Only IDLE"); + } + + @Test + void releaseRestoresBothResourcesAndClosesLock() { + WarehouseResourceLockEntity lock = activeLock(); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.releasePallet(lock)).thenReturn(1); + when(repository.releaseLocation(lock)).thenReturn(1); + when(repository.close(lock, "RELEASED")).thenReturn(1); + + var result = service.release(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("RELEASED"); + verify(repository).releasePallet(lock); + verify(repository).releaseLocation(lock); + } + + @Test + void scrapDiscardsPalletAndReleasesLocation() { + WarehouseResourceLockEntity lock = activeLock(); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.scrapPallet(lock)).thenReturn(1); + when(repository.releaseLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrap(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + verify(repository).scrapPallet(lock); + verify(repository).releaseLocation(lock); + } + + @Test + void concurrentLocationClaimFailsWholeOperation() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(0); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("no longer IDLE"); + verify(repository, never()).insert(any()); + } + + private static WarehouseResourceLockRequests.ReserveRequest request(String key) { + return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION, + "INBOUND_PLAN_PALLET", "plan-pallet-1", key); + } + + private static WarehousePalletEntity pallet(String status) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setStatus(status); + return entity; + } + + private static WarehouseLocationEntity location(String status) { + WarehouseLocationEntity entity = new WarehouseLocationEntity(); + entity.setStatus(status); + return entity; + } + + private static WarehouseResourceLockEntity activeLock() { + WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity(); + lock.setId(501L); + lock.setTenantId(TENANT); + lock.setWarehouseId(WAREHOUSE); + lock.setPalletId(PALLET); + lock.setLocationId(LOCATION); + lock.setBusinessType("INBOUND_PLAN_PALLET"); + lock.setBusinessId("plan-pallet-1"); + lock.setReservationKey("reserve-1"); + lock.setStatus("ACTIVE"); + return lock; + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java new file mode 100644 index 0000000..2093085 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -0,0 +1,164 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseResourceServiceTest { + private static final long TENANT = 10001L; + private static final long WAREHOUSE = 20001L; + + @Mock + private WarehouseResourceRepository repository; + + private WarehouseResourceService service; + + @BeforeEach + void setUp() { + service = new WarehouseResourceService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void resourceIdsUseMybatisSnowflakeAssignment() throws NoSuchFieldException { + assertThat(WarehouseLocationEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type()) + .isEqualTo(IdType.ASSIGN_ID); + assertThat(WarehousePalletEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type()) + .isEqualTo(IdType.ASSIGN_ID); + } + + @Test + void createsLocationWithSnowflakeAssignedByMapper() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + doAnswer(invocation -> { + ((WarehouseLocationEntity) invocation.getArgument(0)).setId(2089000000000000001L); + return null; + }).when(repository).insertLocation(org.mockito.ArgumentMatchers.any()); + + Long id = service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")); + + assertThat(id).isEqualTo(2089000000000000001L); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertLocation(captor.capture()); + assertThat(captor.getValue().getTenantId()).isEqualTo(TENANT); + assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); + } + + @Test + void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(false); + + assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓库不存在"); + verify(repository, never()).insertLocation(org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsDuplicateLocationCodeWithinWarehouse() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.locationCodeExists(TENANT, WAREHOUSE, "A-01", null)).thenReturn(true); + + assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("库位编码已存在"); + } + + @Test + void palletDefaultsToTwoCubicMetresAndBindsOwnedLocation() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30001L); + when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(location); + doAnswer(invocation -> { + ((WarehousePalletEntity) invocation.getArgument(0)).setId(2089000000000000002L); + return null; + }).when(repository).insertPallet(org.mockito.ArgumentMatchers.any()); + + Long id = service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + new BigDecimal("12.50"), "IDLE", 30001L, null)); + + assertThat(id).isEqualTo(2089000000000000002L); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getCapacityCbm()).isEqualByComparingTo("2.0000"); + assertThat(captor.getValue().getLocationId()).isEqualTo(30001L); + } + + @Test + void rejectsLocationFromAnotherWarehouseWhenBindingPallet() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findLocation(TENANT, WAREHOUSE, 30002L)).thenReturn(null); + + assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + BigDecimal.ONE, "IDLE", 30002L, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("库位不存在"); + verify(repository, never()).insertPallet(org.mockito.ArgumentMatchers.any()); + } + + @Test + void preventsDeletingLocationThatStillHasPallets() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(new WarehouseLocationEntity()); + when(repository.locationHasPallets(TENANT, WAREHOUSE, 30001L)).thenReturn(true); + + assertThatThrownBy(() -> service.deleteLocation(TENANT, WAREHOUSE, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("仍绑定托盘"); + verify(repository, never()).deleteLocation(30001L); + } + + @Test + void preventsDeletingOccupiedPallet() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity pallet = new WarehousePalletEntity(); + pallet.setStatus("OCCUPIED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40001L)).thenReturn(pallet); + + assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("占用中的托盘"); + verify(repository, never()).deletePallet(40001L); + } + + @Test + void rejectsUnknownPalletStatus() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + + assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + BigDecimal.ONE, "BROKEN", null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("IDLE、OCCUPIED、SCRAPPED"); + } + + private static LocationRequests.SaveRequest locationRequest(String code) { + return new LocationRequests.SaveRequest("A", code, "STORAGE", + new BigDecimal("1000.000"), "ACTIVE", null); + } +}