diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 662b2cf74..d3d7e8542 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -16,9 +16,9 @@ | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | -| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | +| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | -| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | +| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java index d3921bd60..9a3734ce8 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -7,7 +7,6 @@ import java.util.Map; /** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ public record FirstLegContainerShippingNodeRecordedV1( String queryId, - String customerId, String oceanBookingId, @JsonProperty("sourceContainerBookingId") String containerBookingId, String containerNo, @@ -29,8 +28,7 @@ public record FirstLegContainerShippingNodeRecordedV1( "EMPTY_RETURNED", 100); public FirstLegContainerShippingNodeRecordedV1 { - if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() - || oceanBookingId == null || oceanBookingId.isBlank() + if (queryId == null || queryId.isBlank() || oceanBookingId == null || oceanBookingId.isBlank() || containerBookingId == null || containerBookingId.isBlank() || containerNo == null || containerNo.isBlank()) { throw new IllegalArgumentException("船节点交接业务标识不能为空"); diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java index 807c0829f..ceed06186 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -29,7 +29,7 @@ class FirstLegContainerShippingNodeRecordedV1Test { @Test void rejectsNonCanonicalPhysicalContainerNumber() { assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( - "1", "2", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80, + "1", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80, Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64))) .hasMessageContaining("normalized"); @@ -38,13 +38,13 @@ class FirstLegContainerShippingNodeRecordedV1Test { @Test void rejectsEstimatedOnlyOrIncompleteProviderFacts() { assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( - "1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, + "1", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, Instant.parse("2026-08-25T01:00:00Z"), null, null, null, "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间"); assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( - "1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60, + "1", "3", "4", "ZCSU7881970", "ARRIVED", 60, Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64))) .isInstanceOf(IllegalArgumentException.class); @@ -52,7 +52,7 @@ class FirstLegContainerShippingNodeRecordedV1Test { private static FirstLegContainerShippingNodeRecordedV1 payload() { return new FirstLegContainerShippingNodeRecordedV1( - "2088000000000000001", "2088000000000000002", "2088000000000000004", + "2088000000000000001", "2088000000000000004", "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80, Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"), "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE", diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 872160af5..7d925c740 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -47,7 +47,7 @@ | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | | 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | -| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | +| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java index 55fab5082..725e45ba4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -44,7 +44,7 @@ public class CarrierScheduleNodeOutboxService { } ZoneId zone = ZoneId.of(scheduleZone); var payload = new FirstLegContainerShippingNodeRecordedV1( - string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()), + string(query.getId()), string(query.getOceanBookingId()), string(query.getContainerBookingId()), normalizedContainerNo, fact.getStandardNode(), fact.getNodeSequence(), fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), @@ -57,12 +57,12 @@ public class CarrierScheduleNodeOutboxService { EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, - string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()), + "0", "CONTAINER_BOOKING", string(query.getContainerBookingId()), aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, payloadNode, payloadHash).requireValid(); InboundOutboxEntity row = new InboundOutboxEntity(); - row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId()); + row.setId(IdWorker.getId()); row.setTenantId(0L); row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId()); row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1); row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java index ee3d3041a..0a0c66761 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -50,6 +50,8 @@ class CarrierScheduleNodeOutboxServiceTest { assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); assertThat(envelope.eventVersion()).isEqualTo(1); assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(envelope.tenantId()).isEqualTo("0"); + assertThat(row.getTenantId()).isZero(); assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003"); assertThat(envelope.aggregateVersion()).isEqualTo(7); assertThat(envelope.handoverVersion()).isEqualTo(8); @@ -57,6 +59,7 @@ class CarrierScheduleNodeOutboxServiceTest { assertThat(envelope.payload().path("sourceContainerBookingId").asText()) .isEqualTo("2088000000000000003"); assertThat(envelope.payload().has("containerBookingId")).isFalse(); + assertThat(envelope.payload().has("customerId")).isFalse(); assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z"); } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index f255e4cd7..dbe8ac3c4 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -35,7 +35,7 @@ | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | -| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” | | 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | @@ -95,7 +95,7 @@ - Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 - 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 -- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 +- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 ## 查询与租户约束 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java index 18d2bfcac..e33c0ac3c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java @@ -13,7 +13,6 @@ public class ReceivingContainerOperation { @TableId(type = IdType.INPUT) private Long id; private Long sessionId; - private String customerId; private String operatorKey; private String action; private String idempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index 7b8ade622..17508259b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -15,7 +15,6 @@ import java.time.LocalDateTime; public class ReceivingContainerSession { @TableId(type = IdType.INPUT) private Long id; - private String customerId; private String sourceContainerBookingId; private String containerFactHash; private String containerNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 578da55e5..03f8b1ac9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -546,33 +546,34 @@ public class InboundPdaWorkflowService { @Transactional public PdaResponses.Task complete(String customerId, String planNo) { InboundPlan plan = requirePlan(customerId, planNo); - return completeContainer(customerId, List.of(plan)).get(0); + return completeContainer(List.of(plan)).get(0); } /** Completes every plan of one physical container and emits exactly one container-level outbox event. */ @Transactional - public List completeContainer(String customerId, List containerPlans) { + public List completeContainer(List containerPlans) { if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); List orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList(); String containerNo = orderedPlans.get(0).getContainerNo(); if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan -> - !customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) { - throw conflict("Inbound plans must belong to one customer and one physical container"); + plan.getCustomerId() == null || plan.getCustomerId().isBlank() || !containerNo.equals(plan.getContainerNo()))) { + throw conflict("Inbound plans must have an owning customer and belong to one physical container"); } - List contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList(); + List contexts = orderedPlans.stream() + .map(plan -> completionContext(plan.getCustomerId(), plan)).toList(); boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus())); boolean allCompleted = contexts.stream().allMatch(context -> "COMPLETED".equals(context.task().getStatus())); if (allCompleted) return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required"); LocalDateTime now = LocalDateTime.now(); - for (CompletionContext context : contexts) completePlan(customerId, context, now); + for (CompletionContext context : contexts) completePlan(context.plan().getCustomerId(), context, now); String sourceContainerBookingId = sourceContainerBookingId(contexts); LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) .max(LocalDateTime::compareTo).orElseThrow(); long aggregateVersion = contexts.stream().map(CompletionContext::task) .map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L); - unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo, + unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo, now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion); return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index 535f62079..60de90bae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -47,7 +47,7 @@ public class InboundUnloadCompletedOutboxService { if (task.getUnloadPhotoUploadedAt() == null) { throw new IllegalStateException("Inbound task has no unload photo upload time"); } - publishContainerCompleted(task.getCustomerId(), sourceOrder.getSourceContainerBookingId(), + publishContainerCompleted(sourceOrder.getSourceContainerBookingId(), plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC), task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC), Math.max(1, task.getVersion() == null ? 1 : task.getVersion())); @@ -56,10 +56,9 @@ public class InboundUnloadCompletedOutboxService { /** * 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。 */ - public void publishContainerCompleted(String customerId, String sourceContainerBookingId, - String containerNo, Instant unloadingCompletedAt, - Instant unloadingPhotoUploadedAt, long aggregateVersion) { - requireText(customerId, "customerId"); + public void publishContainerCompleted(String sourceContainerBookingId, + String containerNo, Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, long aggregateVersion) { requireText(sourceContainerBookingId, "sourceContainerBookingId"); requireText(containerNo, "containerNo"); Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt"); @@ -71,7 +70,7 @@ public class InboundUnloadCompletedOutboxService { String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId; LastlegEventOutbox existing = mapper.findByEventKey(eventKey); if (existing != null) { - requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo); + requireSameContainer(existing, sourceContainerBookingId, containerNo); return; } var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId, @@ -79,12 +78,12 @@ public class InboundUnloadCompletedOutboxService { JsonNode payloadNode = objectMapper.valueToTree(payload); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, - 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, customerId, "CONTAINER_BOOKING", + 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "0", "CONTAINER_BOOKING", sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); LastlegEventOutbox event = new LastlegEventOutbox(); - event.setId(IdWorker.getId()); event.setCustomerId(customerId); + event.setId(IdWorker.getId()); event.setCustomerId("0"); event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING"); @@ -94,17 +93,17 @@ public class InboundUnloadCompletedOutboxService { } catch (DuplicateKeyException exception) { LastlegEventOutbox raced = mapper.findByEventKey(eventKey); if (raced == null) throw exception; - requireSameContainer(raced, customerId, sourceContainerBookingId, containerNo); + requireSameContainer(raced, sourceContainerBookingId, containerNo); } } - private void requireSameContainer(LastlegEventOutbox existing, String customerId, + private void requireSameContainer(LastlegEventOutbox existing, String sourceContainerBookingId, String containerNo) { try { EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue( envelope.payload(), OverseasContainerUnloadedV1.class); - if (!Objects.equals(existing.getCustomerId(), customerId) + if (!"0".equals(existing.getCustomerId()) || !Objects.equals(existing.getAggregateId(), sourceContainerBookingId) || !Objects.equals(frozen.containerNo(), containerNo)) { throw new IllegalStateException("Unload-completed event key conflicts with another container"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 31ed2e281..4f6aac638 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -59,6 +59,7 @@ import java.util.stream.Collectors; @Service public class ReceivingContainerService { + private static final String PLATFORM_SCOPE = "0"; private static final String PHOTO_DOWNLOAD_PATH = "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; private static final String PALLET_MERGE_HINT = @@ -125,10 +126,9 @@ public class ReceivingContainerService { } public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { + requireReadyContainer(containerNo, overseasWarehouseId, false); List containerPlans = requirePlans(containerNo); - String customerId = requireSingleCustomer(containerPlans, containerNo); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); - requireReceivingAvailable(customerId, containerNo); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); if (current != null && !containerNo.equals(current.getContainerNo())) { @@ -141,7 +141,7 @@ public class ReceivingContainerService { WarehouseResourceGateway.WarehouseReference receivingWarehouse = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( - customerId, operatorKey, receivingWarehouse, containerNo), containerPlans); + operatorKey, receivingWarehouse, containerNo), containerPlans); } @Transactional @@ -158,9 +158,7 @@ public class ReceivingContainerService { return result; } List containerPlans = requirePlans(containerNo); - String customerId = requireSingleCustomer(containerPlans, containerNo); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); - requireReceivingAvailable(customerId, containerNo); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); requireSessionWarehouse(active, overseasWarehouseId); @@ -179,7 +177,7 @@ public class ReceivingContainerService { } LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); - session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setId(IdWorker.getId()); session.setContainerNo(containerNo); session.setSourceContainerBookingId(readiness.sourceContainerBookingId()); session.setContainerFactHash(readiness.factHash()); session.setOverseasWarehouseId(overseasWarehouseId); @@ -197,9 +195,8 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) { ReceivingContainerSession session = requireCurrent(operatorKey, true); - String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); - UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); + UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(PLATFORM_SCOPE, session.getId(), operatorId, file); return new ReceivingContainerResponses.Photo(Long.toString(uploaded.fileId()), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); } @@ -209,24 +206,24 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerSession session = requireCurrent(operatorKey, true); - String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); List photos = validateSignPhotos(request); List photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); - photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); + photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId)); LocalDateTime now = LocalDateTime.now(); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); - for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { - workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now); + List containerPlans = requirePlans(session.getContainerNo()); + for (InboundPlan plan : containerPlans) { + workflow.ensureSignedTask(plan.getCustomerId(), plan, operatorKey, operatorName, photoFileIds, now); } - ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo())); + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); save(session, "SIGN", request.idempotencyKey(), request, result); return result; } public ReceivingContainerResponses.Session current(String operatorKey) { ReceivingContainerSession session = requireCurrent(operatorKey, false); - return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); + return snapshot(session, requirePlans(session.getContainerNo())); } public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) { @@ -236,7 +233,7 @@ public class ReceivingContainerService { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Unload photo is not part of the current receiving session"); } - return unloadPhotos.download(session.getCustomerId(), session.getId(), parsedFileId); + return unloadPhotos.download(PLATFORM_SCOPE, session.getId(), parsedFileId); } public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) { @@ -246,9 +243,8 @@ public class ReceivingContainerService { public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); String inboundCode = cartonInboundCode(request.cartonMark()); - List matches = refs(customerId, session.getContainerNo()).stream() + List matches = refs(session.getContainerNo()).stream() .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); CartonRef carton; if (matches.size() > 1) { @@ -256,9 +252,9 @@ public class ReceivingContainerService { } else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) { carton = new CartonRef(matches.get(0)); } else if (matches.size() == 1) { - carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark()); } else { - carton = requireUnmatchedFbaCartonPrefix(customerId, inboundCode); + carton = requireUnmatchedFbaCartonPrefix(inboundCode); } InboundRef ref = carton.ref(); ReceivingContainerResponses.Inbound inbound = inbound(ref); @@ -273,8 +269,7 @@ public class ReceivingContainerService { public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false); WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), @@ -287,12 +282,12 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); + String customerId = ref.order().getCustomerId(); if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } - List fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref); + List fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref); List skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount()); skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), allocation.packages())); @@ -328,12 +323,14 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - List currentRefs = refs(customerId, session.getContainerNo()); + List currentRefs = refs(session.getContainerNo()); if (currentRefs.stream().noneMatch(value -> "OVERSEAS".equals(destinationType(value.line(), value.order())))) { throw conflict("FBA destination must use bulk count"); } + String inboundCode = cartonInboundCode(request.cartonMark()); + InboundRef inboundRef = requireInbound(session.getContainerNo(), inboundCode); + String customerId = inboundRef.order().getCustomerId(); CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); InboundRef ref = carton.ref(); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { @@ -367,12 +364,11 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - List containerPlans = requirePlans(customerId, session.getContainerNo()); + List containerPlans = requirePlans(session.getContainerNo()); Totals totals = totals(containerPlans); if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); - validateFbaSkuBalance(customerId, session.getContainerNo()); - workflow.completeContainer(customerId, containerPlans); + validateFbaSkuBalance(session.getContainerNo()); + workflow.completeContainer(containerPlans); LocalDateTime now = LocalDateTime.now(); session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session); @@ -446,7 +442,6 @@ public class ReceivingContainerService { Set warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName) .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); boolean warehouseSnapshotConsistent = warehouseCodes.size() == 1 && warehouseNames.size() == 1; - Set customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet()); String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null); int totalOrders = orderFacts.size(); int inboundCodes = (int) orderFacts.stream().map(LastlegOrder::getInboundCode).map(ReceivingContainerService::trim) @@ -456,14 +451,13 @@ public class ReceivingContainerService { return values.size() == 1 && plansById.containsKey(values.get(0).getPlanId()); }).count(); int planCount = matchedPlans.size(); - boolean allPlansReady = totalOrders > 0 && customers.size() == 1 && inboundCodes == totalOrders + boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders && plannedOrders == totalOrders && planCount == totalOrders; List containerPlans = planFacts.stream() .filter(plan -> containerNo.equals(plan.getContainerNo())).toList(); boolean allPlansCompleted = !containerPlans.isEmpty() && containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus())); - String customerId = customers.size() == 1 ? customers.iterator().next() : null; - boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1; + boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; int pending = Math.max(0, totalOrders - plannedOrders); String blockReason = operable ? null : (!warehouseSnapshotConsistent @@ -521,42 +515,28 @@ public class ReceivingContainerService { List values = plans.selectList(new LambdaQueryWrapper() .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); if (values.isEmpty()) throw notFound("Receiving container was not found"); - requireSingleCustomer(values, containerNo); + if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { + throw conflict("Every inbound plan in the receiving container must have an owning customer"); + } if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { throw conflict("Receiving container is already completed"); } return values; } - private List requirePlans(String customerId, String containerNo) { - List values = plansForContainer(customerId, containerNo); - if (values.isEmpty()) throw notFound("Receiving container was not found"); - if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { - throw conflict("Receiving container is already completed"); - } - return values; - } - - private List plansForContainer(String customerId, String containerNo) { + private List plansForContainer(String containerNo) { List values = plans.selectList(new LambdaQueryWrapper() - .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo) + .eq(InboundPlan::getContainerNo, containerNo) .orderByAsc(InboundPlan::getId)); if (values.isEmpty()) throw notFound("Receiving container was not found"); return values; } - private String requireSingleCustomer(List values, String containerNo) { - Set customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim) - .filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new)); - if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { - throw conflict("Receiving container must resolve to exactly one customer: " + containerNo); - } - return customers.iterator().next(); - } - - private List refs(String customerId, String containerNo) { + private List refs(String containerNo) { List result = new ArrayList<>(); - for (InboundPlan plan : plansForContainer(customerId, containerNo)) { + for (InboundPlan plan : plansForContainer(containerNo)) { + String customerId = trim(plan.getCustomerId()); + if (customerId == null) throw conflict("Inbound plan has no owning customer"); for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) .orderByAsc(InboundPlanLine::getId))) { @@ -568,8 +548,8 @@ public class ReceivingContainerService { return result; } - private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) { - List matches = refs(customerId, containerNo).stream() + private InboundRef requireInbound(String containerNo, String inboundCode) { + List matches = refs(containerNo).stream() .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); Set planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); @@ -581,7 +561,8 @@ public class ReceivingContainerService { InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)); if (carton == null) throw notFound("Carton mark was not found"); - List matches = refs(customerId, containerNo).stream() + List matches = refs(containerNo).stream() + .filter(value -> customerId.equals(value.order().getCustomerId())) .filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId())) .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList(); @@ -597,16 +578,15 @@ public class ReceivingContainerService { return inboundCode; } - private CartonRef requireUnmatchedFbaCartonPrefix(String customerId, String inboundCode) { + private CartonRef requireUnmatchedFbaCartonPrefix(String inboundCode) { boolean exists = lines.selectList(new LambdaQueryWrapper() - .eq(InboundPlanLine::getCustomerId, customerId) .eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent(); if (exists) throw conflict("Carton mark belongs to another container"); throw notFound("Carton mark was not found"); } - private List requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) { - List values = refs(customerId, containerNo).stream() + private List requireFbaPlanRefs(String containerNo, InboundRef selected) { + List values = refs(containerNo).stream() .filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId())) .sorted(Comparator.comparing(value -> value.line().getId())).toList(); if (values.isEmpty() || values.stream().anyMatch(value -> @@ -635,11 +615,12 @@ public class ReceivingContainerService { } private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) { + String normalizedPalletCode = palletCode.trim(); List reserved = pallets.selectList(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() .filter(value -> sameDestination(ref.line(), value)) - .filter(value -> palletCode.trim().equals(value.getPalletNo())) + .filter(value -> matchesPlannedPalletCode(value, normalizedPalletCode)) .filter(this::hasConfirmedReservation) .toList(); if (reserved.size() > 1) { @@ -651,11 +632,11 @@ public class ReceivingContainerService { pallet = reserved.get(0); long warehouseId = ref.plan().getOverseasWarehouseId() == null ? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId(); - resource = warehouse.resolveReservedPallet(palletCode.trim(), ref.order().getCustomerId(), warehouseId, + resource = warehouse.resolveReservedPallet(pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId()); } else { try { - resource = warehouse.resolveAvailablePallet(palletCode.trim()); + resource = warehouse.resolveAvailablePallet(normalizedPalletCode); } catch (IllegalArgumentException exception) { throw notFound("Pallet was not found"); } @@ -666,6 +647,11 @@ public class ReceivingContainerService { return new PalletSelection(pallet, resource); } + private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) { + return scannedCode.equals(trim(pallet.getPalletNo())) + || scannedCode.equals(trim(pallet.getSourcePalletNo())); + } + private boolean hasConfirmedReservation(InboundPlanPallet pallet) { return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0 && pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0 @@ -706,7 +692,7 @@ public class ReceivingContainerService { WarehouseResourceGateway.WarehousePalletReference resource) { } private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { - List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() + List inbounds = refs(session.getContainerNo()).stream() .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); @@ -749,8 +735,8 @@ public class ReceivingContainerService { } private Totals totals(List containerPlans) { - List values = refs(containerPlans.get(0).getCustomerId(), - containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); + List values = refs(containerPlans.get(0).getContainerNo()) + .stream().map(this::inbound).toList(); List destinationCodes = values.stream().map(this::destinationCode) .filter(Objects::nonNull).distinct().sorted().toList(); return new Totals(values.size(), destinationCodes, @@ -856,14 +842,15 @@ public class ReceivingContainerService { } private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) { - List existing = skuReceipts.lockLineSku(session.getCustomerId(), + String customerId = sku.ref().order().getCustomerId(); + List existing = skuReceipts.lockLineSku(customerId, sku.ref().line().getId(), sku.skuCode()); ReceivingSkuReceipt receipt = existing.stream() .filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null); LocalDateTime now = LocalDateTime.now(); if (receipt == null) { receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId()); - receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId()); + receipt.setCustomerId(customerId); receipt.setSessionId(session.getId()); receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); @@ -877,9 +864,10 @@ public class ReceivingContainerService { } } - private void validateFbaSkuBalance(String customerId, String containerNo) { - for (InboundRef ref : refs(customerId, containerNo)) { + private void validateFbaSkuBalance(String containerNo) { + for (InboundRef ref : refs(containerNo)) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; + String customerId = ref.order().getCustomerId(); List skuCodes = skuExpectations(ref); int received = 0; for (SkuExpectation sku : skuCodes) { @@ -914,10 +902,10 @@ public class ReceivingContainerService { if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); } - private ReceivingContainerSession waitingView(String customerId, String operatorKey, + private ReceivingContainerSession waitingView(String operatorKey, WarehouseResourceGateway.WarehouseReference warehouse, String containerNo) { - ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + ReceivingContainerSession value = new ReceivingContainerSession(); value.setOverseasWarehouseId(warehouse.warehouseId()); value.setOverseasWarehouseCode(warehouse.warehouseCode()); value.setOverseasWarehouseName(warehouse.warehouseName()); @@ -925,12 +913,6 @@ public class ReceivingContainerService { return value; } - private boolean eligible(List containerPlans, long overseasWarehouseId) { - String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo()); - return warehouseEligible(containerPlans, overseasWarehouseId) - && shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1; - } - private boolean warehouseEligible(List containerPlans, long overseasWarehouseId) { Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) .filter(Objects::nonNull).collect(Collectors.toSet()); @@ -956,12 +938,6 @@ public class ReceivingContainerService { } } - private void requireReceivingAvailable(String customerId, String containerNo) { - if (shippingGates.receivingAvailable(customerId, containerNo) != 1) { - throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving"); - } - } - private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) { if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) { throw conflict("Current container belongs to another overseas warehouse"); @@ -981,7 +957,7 @@ public class ReceivingContainerService { private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) { key = idempotencyKey(key); ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId()); - value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId()); + value.setSessionId(session.getId()); value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key); value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result)); value.setCreatedAt(LocalDateTime.now()); operations.insert(value); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 6ff108938..a227164d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -116,7 +116,7 @@ public class LoadingCompletedConsumer { private void freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId, String containerNo) { - shippingGates.initialize(sourceContainerBookingId, envelope.tenantId(), "", + shippingGates.initialize(sourceContainerBookingId, "", containerNo, 0, envelope.handoverVersion()); ContainerShippingGate frozen = shippingGates.lock(sourceContainerBookingId); if (frozen == null) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java index 26b248db4..23bb9111e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java @@ -12,7 +12,6 @@ import java.time.LocalDateTime; public class ContainerShippingGate { @TableId(type = IdType.INPUT) private String containerBookingId; - private String customerId; private String oceanBookingId; private String containerNo; private String status; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java index c505dca92..502508040 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java @@ -12,18 +12,17 @@ public interface ContainerShippingGateMapper extends BaseMapper captured = ArgumentCaptor.forClass(LastlegEventOutbox.class); @@ -57,6 +57,8 @@ class InboundUnloadCompletedOutboxServiceTest { assertThat(payload.unloadingCompletedAt()).isEqualTo(containerCompletedAt); assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(photoUploadedAt); assertThat(row.getEventKey()).isEqualTo(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"); + assertThat(row.getCustomerId()).isEqualTo("0"); + assertThat(envelope.tenantId()).isEqualTo("0"); assertThat(envelope.aggregateId()).isEqualTo("booking-1"); } @@ -87,11 +89,11 @@ class InboundUnloadCompletedOutboxServiceTest { @Test void repeatedContainerCompletionDoesNotPublishAnotherEvent() { - LastlegEventOutbox existing = existing("100", "booking-1", "MSCU6639870"); + LastlegEventOutbox existing = existing("0", "booking-1", "MSCU6639870"); when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1")) .thenReturn(existing); - service.publishContainerCompleted("100", "booking-1", "MSCU6639870", + service.publishContainerCompleted("booking-1", "MSCU6639870", Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8); verify(mapper, never()).insert(any(LastlegEventOutbox.class)); @@ -99,11 +101,11 @@ class InboundUnloadCompletedOutboxServiceTest { @Test void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() { - LastlegEventOutbox existing = existing("100", "booking-1", "MSCU6639870"); + LastlegEventOutbox existing = existing("0", "booking-1", "MSCU6639870"); when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1")) .thenReturn(existing); - assertThatThrownBy(() -> service.publishContainerCompleted("100", "booking-1", "ZCSU7881970", + assertThatThrownBy(() -> service.publishContainerCompleted("booking-1", "ZCSU7881970", Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("conflicts with another container"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 197463131..e170f209f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -93,7 +93,7 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"); when(orders.selectList(any())).thenReturn(List.of(order)); - when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0); + when(shippingGates.receivingAvailable("BOOKING-A", "CONT-A")).thenReturn(0); assertThat(service.candidates("7", 100L)).isEmpty(); assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", @@ -118,7 +118,6 @@ class ReceivingContainerServiceBehaviorTest { assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX"); ArgumentCaptor selected = ArgumentCaptor.forClass(ReceivingContainerSession.class); verify(sessions).insert(selected.capture()); - assertThat(selected.getValue().getCustomerId()).isEqualTo("100"); assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7"); assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A"); ArgumentCaptor saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class); @@ -131,7 +130,7 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() { + void pickerCandidatesAllowMultipleCustomersInTheSameContainer() { InboundPlan first = plan("CONT-A"); InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200"); when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second)); @@ -149,7 +148,7 @@ class ReceivingContainerServiceBehaviorTest { when(orders.selectList(any())).thenReturn(List.of( containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"), containerOrder(2L, "200", "CONT-A", "BOOKING-A", "IN-2"))); - assertThat(service.candidates("7", 100L)).isEmpty(); + assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A"); } @Test @@ -311,6 +310,9 @@ class ReceivingContainerServiceBehaviorTest { @Test void detailRejectsContainerReservedByAnotherOperator() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectList(any())).thenReturn(List.of( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); ReceivingContainerSession other = session("CONT-B", "SIGNED"); other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); other.setActiveContainerKey("CONTAINER:CONT-B"); @@ -324,6 +326,9 @@ class ReceivingContainerServiceBehaviorTest { @Test void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectList(any())).thenReturn(List.of( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); @@ -397,8 +402,6 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); stubCurrent("OVERSEAS", "CONT-A"); - when(cartons.selectOne(any())).thenReturn(carton(1L, "BOX-1")); - when(pallets.selectList(any())).thenReturn(List.of(planPallet())); assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key"))) .isInstanceOf(ResponseStatusException.class); @@ -429,7 +432,7 @@ class ReceivingContainerServiceBehaviorTest { when(allocations.selectList(any())).thenReturn(List.of(partial)); assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); - verify(workflow, never()).completeContainer(any(), any()); + verify(workflow, never()).completeContainer(any()); } @Test @@ -492,6 +495,39 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.ArgumentMatchers.same(occupied)); } + @Test + void bulkReceivingAcceptsFrozenSourcePalletCodeForItsPreReservedResource() { + stubCurrent("FBA", "CONT-A"); + stubSku(); + InboundPlanPallet reserved = planPallet(); + reserved.setPalletNo("PAL-RESERVED"); + reserved.setSourcePalletNo("CC001"); + reserved.setWarehousePalletId(30L); + reserved.setWarehouseLocationId(40L); + reserved.setWarehouseResourceLockId(50L); + reserved.setAssignmentStatus("RESERVED"); + when(pallets.selectList(any())).thenReturn(List.of(reserved)); + WarehouseResourceGateway.WarehousePalletReference occupied = + new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-RESERVED", 100L, 40L, + "OCCUPIED", "FBA-1", 0); + when(warehouse.resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L)) + .thenReturn(occupied); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenReturn(1); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + var result = service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "CC001", 1, "source-code-key")); + + assertThat(result.palletCode()).isEqualTo("PAL-RESERVED"); + verify(warehouse).resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L); + verify(warehouse, never()).resolveAvailablePallet("CC001"); + verify(workflow).assignResolvedPallet(org.mockito.ArgumentMatchers.eq("100"), + any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved), + org.mockito.ArgumentMatchers.same(occupied)); + } + @Test void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); @@ -516,9 +552,9 @@ class ReceivingContainerServiceBehaviorTest { "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2092476334483931138/download", "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2/download", "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/3/download"); - verify(unloadPhotos).validate("100", 8L, 2092476334483931138L); - verify(unloadPhotos).validate("100", 8L, 2L); - verify(unloadPhotos).validate("100", 8L, 3L); + verify(unloadPhotos).validate("0", 8L, 2092476334483931138L); + verify(unloadPhotos).validate("0", 8L, 2L); + verify(unloadPhotos).validate("0", 8L, 3L); } @Test @@ -558,7 +594,7 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void currentSignedPhotoCanBeDownloadedOnlyThroughItsOperatorCustomerAndSessionScope() { + void currentSignedPhotoCanBeDownloadedOnlyThroughItsPlatformSessionScope() { ReceivingContainerSession current = session("CONT-A", "SIGNED"); current.setSignPhotos("[{\"role\":\"LICENSE_PLATE\",\"fileId\":\"9\"}," + "{\"role\":\"CONTAINER_SEAL\",\"fileId\":\"10\"}," @@ -566,10 +602,10 @@ class ReceivingContainerServiceBehaviorTest { when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current); UnloadPhotoFileGateway.DownloadedFile expected = new UnloadPhotoFileGateway.DownloadedFile("image".getBytes(), "plate.jpg", "image/jpeg"); - when(unloadPhotos.download("100", 8L, 9L)).thenReturn(expected); + when(unloadPhotos.download("0", 8L, 9L)).thenReturn(expected); assertThat(service.downloadCurrentPhoto("7", "9")).isSameAs(expected); - verify(unloadPhotos).download("100", 8L, 9L); + verify(unloadPhotos).download("0", 8L, 9L); } @Test @@ -781,7 +817,7 @@ class ReceivingContainerServiceBehaviorTest { private static ReceivingContainerSession session(String containerNo, String status) { ReceivingContainerSession value = new ReceivingContainerSession(); - value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7"); + value.setId(8L); value.setContainerNo(containerNo); value.setOperatorKey("7"); value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseName("Shared LAX"); value.setStatus(status); value.setVersion(0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 54f964f57..021ef7d9f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -4,17 +4,17 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; 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,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); 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.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",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(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=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(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=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(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } - @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU6639870",0L,1L); } + @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=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(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=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(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),anyString(),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } + @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.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 tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=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(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index aff149eb8..2df7d6fb5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -113,7 +113,7 @@ class ContainerShippingNodeConsumerTest { private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) { ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1"); - gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("MSCU6639870"); + gate.setOceanBookingId("OB-1"); gate.setContainerNo("MSCU6639870"); gate.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version); gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt, java.time.ZoneOffset.UTC)); return gate; @@ -121,12 +121,12 @@ class ContainerShippingNodeConsumerTest { private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) { FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1( - "Q-1", "100", "OB-1", "CB-1", "MSCU6639870", node, sequence, actualAt, + "Q-1", "OB-1", "CB-1", "MSCU6639870", node, sequence, actualAt, actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64)); JsonNode payloadNode = json.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, - actualAt, RegionCode.CN, RegionCode.OVERSEAS, "100", "CONTAINER_BOOKING", "CB-1", + actualAt, RegionCode.CN, RegionCode.OVERSEAS, "0", "CONTAINER_BOOKING", "CB-1", version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null, payloadNode, hash); }